@@ -5,10 +5,20 @@ import { isWindows } from 'std-env'
55import { normalize } from 'pathe'
66import { $fetch , fetch , setup , startServer } from '@nuxt/test-utils/e2e'
77import type { NuxtIslandResponse } from 'nuxt/app'
8+ import { computeIslandHash , filterIslandProps } from '../packages/nuxt/src/app/island-hash'
89
910import { isDev , isWebpack } from './matrix'
1011import { renderPage } from './utils'
1112
13+ function islandURL ( name : string , opts : { props ?: Record < string , any > , context ?: Record < string , any > } = { } ) {
14+ const filtered = filterIslandProps ( opts . props ?? { } )
15+ const ctx = opts . context ?? { }
16+ const hashId = computeIslandHash ( name , filtered , ctx , undefined )
17+ const query : Record < string , any > = { ...ctx }
18+ if ( opts . props ) { query . props = JSON . stringify ( opts . props ) }
19+ return withQuery ( `/__nuxt_island/${ name } _${ hashId } .json` , query )
20+ }
21+
1222await setup ( {
1323 rootDir : fileURLToPath ( new URL ( './fixtures/server-components' , import . meta. url ) ) ,
1424 dev : isDev ,
@@ -171,7 +181,7 @@ describe('server components/islands', () => {
171181
172182describe ( 'component islands' , ( ) => {
173183 it ( 'renders components with route' , async ( ) => {
174- const result = await $fetch < NuxtIslandResponse > ( '/__nuxt_island/ RouteComponent.json? url= /foo')
184+ const result = await $fetch < NuxtIslandResponse > ( islandURL ( ' RouteComponent' , { context : { url : ' /foo' } } ) )
175185
176186 result . html = result . html . replace ( / d a t a - i s l a n d - u i d = " [ ^ " ] * " / g, '' )
177187 if ( isDev ) {
@@ -180,6 +190,7 @@ describe('component islands', () => {
180190
181191 result . head . link ||= [ ]
182192 result . head . style ||= [ ]
193+ delete result . id
183194
184195 expect ( result ) . toMatchInlineSnapshot ( `
185196 {
@@ -194,18 +205,15 @@ describe('component islands', () => {
194205 } )
195206
196207 it ( 'render async component' , async ( ) => {
197- const result = await $fetch < NuxtIslandResponse > ( withQuery ( '/__nuxt_island/LongAsyncComponent.json' , {
198- props : JSON . stringify ( {
199- count : 3 ,
200- } ) ,
201- } ) )
208+ const result = await $fetch < NuxtIslandResponse > ( islandURL ( 'LongAsyncComponent' , { props : { count : 3 } } ) )
202209 if ( isDev ) {
203210 result . head . link = result . head . link ?. filter ( l => typeof l . href !== 'string' || ( ! l . href . includes ( '_nuxt/components/islands/LongAsyncComponent' ) && ! l . href . includes ( 'PureComponent' ) /* TODO: fix dev bug triggered by previous fetch of /islands */ ) )
204211 }
205212
206213 result . head . link ||= [ ]
207214 result . head . style ||= [ ]
208215 result . html = result . html . replaceAll ( / (?: d a t a - i s l a n d - u i d | d a t a - i s l a n d - c o m p o n e n t ) = " [ ^ " ] * " / g, '' )
216+ delete result . id
209217 expect ( result ) . toMatchInlineSnapshot ( `
210218 {
211219 "head": {
@@ -255,11 +263,7 @@ describe('component islands', () => {
255263 } )
256264
257265 it ( 'render .server async component' , async ( ) => {
258- const result = await $fetch < NuxtIslandResponse > ( withQuery ( '/__nuxt_island/AsyncServerComponent.json' , {
259- props : JSON . stringify ( {
260- count : 2 ,
261- } ) ,
262- } ) )
266+ const result = await $fetch < NuxtIslandResponse > ( islandURL ( 'AsyncServerComponent' , { props : { count : 2 } } ) )
263267 if ( isDev ) {
264268 result . head . link = result . head . link ?. filter ( l => typeof l . href === 'string' && ! l . href . includes ( 'PureComponent' ) /* TODO: fix dev bug triggered by previous fetch of /islands */ && ( ! l . href . startsWith ( '_nuxt/components/islands/' ) || l . href . includes ( 'AsyncServerComponent' ) ) )
265269 }
@@ -270,6 +274,7 @@ describe('component islands', () => {
270274 result . components = { }
271275 result . slots = { }
272276 result . html = result . html . replaceAll ( / (?: d a t a - i s l a n d - u i d | d a t a - i s l a n d - c o m p o n e n t ) = " [ ^ " ] * " / g, '' )
277+ delete result . id
273278
274279 expect ( result ) . toMatchInlineSnapshot ( `
275280 {
@@ -287,7 +292,7 @@ describe('component islands', () => {
287292
288293 if ( ! isWebpack ) {
289294 it ( 'render server component with selective client hydration' , async ( ) => {
290- const result = await $fetch < NuxtIslandResponse > ( '/__nuxt_island/ ServerWithClient')
295+ const result = await $fetch < NuxtIslandResponse > ( islandURL ( ' ServerWithClient') )
291296 if ( isDev ) {
292297 result . head . link = result . head . link ?. filter ( l => typeof l . href !== 'string' || ( ! l . href . includes ( '_nuxt/components/islands/LongAsyncComponent' ) && ! l . href . includes ( 'PureComponent' ) /* TODO: fix dev bug triggered by previous fetch of /islands */ ) )
293298
@@ -304,6 +309,7 @@ describe('component islands', () => {
304309
305310 result . head . link ||= [ ]
306311 result . head . style ||= [ ]
312+ delete result . id
307313
308314 expect ( result ) . toMatchInlineSnapshot ( `
309315 {
@@ -327,13 +333,13 @@ describe('component islands', () => {
327333 }
328334
329335 it ( 'renders pure components' , async ( ) => {
330- const result = await $fetch < NuxtIslandResponse > ( withQuery ( '/__nuxt_island/ PureComponent.json ', {
331- props : JSON . stringify ( {
336+ const result = await $fetch < NuxtIslandResponse > ( islandURL ( ' PureComponent', {
337+ props : {
332338 bool : false ,
333339 number : 3487 ,
334340 str : 'something' ,
335341 obj : { foo : 42 , bar : false , me : 'hi' } ,
336- } ) ,
342+ } ,
337343 } ) )
338344 result . html = result . html . replace ( / d a t a - i s l a n d - u i d = " [ ^ " ] * " / g, '' )
339345
@@ -461,6 +467,41 @@ describe('component islands', () => {
461467 } )
462468} )
463469
470+ describe ( 'hash binding' , ( ) => {
471+ it ( 'accepts a request whose URL hash matches the props' , async ( ) => {
472+ const res = await fetch ( islandURL ( 'PureComponent' , {
473+ props : { bool : false , number : 1 , str : 's' , obj : { } } ,
474+ } ) )
475+ expect ( res . status ) . toBe ( 200 )
476+ } )
477+
478+ it ( 'rejects a request whose URL hash was computed over different props' , async ( ) => {
479+ // Compute a valid hash for one set of props, then swap the actual query props.
480+ const url = islandURL ( 'PureComponent' , {
481+ props : { bool : false , number : 1 , str : 's' , obj : { } } ,
482+ } )
483+ const tampered = url . replace ( / p r o p s = [ ^ & ] + / , 'props=' + encodeURIComponent ( JSON . stringify ( {
484+ bool : true , number : 999 , str : '<script>x</script>' , obj : { evil : true } ,
485+ } ) ) )
486+ const res = await fetch ( tampered )
487+ expect ( res . status ) . toBe ( 400 )
488+ } )
489+
490+ it ( 'rejects a request with a fabricated hash' , async ( ) => {
491+ const res = await fetch ( withQuery ( '/__nuxt_island/PureComponent_deadbeefcafef00d.json' , {
492+ props : JSON . stringify ( { bool : false , number : 1 , str : 's' , obj : { } } ) ,
493+ } ) )
494+ expect ( res . status ) . toBe ( 400 )
495+ } )
496+
497+ it ( 'rejects a request with no hash segment in the URL' , async ( ) => {
498+ const res = await fetch ( withQuery ( '/__nuxt_island/PureComponent.json' , {
499+ props : JSON . stringify ( { bool : false , number : 1 , str : 's' , obj : { } } ) ,
500+ } ) )
501+ expect ( res . status ) . toBe ( 400 )
502+ } )
503+ } )
504+
464505describe . skipIf ( isDev || isWebpack ) ( 'regressions' , ( ) => {
465506 // https://github.com/nuxt/nuxt/issues/26527
466507 it . fails ( 'renders <Counter nuxt-client /> when nested two levels deep in server components' , async ( ) => {
0 commit comments