diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 2e91f9172..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`, 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 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 | 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 375774d23..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, 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 <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