From 7946b8b5e90f03c6cc1b138b571af1b09e09f1cc Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 22:39:36 +0530 Subject: [PATCH 1/3] docs: give the real reason a Suspense fallback is read inline at SSR Two doc surfaces explained the inline `.fallback` read by saying a TemplateResult is not serializer-safe. That is true but secondary, and it points at a fixable-looking obstacle: a reader could conclude that making TemplateResult serializable would unlock the `data-webjs-prop-*` path, and spend real time on it. The load-bearing constraint is timing. `_hydratePropAttrs()` runs from `connectedCallback`, behind an `if (!isBrowser) return` guard, so the property channel applies at hydration by construction. A placeholder whose whole job is to be in the first flushed bytes cannot wait for that, however serializable it is. Lead with timing on both surfaces, matching the ordering the source comment at packages/core/src/render-server.js:403 already uses. The core module table keeps serializer-safety as the trailing secondary fact, since it is real and worth knowing when reading `renderTemplate`. --- packages/core/AGENTS.md | 2 +- website/app/docs/suspense/page.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 2e91f9172..293d8a2f1 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -35,7 +35,7 @@ the same output in all three. | `directives.js` | the lit-html-parity directive set (`unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` / `createRef`, `cache`, `until`, `asyncAppend` / `asyncReplace`, `watch`, plus each `is*` guard). `repeat` lives in `repeat.js`. All are re-exported from `index.js` / `index-browser.js` so the bare specifier and the `/directives` subpath (which collapses onto the dist browser bundle) expose the full set | | `repeat.js` | `repeat(items, keyFn, templateFn)` for keyed list reconciliation | | `suspense.js` | `Suspense()` page/region-level boundary primitive | -| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, since a TemplateResult is not serializer-safe) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | +| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, because the normal `data-webjs-prop-*` path applies at hydration, far too late for a placeholder that has to be in the first flushed bytes, and a `TemplateResult` would not survive that path anyway) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | | `context.js` | Context Protocol: `createContext`, `ContextProvider`, `ContextConsumer`, `ContextRequestEvent` | | `task.js` | `Task` / `TaskStatus` controller for async data in components | | `router-client.js` | Turbo Drive–style client router; entry: `enableClientRouter` / `navigate`. Also exports `loadFrame(frameEl, url)` (#253), the reusable frame self-load `webjs-frame.js` calls: it fetches `url` as a frame nav (the `x-webjs-frame` header) and applies the matched subtree through the SAME `fetchAndApply` frame-swap path a click uses (no history push / snapshot / optimistic skeleton, since it swaps one region). Post-swap activation of a boundary range goes through `activateSwappedRange` (#1102), the ONE place both tiers (`replaceBoundaryRange`, `swapMarkerRange`) reactivate scripts and upgrade custom elements. Two things it owns and a new call site must keep: it SNAPSHOTS the range before iterating, because `reactivateScripts` replaces a top-level script and a detached node cuts a live `nextSibling` walk (every later node in the range is then silently skipped); and `reactivateScripts` handles container-IS-a-script itself, since `querySelectorAll` never matches the node it is called on. A top-level script therefore re-executes on every swap of its range, INCLUDING one the keyed differ reused by `id`, matching what a descendant script in a reused container has always done. `data-webjs-permanent` splits into two cases and they must NOT be unified (#1252). The marked element IS a script: NEVER exempt, whether the walk reaches it as the container or as a descendant of one (the regraft selector has no tag filter, so a marked script IS preserved by identity and does land in the WeakSet, which is why the exemption is STRICT containment and never reflexive). The regraft also has a both-exist guard, so on the swap that first mounts a route there is no live node to preserve and exempting the inert parsed copy would leave a script that runs on a cold load and never on a soft nav, which is #1102 itself. Script INSIDE a preserved marked element: exempt, because the attribute is subtree-scoped (`diffElementInPlace` already returns early rather than recursing into one) and re-emitting an init script against an instance the author kept alive is a double-initialization. The filter keys on the `regraftedPermanents` WeakSet, which the two regrafts populate on every successful path, so it means ACTUALLY preserved by identity rather than merely carrying the attribute; an attribute-only filter would leave a first-mount permanent element's scripts never running at all | diff --git a/website/app/docs/suspense/page.ts b/website/app/docs/suspense/page.ts index 375774d23..f82e5176d 100644 --- a/website/app/docs/suspense/page.ts +++ b/website/app/docs/suspense/page.ts @@ -91,7 +91,7 @@ With Suspense: TTFB = shell render = ~40ms
  • Error-isolated. A throwing component inside a boundary renders its own error state while siblings stream.
  • Progressive on soft navigation. A client-router navigation to a streamed page applies the shell (with fallbacks) immediately, advances the URL, then streams each boundary in, matching the initial-load experience.
  • -

    The .fallback is read at SSR as the inline placeholder (never through the data-webjs-prop-* path, since a TemplateResult is not serializer-safe) and must be an unquoted property hole. renderFallback() on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see Loading States.

    +

    The .fallback is read at SSR as the inline placeholder (never through the data-webjs-prop-* path, because that applies the property at hydration, far too late for a placeholder that has to be in the first flushed bytes) and must be an unquoted property hole. renderFallback() on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see Loading States.

    When to Use Suspense

      From c7b4ef270db486cccef28d1d6dc770257563c6f2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 22:47:42 +0530 Subject: [PATCH 2/3] docs: name the SSR-inert element as the reason, not the serializer The first pass replaced one wrong reason with another. Checking the source rather than the claim: `html.js:14` returns a plain `{ _$webjs: 'template', strings, values }` object, and it round-trips through `stringify` / `parse` intact, so "a TemplateResult is not serializer-safe" was never true. And the `data-webjs-prop-*` channel is not browser-only either: `consumePropAttrs` at `render-server.js:959` decodes it server-side and assigns onto the instance before `render()`. What actually forces the design is narrower. `webjs-suspense.js:38` defines the element only when `customElements` exists, so the injectDSD walk skips it (`lookup(tag)` finds no class) and no server-side instance ever runs `consumePropAttrs`. For this one element the only remaining consumer is `connectedCallback`, which is far too late for a placeholder whose whole job is to be in the first flushed bytes. Say that on all three surfaces, including the source comment the two doc copies had drifted from, since it carried the serializer claim that started this. Also removes the contradiction with `docs/ssr/page.ts:161`, which correctly says the walker consumes the channel before `render()`. --- packages/core/AGENTS.md | 2 +- packages/core/src/render-server.js | 15 +++++++++------ website/app/docs/suspense/page.ts | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 293d8a2f1..dccc27728 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -35,7 +35,7 @@ the same output in all three. | `directives.js` | the lit-html-parity directive set (`unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` / `createRef`, `cache`, `until`, `asyncAppend` / `asyncReplace`, `watch`, plus each `is*` guard). `repeat` lives in `repeat.js`. All are re-exported from `index.js` / `index-browser.js` so the bare specifier and the `/directives` subpath (which collapses onto the dist browser bundle) expose the full set | | `repeat.js` | `repeat(items, keyFn, templateFn)` for keyed list reconciliation | | `suspense.js` | `Suspense()` page/region-level boundary primitive | -| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, because the normal `data-webjs-prop-*` path applies at hydration, far too late for a placeholder that has to be in the first flushed bytes, and a `TemplateResult` would not survive that path anyway) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | +| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, because this element is browser-defined, so the walk below skips it (`lookup(tag)` returns nothing) and no server-side instance runs `consumePropAttrs`; a normal `data-webjs-prop-*` binding would therefore not land until `connectedCallback`, far too late for a placeholder that has to be in the first flushed bytes) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | | `context.js` | Context Protocol: `createContext`, `ContextProvider`, `ContextConsumer`, `ContextRequestEvent` | | `task.js` | `Task` / `TaskStatus` controller for async data in components | | `router-client.js` | Turbo Drive–style client router; entry: `enableClientRouter` / `navigate`. Also exports `loadFrame(frameEl, url)` (#253), the reusable frame self-load `webjs-frame.js` calls: it fetches `url` as a frame nav (the `x-webjs-frame` header) and applies the matched subtree through the SAME `fetchAndApply` frame-swap path a click uses (no history push / snapshot / optimistic skeleton, since it swaps one region). Post-swap activation of a boundary range goes through `activateSwappedRange` (#1102), the ONE place both tiers (`replaceBoundaryRange`, `swapMarkerRange`) reactivate scripts and upgrade custom elements. Two things it owns and a new call site must keep: it SNAPSHOTS the range before iterating, because `reactivateScripts` replaces a top-level script and a detached node cuts a live `nextSibling` walk (every later node in the range is then silently skipped); and `reactivateScripts` handles container-IS-a-script itself, since `querySelectorAll` never matches the node it is called on. A top-level script therefore re-executes on every swap of its range, INCLUDING one the keyed differ reused by `id`, matching what a descendant script in a reused container has always done. `data-webjs-permanent` splits into two cases and they must NOT be unified (#1252). The marked element IS a script: NEVER exempt, whether the walk reaches it as the container or as a descendant of one (the regraft selector has no tag filter, so a marked script IS preserved by identity and does land in the WeakSet, which is why the exemption is STRICT containment and never reflexive). The regraft also has a both-exist guard, so on the swap that first mounts a route there is no live node to preserve and exempting the inert parsed copy would leave a script that runs on a cold load and never on a soft nav, which is #1102 itself. Script INSIDE a preserved marked element: exempt, because the attribute is subtree-scoped (`diffElementInPlace` already returns early rather than recursing into one) and re-emitting an init script against an instance the author kept alive is a double-initialization. The filter keys on the `regraftedPermanents` WeakSet, which the two regrafts populate on every successful path, so it means ACTUALLY preserved by identity rather than merely carrying the attribute; an attribute-only filter would leave a first-mount permanent element's scripts never running at all | diff --git a/packages/core/src/render-server.js b/packages/core/src/render-server.js index d57ae0108..ad5b3e190 100644 --- a/packages/core/src/render-server.js +++ b/packages/core/src/render-server.js @@ -400,12 +400,15 @@ async function renderTemplate(tr, ctx) { // page-level `.prop` on a native element could have set the // property to begin with. out = out.slice(0, attrStart); - // `` (#471). A TemplateResult - // is not serializer-safe (the normal data-webjs-prop-* path would - // drop it) and a normal custom-element prop applies only at - // hydration, too late for the streaming placeholder. So render the - // fallback to HTML now and carry it as data-webjs-fallback, which the - // injectDSD streaming pre-pass reads as the boundary placeholder. + // `` (#471). This element is + // defined only in the browser, so the injectDSD walk skips it + // (`lookup(tag)` finds no class) and no server-side instance runs + // consumePropAttrs. A normal data-webjs-prop-* binding would then + // land at connectedCallback, too late for the streaming placeholder. + // So render the fallback to HTML now and carry it as + // data-webjs-fallback, which the injectDSD streaming pre-pass reads + // as the boundary placeholder. (The value itself would serialize + // fine: a TemplateResult is a plain {strings, values} object.) if (currentTag === 'webjs-suspense' && name === 'fallback') { const fbHtml = await render(val, ctx); out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`; diff --git a/website/app/docs/suspense/page.ts b/website/app/docs/suspense/page.ts index f82e5176d..9212fb265 100644 --- a/website/app/docs/suspense/page.ts +++ b/website/app/docs/suspense/page.ts @@ -91,7 +91,7 @@ With Suspense: TTFB = shell render = ~40ms
    • Error-isolated. A throwing component inside a boundary renders its own error state while siblings stream.
    • Progressive on soft navigation. A client-router navigation to a streamed page applies the shell (with fallbacks) immediately, advances the URL, then streams each boundary in, matching the initial-load experience.
    -

    The .fallback is read at SSR as the inline placeholder (never through the data-webjs-prop-* path, because that applies the property at hydration, far too late for a placeholder that has to be in the first flushed bytes) and must be an unquoted property hole. renderFallback() on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see Loading States.

    +

    The .fallback is read at SSR as the inline placeholder (never through the data-webjs-prop-* path, because <webjs-suspense> is defined only in the browser, so no server-side instance consumes that attribute and the property would not land until connectedCallback, far too late for a placeholder that has to be in the first flushed bytes) and must be an unquoted property hole. renderFallback() on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see Loading States.

    When to Use Suspense

      From 9727d155fc5be6b8cb977f3e6e506b4331fbc16f Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 22:49:54 +0530 Subject: [PATCH 3/3] docs: drop a pause semicolon from the core module-table row Invariant 11 bans a space-surrounded semicolon as pause punctuation. Also unnests the parenthetical, which had a second one inside it. --- packages/core/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index dccc27728..bbcdc3415 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -35,7 +35,7 @@ the same output in all three. | `directives.js` | the lit-html-parity directive set (`unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` / `createRef`, `cache`, `until`, `asyncAppend` / `asyncReplace`, `watch`, plus each `is*` guard). `repeat` lives in `repeat.js`. All are re-exported from `index.js` / `index-browser.js` so the bare specifier and the `/directives` subpath (which collapses onto the dist browser bundle) expose the full set | | `repeat.js` | `repeat(items, keyFn, templateFn)` for keyed list reconciliation | | `suspense.js` | `Suspense()` page/region-level boundary primitive | -| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, because this element is browser-defined, so the walk below skips it (`lookup(tag)` returns nothing) and no server-side instance runs `consumePropAttrs`; a normal `data-webjs-prop-*` binding would therefore not land until `connectedCallback`, far too late for a placeholder that has to be in the first flushed bytes) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | +| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, because this element is browser-defined, so the walk below finds no class for it and no server-side instance runs `consumePropAttrs`, which leaves `connectedCallback` as the only consumer of a normal `data-webjs-prop-*` binding, far too late for a placeholder that has to be in the first flushed bytes) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | | `context.js` | Context Protocol: `createContext`, `ContextProvider`, `ContextConsumer`, `ContextRequestEvent` | | `task.js` | `Task` / `TaskStatus` controller for async data in components | | `router-client.js` | Turbo Drive–style client router; entry: `enableClientRouter` / `navigate`. Also exports `loadFrame(frameEl, url)` (#253), the reusable frame self-load `webjs-frame.js` calls: it fetches `url` as a frame nav (the `x-webjs-frame` header) and applies the matched subtree through the SAME `fetchAndApply` frame-swap path a click uses (no history push / snapshot / optimistic skeleton, since it swaps one region). Post-swap activation of a boundary range goes through `activateSwappedRange` (#1102), the ONE place both tiers (`replaceBoundaryRange`, `swapMarkerRange`) reactivate scripts and upgrade custom elements. Two things it owns and a new call site must keep: it SNAPSHOTS the range before iterating, because `reactivateScripts` replaces a top-level script and a detached node cuts a live `nextSibling` walk (every later node in the range is then silently skipped); and `reactivateScripts` handles container-IS-a-script itself, since `querySelectorAll` never matches the node it is called on. A top-level script therefore re-executes on every swap of its range, INCLUDING one the keyed differ reused by `id`, matching what a descendant script in a reused container has always done. `data-webjs-permanent` splits into two cases and they must NOT be unified (#1252). The marked element IS a script: NEVER exempt, whether the walk reaches it as the container or as a descendant of one (the regraft selector has no tag filter, so a marked script IS preserved by identity and does land in the WeakSet, which is why the exemption is STRICT containment and never reflexive). The regraft also has a both-exist guard, so on the swap that first mounts a route there is no live node to preserve and exempting the inert parsed copy would leave a script that runs on a cold load and never on a soft nav, which is #1102 itself. Script INSIDE a preserved marked element: exempt, because the attribute is subtree-scoped (`diffElementInPlace` already returns early rather than recursing into one) and re-emitting an init script against an instance the author kept alive is a double-initialization. The filter keys on the `regraftedPermanents` WeakSet, which the two regrafts populate on every successful path, so it means ACTUALLY preserved by identity rather than merely carrying the attribute; an attribute-only filter would leave a first-mount permanent element's scripts never running at all |