Skip to content

2.0.0-beta.16: streaming SSR — a root-level lazy() after a pending <Loading> renders HTML but never hydrates #2860

Description

@yumemi-thomas

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 _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:

  1. LazySupportWidget renders its server HTML correctly.
  2. Its <link rel="modulepreload"> is emitted, so the module was registered.
  3. But its module URL is filed under the previous boundary's asset map instead of the root asset map.
  4. That boundary asset map was already serialized before the later sibling registered, and the final re-emit is reference-deduped.
  5. 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:

<LazySupportWidget />

<Loading>
  <LazyProductRoute />
</Loading>

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.

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 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 next ef4d53ea. The reproduction (src/repro.tsx):

/** @jsxImportSource @solidjs/web */
import { renderToStream } from "@solidjs/web";
import { createMemo, lazy, Loading } from "solid-js";

type Module<T> = { default: T };

function delay<T>(value: T, ms = 10): Promise<T> {
  return new Promise(resolve => setTimeout(() => resolve(value), ms));
}

function renderComplete(code: () => unknown, options: Parameters<typeof renderToStream>[1]) {
  return new Promise<string>(resolve => {
    renderToStream(code, options).then(resolve);
  });
}

const manifest = {
  "./ProductRoute.tsx": { file: "assets/product-route.js" },
  "./SupportWidget.tsx": { file: "assets/support-widget.js" }
};

function ProductRoute() {
  const product = createMemo(async () => {
    await delay(undefined, 20);
    return { name: "Trail Pack", price: "$129" };
  });

  return (
    <main data-page="product">
      <h1>{product().name}</h1>
      <p>{product().price}</p>
    </main>
  );
}

function SupportWidget() {
  return (
    <aside data-widget="support">
      <button>Chat with support</button>
    </aside>
  );
}

const LazyProductRoute = lazy(
  () => delay<Module<typeof ProductRoute>>({ default: ProductRoute }),
  "./ProductRoute.tsx"
);

const LazySupportWidget = lazy(
  () => delay<Module<typeof SupportWidget>>({ default: SupportWidget }),
  "./SupportWidget.tsx"
);

function BrokenApp() {
  return (
    <html>
      <head>
        <title>Product</title>
      </head>
      <body>
        <Loading fallback={<p>Loading product...</p>}>
          <LazyProductRoute />
        </Loading>

        {/* Root-level lazy sibling after the pending Loading boundary. */}
        <LazySupportWidget />
      </body>
    </html>
  );
}

function ControlApp() {
  return (
    <html>
      <head>
        <title>Product</title>
      </head>
      <body>
        {/* Same root-level lazy component, but before the boundary. */}
        <LazySupportWidget />

        <Loading fallback={<p>Loading product...</p>}>
          <LazyProductRoute />
        </Loading>
      </body>
    </html>
  );
}

function getScripts(html: string) {
  return (html.match(/<script[^>]*>[\s\S]*?<\/script>/g) || []).join("\n");
}

function getAssetMaps(html: string) {
  const scripts = getScripts(html);
  const assetMaps = [
    ...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"')
  };
}

function printResult(label: string, html: string, shouldHaveRootSupportAsset: boolean) {
  const result = getAssetMaps(html);
  const hasRootSupportAsset = result.rootAssets.includes("./SupportWidget.tsx");
  const hasAnySupportAsset = result.supportAssetMaps.length > 0;
  const ok =
    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);

  return ok;
}

await LazyProductRoute.preload();
await LazySupportWidget.preload();

const brokenHtml = await renderComplete(() => <BrokenApp />, { manifest });
const controlHtml = await renderComplete(() => <ControlApp />, { manifest });

const brokenOk = printResult(
  "broken layout: lazy support widget after pending route boundary",
  brokenHtml,
  true
);

const controlOk = 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

  1. Open the StackBlitz terminal.
  2. Run npm run repro.
  3. The repro renders two layouts:
    • broken: route boundary first, lazy support widget after it
    • control: the same lazy support widget before the route boundary
  4. 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):

<html _hk=0><head><title>Product</title><link rel="modulepreload" href="/assets/product-route.js"><link rel="modulepreload" href="/assets/support-widget.js"></head><body><!--$--><main data-page="product" _hk=10001><h1>Trail Pack</h1><p>$129</p></main><!--/--><!--$--><aside data-widget="support" _hk=20><button>Chat with support</button></aside><!--/--></body></html><script>…_$HY.r["1_assets"]=($R[5]={"./ProductRoute.tsx":"/assets/product-route.js"});…_$HY.r["1_assets"]=$R[5];…</script>

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.

  1. The control layout printed in the same run (<LazySupportWidget/> before the boundary) instead emits the root map:
_$HY.r["2_assets"]=($R[5]={"./ProductRoute.tsx":"/assets/product-route.js"})
_$HY.r["2_assets"]=$R[5]
_$HY.r["_assets"]=($R[7]={"./SupportWidget.tsx":"/assets/support-widget.js"})

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:

=== Summary ===
PASS broken layout: root lazy asset was serialized
PASS control layout: root lazy asset was serialized
_$HY.r["1_assets"]={"./ProductRoute.tsx":"/assets/product-route.js"}
_$HY.r["_assets"]={"./SupportWidget.tsx":"/assets/support-widget.js"}

and LazySupportWidget hydrates on the client.

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Runtime: Node.js
  • Version: 2.0.0-beta.16 (verified locally at next ef4d53ea — 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:

const bufferedCtx = Object.create(ctx) as typeof ctx;
bufferedCtx.serialize = (id, value, deferStream) => { ... };
bufferedCtx._currentBoundaryId = id;   // hydration.ts:112

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() { return tracking.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions