Skip to content

feat: elide bare async-render components (ship only when async render isn't the sole client signal) #474

Description

@vivek7405

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

  • Differential-elision cases (render on vs off, identical observable behavior): bare async ELIDED (zero application JS, network-probed) · async+@event ships · async+signal ships · async+non-state prop ships · async+renderFallback ships · async+static shadow ships · async-importing-an-interactive-child ships (import rule) · async parent + child registered elsewhere → parent elided, child still upgrades and works.
  • An elided bare async component renders correctly JS-off AND JS-on with no console error (e2e + the JS-off probe).
  • Counterfactual: each "ships" case fails to ship when its signal is removed.
  • The lifecycle-coverage / elision SSOT guard updated.
  • Docs corrected (root AGENTS.md, packages/core/AGENTS.md, agent-docs/components.md, the data-fetching doc page): drop the "async render is always shipped / never elided" note; document the shadow carve-out and the renderFallback / static refresh opt-out.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions