2121 * instantiation, so the hydrate spec can drive post-hydration updates.
2222 * - Keep async delays short (5-15ms) — the specs own the settle waits.
2323 */
24- import { createSignal , createMemo , Show , For , Loading } from "solid-js" ;
24+ import { createSignal , createMemo , Show , For , Loading , Errored } from "solid-js" ;
25+ import { Portal } from "@solidjs/web" ;
2526
2627const sleep = ( ms : number ) => new Promise ( r => setTimeout ( r , ms ) ) ;
2728
@@ -30,6 +31,12 @@ export type Scenario = {
3031 App : ( ) => any ;
3132 /** textContent of the container once hydration fully settles */
3233 expectedText : string ;
34+ /**
35+ * Tokens the server-rendered HTML must contain. Defaults to expectedText —
36+ * override for scenarios with client-only content (e.g. Portal), where the
37+ * settled client DOM legitimately contains text the server never rendered.
38+ */
39+ serverText ?: string ;
3340 /** client-side update trigger (rebound on every App instantiation) */
3441 update ?: ( ) => void ;
3542 expectedTextAfterUpdate ?: string ;
@@ -364,6 +371,119 @@ function FalsyAndProp() {
364371 return < ValueCard value = { val ( ) && val ( ) ! . toUpperCase ( ) } /> ;
365372}
366373
374+ // ---------------------------------------------------------------------------
375+ // Portal: client-only island (#2876). Server renders nothing for the portal;
376+ // the client renders its children fresh once hydration settles. Mounting into
377+ // a host inside the container lets the harness textContent assertion cover
378+ // the portal content. The host is a signal so the `mount` prop compiles as a
379+ // getter — a bare identifier would be captured (undefined) at creation time,
380+ // before the ref assigns.
381+ //
382+ // The host <section> sits OUTSIDE the <article> that owns the click handler,
383+ // so the update's synthetic click on the portal content can only reach the
384+ // handler through `_$host` logical retargeting — which requires the portal's
385+ // tree anchor to actually connect during hydration. (A dead anchor still
386+ // mounts visible content; this is the assertion that catches it, since real
387+ // DOM bubbling from the host would never touch the <article>.)
388+ let setPortalMsg ! : ( v : string ) => void ;
389+ let clickPortalContent ! : ( ) => void ;
390+ function PortalClientIsland ( ) {
391+ const [ msg , set ] = createSignal ( "modal" ) ;
392+ const [ clicks , setClicks ] = createSignal ( 0 ) ;
393+ const [ host , setHost ] = createSignal < HTMLElement > ( ) ;
394+ setPortalMsg = set ;
395+ let b ! : HTMLElement ;
396+ clickPortalContent = ( ) => b . click ( ) ;
397+ return (
398+ < div >
399+ < article onClick = { ( ) => setClicks ( c => c + 1 ) } >
400+ < span > page </ span >
401+ < span > clicks:{ clicks ( ) } </ span >
402+ < Portal mount = { host ( ) } >
403+ < b ref = { b } > { msg ( ) } </ b >
404+ </ Portal >
405+ </ article >
406+ < section ref = { setHost } />
407+ </ div >
408+ ) ;
409+ }
410+
411+ // ---------------------------------------------------------------------------
412+ // Portal with async children under a settled Loading boundary. The async is
413+ // discovered only after hydration settles (the portal gate defers children);
414+ // the initialized boundary must forward the pending status without regressing
415+ // the page to its fallback, and the portal content pops in when it resolves.
416+ let refreshPortalAsync ! : ( ) => void ;
417+ function PortalAsyncContent ( ) {
418+ const [ version , setVersion ] = createSignal ( 0 ) ;
419+ const [ host , setHost ] = createSignal < HTMLElement > ( ) ;
420+ refreshPortalAsync = ( ) => setVersion ( v => v + 1 ) ;
421+ const data = createMemo ( async ( ) => {
422+ const v = version ( ) ;
423+ await sleep ( 10 ) ;
424+ return v ? `late-${ v } ` : "late" ;
425+ } ) ;
426+ return (
427+ < Loading fallback = { < p > loading</ p > } >
428+ < div >
429+ < span > body </ span >
430+ < Portal mount = { host ( ) } >
431+ < em > { data ( ) } </ em >
432+ </ Portal >
433+ < section ref = { setHost } />
434+ </ div >
435+ </ Loading >
436+ ) ;
437+ }
438+
439+ // ---------------------------------------------------------------------------
440+ // Portal BEFORE id-allocating siblings. The portal's client-side primitives
441+ // allocate hydration ids that the server (which renders nothing) never did —
442+ // unless both sides advance the parent counter identically, every id after
443+ // the portal drifts: the sibling condition's <h4> _hk no longer matches and
444+ // the async memo looks up (or worse, adopts) a serialized value that belongs
445+ // to a different primitive.
446+ let refreshPortalSibling ! : ( ) => void ;
447+ function PortalBeforeSiblings ( ) {
448+ const [ version , setVersion ] = createSignal ( 0 ) ;
449+ const [ host , setHost ] = createSignal < HTMLElement > ( ) ;
450+ refreshPortalSibling = ( ) => setVersion ( v => v + 1 ) ;
451+ const [ shown ] = createSignal ( true ) ;
452+ const data = createMemo ( async ( ) => {
453+ const v = version ( ) ;
454+ await sleep ( 5 ) ;
455+ return v ? `srv-${ v } ` : "srv" ;
456+ } ) ;
457+ return (
458+ < Loading fallback = { < p > loading</ p > } >
459+ < div >
460+ < span > lead </ span >
461+ < Portal mount = { host ( ) } > pop</ Portal >
462+ { shown ( ) && < h4 > head </ h4 > }
463+ < span > { data ( ) } </ span >
464+ < section ref = { setHost } />
465+ </ div >
466+ </ Loading >
467+ ) ;
468+ }
469+
470+ // ---------------------------------------------------------------------------
471+ // Portal under Errored — the exact #2876 report shape. The old server throw
472+ // was caught by the boundary and baked the error fallback into the stream;
473+ // the no-op server Portal must render the real content around it.
474+ function PortalUnderErrored ( ) {
475+ const [ host , setHost ] = createSignal < HTMLElement > ( ) ;
476+ return (
477+ < Errored fallback = { < p > err-fallback</ p > } >
478+ < div >
479+ < span > safe </ span >
480+ < Portal mount = { host ( ) } > tip</ Portal >
481+ < section ref = { setHost } />
482+ </ div >
483+ </ Errored >
484+ ) ;
485+ }
486+
367487export const scenarios : Scenario [ ] = [
368488 {
369489 name : "text-hole" ,
@@ -508,5 +628,44 @@ export const scenarios: Scenario[] = [
508628 update : ( ) => setVal ( "hi" ) ,
509629 expectedTextAfterUpdate : "set:HI" ,
510630 stableSelector : "div"
631+ } ,
632+ {
633+ name : "portal-client-island" ,
634+ App : PortalClientIsland ,
635+ expectedText : "page clicks:0 modal" ,
636+ serverText : "page clicks:0" ,
637+ update : ( ) => {
638+ setPortalMsg ( "MODAL" ) ;
639+ clickPortalContent ( ) ;
640+ } ,
641+ expectedTextAfterUpdate : "page clicks:1 MODAL" ,
642+ stableSelector : "div, span, section"
643+ } ,
644+ {
645+ name : "portal-async-content" ,
646+ App : PortalAsyncContent ,
647+ async : true ,
648+ expectedText : "body late" ,
649+ serverText : "body" ,
650+ update : ( ) => refreshPortalAsync ( ) ,
651+ expectedTextAfterUpdate : "body late-1" ,
652+ stableSelector : "span, section"
653+ } ,
654+ {
655+ name : "portal-before-siblings" ,
656+ App : PortalBeforeSiblings ,
657+ async : true ,
658+ expectedText : "lead head srvpop" ,
659+ serverText : "lead head srv" ,
660+ update : ( ) => refreshPortalSibling ( ) ,
661+ expectedTextAfterUpdate : "lead head srv-1pop" ,
662+ stableSelector : "h4, section"
663+ } ,
664+ {
665+ name : "portal-under-errored" ,
666+ App : PortalUnderErrored ,
667+ expectedText : "safe tip" ,
668+ serverText : "safe" ,
669+ stableSelector : "div, span, section"
511670 }
512671] ;
0 commit comments