Skip to content

Extend the elision drift guard to template sigils + static-field conventions #784

Description

@vivek7405

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:

  1. 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.

  2. 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.

  1. 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).

  2. 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

  • The client-behavior sigil set is single-sourced in core and consumed by both renderers (no triplication) without a render-path perf regression
  • A guard test asserts every renderer-recognised sigil is classified by the analyser as ship-signal or round-trips-safely
  • A counterfactual proves the guard fires (an unclassified sigil reds the test)
  • Interactivity-signal static conventions are a single registry in the analyser, with a contract that new ones must be added there
  • .prop / ?bool remain elidable (the guard does not force every sigil to ship); existing elision verdicts are unchanged
  • Docs (agent-docs/components.md, the analyser header) note which interactivity surfaces are mechanically guarded

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
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