You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In Solid 2.0.0-beta.16 streaming SSR, a lazy() component rendered outside a <Loading> boundary can lose its hydration module map if it appears after that boundary in document order.
The invariant I expect: a lazy component that is a root-level sibling of a boundary should serialize into the root _assets map, no matter whether it comes before or after the boundary.
That invariant currently fails for this common shape:
<Loading><LazyProductRoute/></Loading><LazySupportWidget/>// outside the boundary, but after it
What happens:
LazySupportWidget renders its server HTML correctly.
Its <link rel="modulepreload"> is emitted, so the module was registered.
But its module URL is filed under the previous boundary's asset map instead of the root asset map.
That boundary asset map was already serialized before the later sibling registered, and the final re-emit is reference-deduped.
The final payload contains no usable ./SupportWidget.tsx -> /assets/support-widget.js mapping, so the client cannot resolve the lazy module and the widget stays as inert HTML.
The control is just moving the same root-level lazy sibling before the boundary:
In that order, the root _assets map is emitted correctly. So the bug is not that the lazy component cannot render or cannot register assets; it is that a pending boundary leaks its asset-attribution scope to later siblings.
Since almost every streaming app has a pending Suspense/Loading boundary around its route content, lazy siblings after it in document order (footers, below-the-fold sections, modals) can hit this. The page looks fine in the streamed HTML, and there is no visible warning, but that lazy island never hydrates.
SSR runs under Node, not a browser — open the StackBlitz and read the terminal. It auto-runs npm run repro and logs PASS/FAIL with expected vs actual. The bug reproduces on published 2.0.0-beta.16 and was re-verified locally at nextef4d53ea. The reproduction (src/repro.tsx):
/** @jsxImportSource@solidjs/web */import{renderToStream}from"@solidjs/web";import{createMemo,lazy,Loading}from"solid-js";typeModule<T>={default: T};functiondelay<T>(value: T,ms=10): Promise<T>{returnnewPromise(resolve=>setTimeout(()=>resolve(value),ms));}functionrenderComplete(code: ()=>unknown,options: Parameters<typeofrenderToStream>[1]){returnnewPromise<string>(resolve=>{renderToStream(code,options).then(resolve);});}constmanifest={"./ProductRoute.tsx": {file: "assets/product-route.js"},"./SupportWidget.tsx": {file: "assets/support-widget.js"}};functionProductRoute(){constproduct=createMemo(async()=>{awaitdelay(undefined,20);return{name: "Trail Pack",price: "$129"};});return(<maindata-page="product"><h1>{product().name}</h1><p>{product().price}</p></main>);}functionSupportWidget(){return(<asidedata-widget="support"><button>Chat with support</button></aside>);}constLazyProductRoute=lazy(()=>delay<Module<typeofProductRoute>>({default: ProductRoute}),"./ProductRoute.tsx");constLazySupportWidget=lazy(()=>delay<Module<typeofSupportWidget>>({default: SupportWidget}),"./SupportWidget.tsx");functionBrokenApp(){return(<html><head><title>Product</title></head><body><Loadingfallback={<p>Loading product...</p>}><LazyProductRoute/></Loading>{/* Root-level lazy sibling after the pending Loading boundary. */}<LazySupportWidget/></body></html>);}functionControlApp(){return(<html><head><title>Product</title></head><body>{/* Same root-level lazy component, but before the boundary. */}<LazySupportWidget/><Loadingfallback={<p>Loading product...</p>}><LazyProductRoute/></Loading></body></html>);}functiongetScripts(html: string){return(html.match(/<script[^>]*>[\s\S]*?<\/script>/g)||[]).join("\n");}functiongetAssetMaps(html: string){constscripts=getScripts(html);constassetMaps=[
...scripts.matchAll(/_\$HY\.r\["([^"]*_assets)"\]=[^;]*/g)].map(match=>({key: match[1],text: match[0]}));return{rootAssets: assetMaps.find(map=>map.key==="_assets")?.text??"",allAssets: assetMaps.map(map=>map.text),supportAssetMaps: assetMaps.filter(map=>map.text.includes("./SupportWidget.tsx")).map(map=>map.text),hasSupportPreload: html.includes("/assets/support-widget.js"),hasSupportHtml: html.includes('data-widget="support"')};}functionprintResult(label: string,html: string,shouldHaveRootSupportAsset: boolean){constresult=getAssetMaps(html);consthasRootSupportAsset=result.rootAssets.includes("./SupportWidget.tsx");consthasAnySupportAsset=result.supportAssetMaps.length>0;constok=result.hasSupportHtml&&result.hasSupportPreload&&hasRootSupportAsset===shouldHaveRootSupportAsset&&hasAnySupportAsset===shouldHaveRootSupportAsset;console.log(`\n=== ${label} ===`);console.log(ok ? "PASS" : "FAIL");console.log("support HTML rendered:",result.hasSupportHtml);console.log("support modulepreload emitted:",result.hasSupportPreload);console.log("support module appears in any serialized asset map:",hasAnySupportAsset);console.log("expected root asset map contains ./SupportWidget.tsx:",shouldHaveRootSupportAsset);console.log("actual root _assets:",result.rootAssets||"(none)");console.log("support asset maps:",result.supportAssetMaps.length ? result.supportAssetMaps.join(" | ") : "(none)");console.log("all asset maps:",result.allAssets.length ? result.allAssets.join(" | ") : "(none)");console.log("html:",html);returnok;}awaitLazyProductRoute.preload();awaitLazySupportWidget.preload();constbrokenHtml=awaitrenderComplete(()=><BrokenApp/>,{ manifest });constcontrolHtml=awaitrenderComplete(()=><ControlApp/>,{ manifest });constbrokenOk=printResult("broken layout: lazy support widget after pending route boundary",brokenHtml,true);constcontrolOk=printResult("control layout: lazy support widget before pending route boundary",controlHtml,true);console.log("\n=== Summary ===");console.log(brokenOk
? "PASS broken layout: root lazy asset was serialized"
: "FAIL broken layout: support widget HTML/preload exist, but no usable lazy asset mapping was serialized");console.log(controlOk
? "PASS control layout: root lazy asset was serialized"
: "FAIL control layout: root lazy asset mapping is missing");if(!brokenOk||!controlOk)process.exitCode=1;
The StackBlitz is preconfigured to run src/repro.tsx through the server runtime (vite-node + vite-plugin-solid with solid: { generate: "ssr", hydratable: true }) — just read the terminal output.
Steps to Reproduce the Bug or Issue
Open the StackBlitz terminal.
Run npm run repro.
The repro renders two layouts:
broken: route boundary first, lazy support widget after it
control: the same lazy support widget before the route boundary
Actual output on 2.0.0-beta.16: the broken layout fails, while the order-only control passes.
=== Summary ===
FAIL broken layout: support widget HTML/preload exist, but no usable lazy asset mapping was serialized
PASS control layout: root lazy asset was serialized
The broken layout reports the important facts separately:
support HTML rendered: true
support modulepreload emitted: true
support module appears in any serialized asset map: false
actual root _assets: (none)
support asset maps: (none)
So the widget did render, and the server did discover its JS file, but the hydration payload has no module mapping the client can use.
Observed HTML for the broken layout (trimmed to the relevant parts):
The only _assets entries in the entire payload are:
_$HY.r["1_assets"]=($R[5]={"./ProductRoute.tsx":"/assets/product-route.js"}) ← boundary map, serialized at boundary commit
_$HY.r["1_assets"]=$R[5] ← re-emitted at stream end: same deduped object, still no SupportWidget
There is no _$HY.r["_assets"] root map anywhere, and "./SupportWidget.tsx" appears in no map. The client has no way to resolve the widget's module, so LazySupportWidget never hydrates.
The control layout printed in the same run (<LazySupportWidget/> before the boundary) instead emits the root map:
Sibling content rendered outside the boundary is attributed to the root scope regardless of document order — i.e. the original layout serializes the same maps as the control:
=== Summary ===
PASS broken layout: root lazy asset was serialized
PASS control layout: root lazy asset was serialized
Version: 2.0.0-beta.16 (verified locally at nextef4d53ea — still reproduces after the 2.0.0-beta.15 Many hydration bugs #2801 hole-id-scope and settled-boundary hydration fixes landed)
Additional context
Root cause: createLoadingBoundary in packages/solid/src/server/hydration.ts:112 intends to scope asset attribution to a buffered child context:
But _currentBoundaryId is not a plain property — applyAssetTracking (dom-expressions src/server.js:94-103) defines it on the root context as an accessor over a single shared tracking.currentBoundaryId:
Object.defineProperty(context,"_currentBoundaryId",{get(){returntracking.currentBoundaryId;},set(v){tracking.currentBoundaryId=v;},// ← inherited via the prototype chain
...
});
Since bufferedCtx prototypally inherits from ctx, the assignment on line 101 invokes the inherited setter instead of shadowing the property — it mutates the shared tracking state for every context. Nothing restores it after the boundary's setup returns, so all later registerModule calls (dom-expressions server.js:75-82 keys the map by currentBoundaryId || "") file their modules under the stale boundary id. The boundary's map was already serialized by commitBoundaryState (hydration.ts:110-114) when the boundary flushed, and the end-of-stream re-serialization of the same object is deduped by seroval, so the late entry is unrecoverable.
Suggested fix direction: actually shadow the property on the buffered context (e.g. Object.defineProperty(bufferedCtx, "_currentBoundaryId", { value: id }), or make asset tracking read the id off the current rendering context instead of shared mutable state), and/or save-and-restore ctx._currentBoundaryId around the boundary's render phases. Either restores correct attribution for post-boundary siblings; nested boundaries would also want the previous id restored rather than reset to root.
Related but distinct: #2801 ("Many hydration bugs") — none of its six numbered bugs covers this boundary-id leak, and the #2801 fixes on next (098876d8, 5bc90802) do not resolve this repro.
Does this exist in Solid 1.x?
Not applicable — architecture-specific to 2.0. The per-boundary asset map / _currentBoundaryId serialization scheme is new in 2.0; solid-js 1.x keys lazy hydration structurally and has no equivalent shared mutable boundary-id state to leak.
Describe the bug
In Solid 2.0.0-beta.16 streaming SSR, a
lazy()component rendered outside a<Loading>boundary can lose its hydration module map if it appears after that boundary in document order.The invariant I expect: a lazy component that is a root-level sibling of a boundary should serialize into the root
_assetsmap, no matter whether it comes before or after the boundary.That invariant currently fails for this common shape:
What happens:
LazySupportWidgetrenders its server HTML correctly.<link rel="modulepreload">is emitted, so the module was registered../SupportWidget.tsx -> /assets/support-widget.jsmapping, so the client cannot resolve the lazy module and the widget stays as inert HTML.The control is just moving the same root-level lazy sibling before the boundary:
In that order, the root
_assetsmap is emitted correctly. So the bug is not that the lazy component cannot render or cannot register assets; it is that a pending boundary leaks its asset-attribution scope to later siblings.Since almost every streaming app has a pending Suspense/Loading boundary around its route content, lazy siblings after it in document order (footers, below-the-fold sections, modals) can hit this. The page looks fine in the streamed HTML, and there is no visible warning, but that lazy island never hydrates.
Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-8wrs36lh?file=src%2Frepro.tsx
SSR runs under Node, not a browser — open the StackBlitz and read the terminal. It auto-runs
npm run reproand logsPASS/FAILwith expected vs actual. The bug reproduces on published2.0.0-beta.16and was re-verified locally atnextef4d53ea. The reproduction (src/repro.tsx):The StackBlitz is preconfigured to run
src/repro.tsxthrough the server runtime (vite-node+vite-plugin-solidwithsolid: { generate: "ssr", hydratable: true }) — just read the terminal output.Steps to Reproduce the Bug or Issue
npm run repro.2.0.0-beta.16: the broken layout fails, while the order-only control passes.The broken layout reports the important facts separately:
So the widget did render, and the server did discover its JS file, but the hydration payload has no module mapping the client can use.
Observed HTML for the broken layout (trimmed to the relevant parts):
The only
_assetsentries in the entire payload are:There is no
_$HY.r["_assets"]root map anywhere, and"./SupportWidget.tsx"appears in no map. The client has no way to resolve the widget's module, soLazySupportWidgetnever hydrates.<LazySupportWidget/>before the boundary) instead emits the root map:Expected behavior
Sibling content rendered outside the boundary is attributed to the root scope regardless of document order — i.e. the original layout serializes the same maps as the control:
and
LazySupportWidgethydrates on the client.Screenshots or Videos
No response
Platform
nextef4d53ea— still reproduces after the 2.0.0-beta.15 Many hydration bugs #2801 hole-id-scope and settled-boundary hydration fixes landed)Additional context
Root cause:
createLoadingBoundaryinpackages/solid/src/server/hydration.ts:112intends to scope asset attribution to a buffered child context:But
_currentBoundaryIdis not a plain property —applyAssetTracking(dom-expressionssrc/server.js:94-103) defines it on the root context as an accessor over a single sharedtracking.currentBoundaryId:Since
bufferedCtxprototypally inherits fromctx, the assignment on line 101 invokes the inherited setter instead of shadowing the property — it mutates the shared tracking state for every context. Nothing restores it after the boundary's setup returns, so all laterregisterModulecalls (dom-expressionsserver.js:75-82keys the map bycurrentBoundaryId || "") file their modules under the stale boundary id. The boundary's map was already serialized bycommitBoundaryState(hydration.ts:110-114) when the boundary flushed, and the end-of-stream re-serialization of the same object is deduped by seroval, so the late entry is unrecoverable.Suggested fix direction: actually shadow the property on the buffered context (e.g.
Object.defineProperty(bufferedCtx, "_currentBoundaryId", { value: id }), or make asset tracking read the id off the current rendering context instead of shared mutable state), and/or save-and-restorectx._currentBoundaryIdaround the boundary's render phases. Either restores correct attribution for post-boundary siblings; nested boundaries would also want the previous id restored rather than reset to root.Related but distinct: #2801 ("Many hydration bugs") — none of its six numbered bugs covers this boundary-id leak, and the #2801 fixes on
next(098876d8,5bc90802) do not resolve this repro.Does this exist in Solid 1.x?
Not applicable — architecture-specific to 2.0. The per-boundary asset map /
_currentBoundaryIdserialization scheme is new in 2.0; solid-js 1.x keys lazy hydration structurally and has no equivalent shared mutable boundary-id state to leak.