Problem
Follow-up refinement to #469 / #470 (PR #470). #470 made async render() a blanket interactivity signal in the elision analyser, so every async-render component ships its module to the browser and re-fetches on hydration. That is over-conservative: a bare async-render component (an async render() with no other client signal) renders identical first-paint HTML with or without JS, so it can be elided like any display-only component, saving a module download plus a redundant hydration RPC + DB hit for zero observable change. This is a common shape in content / docs apps (fetch-and-display leaves).
Today's elision note ("an async-render component is always shipped, never elided") should be corrected.
Design / approach
Refined rule. async render() is interactive iff the component also has another signal: an @event, a non-state reactive prop, a signal / reactive import, a lifecycle hook, a <slot>, renderFallback(), cross-module observation, static shadow = true, or a transitively-reachable interactive dep / child. A bare light-DOM async-display component with none of those becomes elidable. Implementation: drop async render from the standalone-signal short-circuit in component-elision.js analyzeComponentSource, and add static shadow = true as a ship signal.
Why it is safe (grounded in the existing machinery).
- Nested interactive child is already fenced by the elision fixpoint's IMPORT RULE (
component-elision.js:817-839): a shipping interactive child forces its importer to ship, so a bare async parent that imports / registers an interactive child still ships.
- Prop-drilling (fetch in page/layout, drill props down) is orthogonal and unchanged: the page is server-only; the receiving component already ships if it has a non-
state reactive prop and elides if state-only. The refinement only touches components that fetch their OWN data.
- Cross-module observation (
whenDefined / :defined / instanceof) already forces shipping.
- The differential-elision guard (render elision on vs off, assert identical post-hydration DOM + behavior) is the backstop and fails CI on any wrong-elide.
The one genuinely new risk: shadow DOM. Declarative Shadow DOM attaches only during HTML PARSING. A STREAMED shadow component arrives via a JS replaceWith, so its DSD would not attach without the module running attachShadow, and the analyser is context-free (can't tell if a component will be streamed). Safe carve-out: static shadow = true always ships.
The one behavior change no guard can catch (deliberate). Eliding removes the stale-while-revalidate refresh on hydration (a re-fetch ~200ms after SSR). Moot for request-stable data; only matters if the author wanted fresh-on-load data. The opt-out already exists (renderFallback() is a ship signal, as is a changeable prop or a signal); consider also an explicit static refresh = true. Document it.
Relationship to #472 (deferred hydration seed): for a bare component, eliding ships NOTHING, which strictly dominates the seed (which still ships the module to read a cached value). The seed stays relevant only for async components that ship for other reasons. #472 stays out of scope.
Acceptance criteria
Problem
Follow-up refinement to #469 / #470 (PR #470). #470 made
async render()a blanket interactivity signal in the elision analyser, so every async-render component ships its module to the browser and re-fetches on hydration. That is over-conservative: a bare async-render component (anasync render()with no other client signal) renders identical first-paint HTML with or without JS, so it can be elided like any display-only component, saving a module download plus a redundant hydration RPC + DB hit for zero observable change. This is a common shape in content / docs apps (fetch-and-display leaves).Today's elision note ("an async-render component is always shipped, never elided") should be corrected.
Design / approach
Refined rule.
async render()is interactive iff the component also has another signal: an@event, a non-statereactive prop, a signal / reactive import, a lifecycle hook, a<slot>,renderFallback(), cross-module observation,static shadow = true, or a transitively-reachable interactive dep / child. A bare light-DOM async-display component with none of those becomes elidable. Implementation: dropasync renderfrom the standalone-signal short-circuit incomponent-elision.jsanalyzeComponentSource, and addstatic shadow = trueas a ship signal.Why it is safe (grounded in the existing machinery).
component-elision.js:817-839): a shipping interactive child forces its importer to ship, so a bare async parent that imports / registers an interactive child still ships.statereactive prop and elides ifstate-only. The refinement only touches components that fetch their OWN data.whenDefined/:defined/instanceof) already forces shipping.The one genuinely new risk: shadow DOM. Declarative Shadow DOM attaches only during HTML PARSING. A STREAMED shadow component arrives via a JS
replaceWith, so its DSD would not attach without the module runningattachShadow, and the analyser is context-free (can't tell if a component will be streamed). Safe carve-out:static shadow = truealways ships.The one behavior change no guard can catch (deliberate). Eliding removes the stale-while-revalidate refresh on hydration (a re-fetch ~200ms after SSR). Moot for request-stable data; only matters if the author wanted fresh-on-load data. The opt-out already exists (
renderFallback()is a ship signal, as is a changeable prop or a signal); consider also an explicitstatic refresh = true. Document it.Relationship to #472 (deferred hydration seed): for a bare component, eliding ships NOTHING, which strictly dominates the seed (which still ships the module to read a cached value). The seed stays relevant only for async components that ship for other reasons. #472 stays out of scope.
Acceptance criteria
@eventships · async+signal ships · async+non-stateprop ships · async+renderFallbackships · async+static shadowships · async-importing-an-interactive-child ships (import rule) · async parent + child registered elsewhere → parent elided, child still upgrades and works.AGENTS.md,packages/core/AGENTS.md,agent-docs/components.md, thedata-fetchingdoc page): drop the "async render is always shipped / never elided" note; document the shadow carve-out and therenderFallback/static refreshopt-out.