@@ -4,7 +4,9 @@ import React, {
44 hydrate ,
55 Fragment ,
66 Suspense ,
7+ lazy ,
78 memo ,
9+ useId ,
810 useState
911} from 'preact/compat' ;
1012import { logCall , getLog , clearLog } from '../../../test/_util/logCall' ;
@@ -15,6 +17,7 @@ import {
1517} from '../../../test/_util/helpers' ;
1618import { ul , li , div } from '../../../test/_util/dom' ;
1719import { createLazy , createSuspenseLoader } from './suspense-utils' ;
20+ import { renderToString , renderToStringAsync } from 'preact-render-to-string' ;
1821
1922/* eslint-env browser, mocha */
2023describe ( 'suspense hydration' , ( ) => {
@@ -73,6 +76,196 @@ describe('suspense hydration', () => {
7376 }
7477 } ) ;
7578
79+ it ( 'is stable for async Suspense siblings resolving in different orders' , async ( ) => {
80+ const getIds = html =>
81+ Object . fromEntries (
82+ [ ...html . matchAll ( / < s p a n i d = " ( [ ^ " ] + ) " > ( [ A B ] ) < \/ s p a n > / g) ] . map (
83+ ( [ , id , name ] ) => [ name , id ]
84+ )
85+ ) ;
86+
87+ async function renderWithResolveOrder ( order ) {
88+ const loaders = { } ;
89+
90+ function Field ( { name } ) {
91+ const id = useId ( ) ;
92+ return < span id = { id } > { name } </ span > ;
93+ }
94+
95+ const createLazy = name =>
96+ lazy (
97+ ( ) =>
98+ new Promise ( resolve => {
99+ loaders [ name ] = ( ) =>
100+ resolve ( { default : ( ) => < Field name = { name } /> } ) ;
101+ } )
102+ ) ;
103+
104+ const A = createLazy ( 'A' ) ;
105+ const B = createLazy ( 'B' ) ;
106+ const rendered = renderToStringAsync (
107+ < div >
108+ < Suspense fallback = { null } >
109+ < A />
110+ </ Suspense >
111+ < Suspense fallback = { null } >
112+ < B />
113+ </ Suspense >
114+ </ div >
115+ ) ;
116+
117+ await Promise . resolve ( ) ;
118+ order . some ( name => loaders [ name ] ( ) ) ;
119+
120+ return getIds ( await rendered ) ;
121+ }
122+
123+ const ordered = await renderWithResolveOrder ( [ 'A' , 'B' ] ) ;
124+ const reversed = await renderWithResolveOrder ( [ 'B' , 'A' ] ) ;
125+
126+ expect ( new Set ( Object . values ( ordered ) ) . size ) . to . equal ( 2 ) ;
127+ expect ( new Set ( Object . values ( reversed ) ) . size ) . to . equal ( 2 ) ;
128+ expect ( reversed ) . to . deep . equal ( ordered ) ;
129+ } ) ;
130+
131+ it ( 'is stable for nested async Suspense siblings resolving in different orders' , async ( ) => {
132+ const getIds = html =>
133+ Object . fromEntries (
134+ [ ...html . matchAll ( / < s p a n i d = " ( [ ^ " ] + ) " > ( [ A B ] ) < \/ s p a n > / g) ] . map (
135+ ( [ , id , name ] ) => [ name , id ]
136+ )
137+ ) ;
138+
139+ async function renderWithResolveOrder ( order ) {
140+ const loaders = { } ;
141+
142+ function Field ( { name } ) {
143+ const id = useId ( ) ;
144+ return < span id = { id } > { name } </ span > ;
145+ }
146+
147+ const createLazy = name =>
148+ lazy (
149+ ( ) =>
150+ new Promise ( resolve => {
151+ loaders [ name ] = ( ) =>
152+ resolve ( { default : ( ) => < Field name = { name } /> } ) ;
153+ } )
154+ ) ;
155+
156+ const A = createLazy ( 'A' ) ;
157+ const B = createLazy ( 'B' ) ;
158+ const rendered = renderToStringAsync (
159+ < Suspense fallback = { null } >
160+ < Suspense fallback = { null } >
161+ < A />
162+ </ Suspense >
163+ < Suspense fallback = { null } >
164+ < B />
165+ </ Suspense >
166+ </ Suspense >
167+ ) ;
168+
169+ await Promise . resolve ( ) ;
170+ order . some ( name => loaders [ name ] ( ) ) ;
171+
172+ return getIds ( await rendered ) ;
173+ }
174+
175+ const ordered = await renderWithResolveOrder ( [ 'A' , 'B' ] ) ;
176+ const reversed = await renderWithResolveOrder ( [ 'B' , 'A' ] ) ;
177+
178+ expect ( ordered ) . to . deep . equal ( { A : 'P1-0' , B : 'P2-0' } ) ;
179+ expect ( reversed ) . to . deep . equal ( ordered ) ;
180+ } ) ;
181+
182+ it ( 'does not leak Suspense useId masks across abandoned renderToString attempts' , ( ) => {
183+ const idsIn = html => [ ...html . matchAll ( / P \d + - \d + / g) ] . map ( ( [ id ] ) => id ) ;
184+
185+ function Field ( ) {
186+ return < i > { useId ( ) } </ i > ;
187+ }
188+
189+ function Suspends ( ) {
190+ throw Promise . resolve ( ) ;
191+ }
192+
193+ const tree = ( ) => (
194+ < >
195+ < Suspense fallback = { null } >
196+ < Field />
197+ </ Suspense >
198+ < Suspense fallback = { null } >
199+ < Field />
200+ </ Suspense >
201+ </ >
202+ ) ;
203+
204+ const first = idsIn ( renderToString ( tree ( ) ) ) ;
205+ expect ( first ) . to . deep . equal ( [ 'P0-0' , 'P1-0' ] ) ;
206+
207+ expect ( ( ) =>
208+ renderToString (
209+ < Suspense fallback = { null } >
210+ < Suspends />
211+ </ Suspense >
212+ )
213+ ) . to . throw ( / r e n d e r T o S t r i n g A s y n c / ) ;
214+
215+ expect ( idsIn ( renderToString ( tree ( ) ) ) ) . to . deep . equal ( first ) ;
216+ } ) ;
217+
218+ it ( 'keeps deeply nested Suspense useId masks compact' , async ( ) => {
219+ function Field ( ) {
220+ const id = useId ( ) ;
221+ return < span id = { id } > field</ span > ;
222+ }
223+
224+ const Wrapper = ( { children } ) => children ;
225+ let child = (
226+ < Suspense fallback = { null } >
227+ < Field />
228+ </ Suspense >
229+ ) ;
230+
231+ for ( let i = 0 ; i < 10 ; i ++ ) {
232+ child = < Wrapper > { child } </ Wrapper > ;
233+ }
234+
235+ const html = await renderToStringAsync (
236+ < Suspense fallback = { null } > { child } </ Suspense >
237+ ) ;
238+
239+ expect ( html ) . to . equal ( '<span id="P1-0">field</span>' ) ;
240+ } ) ;
241+
242+ it ( 'keeps nested Suspense ids distinct from parent useId calls' , async ( ) => {
243+ const ids = [ ] ;
244+
245+ function Field ( ) {
246+ ids . push ( useId ( ) ) ;
247+ return < span id = { ids [ 1 ] } > field</ span > ;
248+ }
249+
250+ function Wrapper ( ) {
251+ ids . push ( useId ( ) ) ;
252+ return (
253+ < Suspense fallback = { null } >
254+ < Field />
255+ </ Suspense >
256+ ) ;
257+ }
258+
259+ await renderToStringAsync (
260+ < Suspense fallback = { null } >
261+ < Wrapper />
262+ </ Suspense >
263+ ) ;
264+
265+ expect ( ids [ 0 ] ) . to . equal ( 'P0-0' ) ;
266+ expect ( ids [ 1 ] ) . to . equal ( 'P1-0' ) ;
267+ } ) ;
268+
76269 it ( 'should leave DOM untouched when suspending while hydrating' , ( ) => {
77270 scratch . innerHTML = '<div>Hello</div>' ;
78271 clearLog ( ) ;
0 commit comments