Problem
The elision analyser is conservative by construction (ship-when-unsure) AND has a mechanical drift guard: packages/server/test/elision/lifecycle-coverage.test.js introspects the live WebComponent.prototype (and core exports) and fails CI if a new public method / reactive export is added to core without classifying it in component-elision.js. That converts "a new interactivity surface silently over-elides in production" from a human-discipline invariant into a loud build failure.
Two interactivity surfaces are NOT covered by that guard, because they are neither prototype methods nor named exports:
-
Template binding sigils. The renderers recognise @ (event), . (prop), ? (bool) by hardcoded prefix dispatch, currently TRIPLICATED in core (packages/core/src/render-client.js:308-313, packages/core/src/render-server.js:257-318, packages/core/src/render-server.js:1423-1429). The analyser independently hardcodes only @event as a ship signal (EVENT_BINDING_RE = /@[A-Za-z][\w-]*\s*=\s*\$\{/ in component-elision.js). .prop / ?bool are correctly NOT ship signals (they round-trip through SSR via data-webjs-prop-* / attribute reflection). But if a future change adds a NEW client-only-behavior sigil (a fourth prefix that behaves like @event, dropping at SSR and only doing work after hydration), the analyser would not know it is a ship signal, and a component whose ONLY interactivity is that new sigil would be silently over-elided. No test fails.
-
Interactivity-signal static fields. The analyser reads static shadow = true and static refresh = true by string (declaresStaticTrue(body, 'shadow') / (body, 'refresh'), component-elision.js ~L515-523) as ship carve-outs. There is no shared registry of "interactivity-relevant static conventions", so a new sibling convention (a future static field that implies client work) added without a matching declaresStaticTrue call would be invisible to elision. Same silent-over-elision failure, no test.
Severity is LOW-to-MEDIUM. The tail only triggers on a RARE, deliberate, reviewed core change (a new template sigil or a new static convention), and that change already touches the renderer / base class where an author is in elision-adjacent code. But the project's stated threat model (AI-agent-driven development, where exactly this kind of "added a surface, forgot the analyser" drift is expected) is why lifecycle-coverage exists, so closing the remaining two surfaces is a consistency fix, not gold-plating.
Design / approach
Mirror the lifecycle-coverage philosophy: make the source-of-truth single, then assert the analyser covers it.
-
Sigils (the higher-value half). Extract the client-behavior sigil dispatch into ONE shared core constant (for example a BINDING_PREFIXES map keyed by sigil to a kind event / prop / bool) consumed by BOTH renderers (de-triplicating the 3 inline spots is a worthwhile side effect, but must not regress the render hot path) and readable by the analyser test. Then add a guard test (sibling to lifecycle-coverage.test.js) asserting every renderer-recognised sigil is classified by the analyser as EITHER a ship-signal (like @) OR a known-round-trips-safely sigil (like . / ?). A new sigil added to the shared constant without classifying it reds the test. Crucially the contract is "recognised sigil to {ship-signal | round-trips}", NOT "every sigil ships" (.prop / ?bool must stay elidable).
-
Static fields (the lighter half). Introduce a single named set of interactivity-signal static conventions in component-elision.js (today implicitly { shadow, refresh }), used by the declaresStaticTrue calls, plus a doc-comment contract that a new interactivity static convention MUST be added there. A fully mechanical guard is weaker here (there is no enumerable runtime source of "all static conventions"), so a source-scan test that asserts the base class / docs static-field table and the analyser registry agree is the realistic ceiling. Acceptable to land this half as a registry + contract comment if a clean mechanical assert is not cheap.
Implementation notes (for the implementing agent)
- Where to edit:
packages/core/src/render-client.js (~L308-313) and packages/core/src/render-server.js (~L257-318 and ~L1423-1429): the three inline prefix === '@' | '.' | '?' dispatch sites. Extract to one shared constant (a new tiny module, or an existing low-level core module both renderers already import) so there is a single source of truth. Verify no render-path perf regression (these are per-attribute hot paths; a frozen object / Map lookup is fine, avoid per-call allocation).
packages/server/src/component-elision.js: EVENT_BINDING_RE and the per-class checks; add the sigil classification map and the static-field registry. Keep the existing conservative defaults.
- New test
packages/server/test/elision/sigil-coverage.test.js mirroring lifecycle-coverage.test.js: import the shared sigil constant, assert each is classified, include a counterfactual (a fake unclassified sigil reds the test).
- Landmines / gotchas:
.prop and ?bool are NOT ship signals: they SSR round-trip (custom-element .prop via data-webjs-prop-*, ?bool via the boolean attribute). The guard must encode this, not force every sigil to ship, or it would defeat elision for every interactive-looking-but-display-only component.
- The sigils are in the render hot path; the de-triplication must be behaviour- and perf-neutral (Bun parity matters:
render-server.js runs on both runtimes; add/extend a test/bun/* assertion if the extraction touches the serialised output path, though a pure constant extraction should not).
- Do not weaken the conservative direction: every change here must only ever make MORE things ship, never fewer. The new guard is about catching a FUTURE missing ship-signal, not about eliding more today.
- Invariants to respect: elision's direction-of-safety (
component-elision.js header L7-12): a false "interactive" verdict costs a fetch, a false "display-only" verdict breaks the page, so every ambiguity ships. AGENTS.md elision/PE rules.
- Tests + docs surfaces:
- Unit: the new
sigil-coverage guard test + counterfactual; extend lifecycle-coverage notes if the static-field registry lands.
- Docs:
agent-docs/components.md (the lifecycle / binding-prefix tables the analyser is kept in lockstep with) and the component-elision.js header note about which surfaces are mechanically guarded.
Acceptance criteria
Problem
The elision analyser is conservative by construction (ship-when-unsure) AND has a mechanical drift guard:
packages/server/test/elision/lifecycle-coverage.test.jsintrospects the liveWebComponent.prototype(and core exports) and fails CI if a new public method / reactive export is added to core without classifying it incomponent-elision.js. That converts "a new interactivity surface silently over-elides in production" from a human-discipline invariant into a loud build failure.Two interactivity surfaces are NOT covered by that guard, because they are neither prototype methods nor named exports:
Template binding sigils. The renderers recognise
@(event),.(prop),?(bool) by hardcoded prefix dispatch, currently TRIPLICATED in core (packages/core/src/render-client.js:308-313,packages/core/src/render-server.js:257-318,packages/core/src/render-server.js:1423-1429). The analyser independently hardcodes only@eventas a ship signal (EVENT_BINDING_RE = /@[A-Za-z][\w-]*\s*=\s*\$\{/incomponent-elision.js)..prop/?boolare correctly NOT ship signals (they round-trip through SSR viadata-webjs-prop-*/ attribute reflection). But if a future change adds a NEW client-only-behavior sigil (a fourth prefix that behaves like@event, dropping at SSR and only doing work after hydration), the analyser would not know it is a ship signal, and a component whose ONLY interactivity is that new sigil would be silently over-elided. No test fails.Interactivity-signal static fields. The analyser reads
static shadow = trueandstatic refresh = trueby string (declaresStaticTrue(body, 'shadow')/(body, 'refresh'),component-elision.js~L515-523) as ship carve-outs. There is no shared registry of "interactivity-relevant static conventions", so a new sibling convention (a future static field that implies client work) added without a matchingdeclaresStaticTruecall would be invisible to elision. Same silent-over-elision failure, no test.Severity is LOW-to-MEDIUM. The tail only triggers on a RARE, deliberate, reviewed core change (a new template sigil or a new static convention), and that change already touches the renderer / base class where an author is in elision-adjacent code. But the project's stated threat model (AI-agent-driven development, where exactly this kind of "added a surface, forgot the analyser" drift is expected) is why
lifecycle-coverageexists, so closing the remaining two surfaces is a consistency fix, not gold-plating.Design / approach
Mirror the
lifecycle-coveragephilosophy: make the source-of-truth single, then assert the analyser covers it.Sigils (the higher-value half). Extract the client-behavior sigil dispatch into ONE shared core constant (for example a
BINDING_PREFIXESmap keyed by sigil to a kindevent/prop/bool) consumed by BOTH renderers (de-triplicating the 3 inline spots is a worthwhile side effect, but must not regress the render hot path) and readable by the analyser test. Then add a guard test (sibling tolifecycle-coverage.test.js) asserting every renderer-recognised sigil is classified by the analyser as EITHER a ship-signal (like@) OR a known-round-trips-safely sigil (like./?). A new sigil added to the shared constant without classifying it reds the test. Crucially the contract is "recognised sigil to {ship-signal | round-trips}", NOT "every sigil ships" (.prop/?boolmust stay elidable).Static fields (the lighter half). Introduce a single named set of interactivity-signal static conventions in
component-elision.js(today implicitly{ shadow, refresh }), used by thedeclaresStaticTruecalls, plus a doc-comment contract that a new interactivity static convention MUST be added there. A fully mechanical guard is weaker here (there is no enumerable runtime source of "all static conventions"), so a source-scan test that asserts the base class / docs static-field table and the analyser registry agree is the realistic ceiling. Acceptable to land this half as a registry + contract comment if a clean mechanical assert is not cheap.Implementation notes (for the implementing agent)
packages/core/src/render-client.js(~L308-313) andpackages/core/src/render-server.js(~L257-318 and ~L1423-1429): the three inlineprefix === '@' | '.' | '?'dispatch sites. Extract to one shared constant (a new tiny module, or an existing low-level core module both renderers already import) so there is a single source of truth. Verify no render-path perf regression (these are per-attribute hot paths; a frozen object / Map lookup is fine, avoid per-call allocation).packages/server/src/component-elision.js:EVENT_BINDING_REand the per-class checks; add the sigil classification map and the static-field registry. Keep the existing conservative defaults.packages/server/test/elision/sigil-coverage.test.jsmirroringlifecycle-coverage.test.js: import the shared sigil constant, assert each is classified, include a counterfactual (a fake unclassified sigil reds the test)..propand?boolare NOT ship signals: they SSR round-trip (custom-element.propviadata-webjs-prop-*,?boolvia the boolean attribute). The guard must encode this, not force every sigil to ship, or it would defeat elision for every interactive-looking-but-display-only component.render-server.jsruns on both runtimes; add/extend atest/bun/*assertion if the extraction touches the serialised output path, though a pure constant extraction should not).component-elision.jsheader L7-12): a false "interactive" verdict costs a fetch, a false "display-only" verdict breaks the page, so every ambiguity ships. AGENTS.md elision/PE rules.sigil-coverageguard test + counterfactual; extendlifecycle-coveragenotes if the static-field registry lands.agent-docs/components.md(the lifecycle / binding-prefix tables the analyser is kept in lockstep with) and thecomponent-elision.jsheader note about which surfaces are mechanically guarded.Acceptance criteria
.prop/?boolremain elidable (the guard does not force every sigil to ship); existing elision verdicts are unchangedagent-docs/components.md, the analyser header) note which interactivity surfaces are mechanically guarded