Skip to content

feat(compiler): intrinsic event/ref channels on the generated leaf (M09-2c) - #1788

Merged
SisyphusZheng merged 5 commits into
mainfrom
feat/m09-event-ref-impl
Nov 13, 2026
Merged

SisyphusZheng merged 5 commits into
mainfrom
feat/m09-event-ref-impl

Conversation

@SisyphusZheng

Copy link
Copy Markdown
Member

Summary

Bounded, compiler-private M09-2c capability: the generated native-element leaf now admits the existing authored JSX intrinsic onClick / ref channels, installs them as client-only attachments on mount and claim phase 2, keeps SSR handler/ref-free, and reports invalid callables through the repository's coded-error dialect with authored origin.

The branch also carries one accepted factual documentation correction: the security baseline matrix / security guide CSP claims (CSP is opt-in; static meta is policy-only and refuses report-only plus the meta-ignored directives; header channel carries nonce/report-only).

Commits

Commit Subject
fd90c0c86 feat(compiler): admit intrinsic event/ref channels on the generated leaf (implementation + Node contract)
fe6d3cdf2 test(compiler): Chromium event/ref generated-element vertical
480513d1a docs(security): correct the CSP baseline claims and the security guide's report-only/meta contradiction (accepted correction; cherry-pick-equivalent of 560fb9fc)
e144c799d fix(compiler): code intrinsic event/ref callable errors and record client-only semantics (independent-review fixes)

Semantics pinned (explicit deltas)

Client-only handler setup is phase-labelled: SSR 0 / client mount 1. Event/ref render no SSR bytes and are skipped by both producers, preserving the M08-1a static __ssr() no-argument form and bytes. A local setup resource reachable only from a client-only channel is pruned from the SSR producer and is not evaluated on the server; the client mount/claim evaluates it once. A server-read local is evaluated once at SSR as well, in authored order. This is recorded in the §4.2a addendum of docs/architecture/jsx-value-and-children-model.md and asserted by phase-labelled counts (SSR setup 0 / dead 0 / order ['first','second']; CSR setup 1 / dead 1).

Conditional branch widening (explicit). Event/ref inside a conditional branch are admitted; installation is branch-scope-owned, so activation attaches exactly once and branch disposal detaches listener/ref exactly once (mount and claim toggle covered in Chromium). This is a deliberate widening over the class lane's narrower branch refusal; it is recorded in docs/architecture/m09-2c-intrinsic-event-ref.md. List entry templates, custom-element hosts and component-reference event/ref props remain refused.

Remaining unsupported carry (explicit). Imported/module function handlers and closures over owned signals still become host bindings, so the generated-element lane refuses the module with OEIR_GENERATED_ELEMENT_UNBOUND_BINDING. No binding/import carry channel and no second callback API are invented; production wiring is not claimed.

Coded errors + authored origin. Invalid callables throw the shared internal helper leafRuntimeError = frameworkError(code, locatedDetail(detail, origin), { phase: 'csr' }): OpenElementError, internal identity (OEIR_LEAF_EVENT_HANDLER_INVALID / OEIR_LEAF_REF_HANDLER_INVALID, not public catalogue members), severity: 'error', recoverable: false, authored (authored file:line:column) suffix. The helper is exported through the internal ./internal/compiled/runtime subpath; no public API/export change.

Lifetime/rollback. Listener/ref cleanup is queued on the mount/claim scope. A non-callable value throws before any listener/ref side effect; a claim mismatch installs nothing; a phase-2 installation throw releases the already-installed listener while borrowed DOM stays; ref detach calls the returned unsubscribe (if any) then ref(null) even when the unsubscribe throws. These remain bounded/nontransactional (no byte transaction, no borrowed-DOM restore).

Exact snapshot delta

docs/release/public-interface-snapshot.json — @openelement/element subpath ./internal/compiled/runtime only:

  • publicShapeSha256: 343dc604d0d38ca28bc2e07f9fdf736fd9fb9cd1398ce2bf6d25ddf8a1f21097 → 15d9f4acca2540677f614a0fe9dca304010658936f684f152796884ebf7e99e3
  • added symbol: leafRuntimeError
  • no other package/subpath/symbol changes; interface:snapshot matches (6 packages).

Tests and evidence (LOCAL, Chromium 147.0.7727.15 unless noted)

Suite Result
generated-leaf.event-ref.test.ts (Node) 32 passed
generated-element.event-ref.browser.test.ts (real Chromium) 8 passed
generated-leaf.claim.test.ts (M07-5 regression) 10 passed
vitest run --project compiler (worktree, final head) 1116 passed
vitest run --project element (shell/class-path regression baseline) 899 passed
check-public-interface-snapshot.ts matches (6 packages)
docs-index:check / seams:check 34 active / 8 historical / 15 entries; 59/59 seam rows
pre-push gates (vp run fmt/lint/markdown/docs-index/typecheck, release graph, pack dry-run, pack-surface, export-files) all passed (hook, mandatory)

Review record: independent review requested changes on (1) coded-error dialect/authored origin, (2) undocumented SSR setup-count delta + reachability coverage, (3) undocumented/untested conditional widening. All three are addressed in e144c799d, and the independent re-review passed (targeted suites + full compiler + snapshot/docs/seams + independent lane/Chromium reproductions). Review documents live outside the repository as task artifacts.

Non-claims

Not full authored Custom Element parity and not the M09 exit; not M13 (one Chromium engine, LOCAL); no version, tag, publish, Starter default or release claim; no merge. Imported handler/owned-signal carry, listener replacement/update semantics and component-reference callbacks remain out of scope.

Checklist

  • Commits signed off; branch based on main@089ace0a8 (current origin/main tip)
  • Mandatory pre-push hook gates run and pass (no edits needed for the push)
  • Included the accepted security CSP factual correction commit
  • No generated artifact/source beyond the slice; no public surface move
  • Merge — deliberately not performed by the author (owner decision)

…eaf.

Bounded compiler-private codegen capability (not full authored CE parity): the
authored JSX keeps the existing onClick/ref attributes on INTRINSIC elements;
no new callback API, runtime module or package export is introduced.

- frontend: event and ref dynamic attributes are lowered in handler
  (attach-once) mode, so their reads are recorded once without reactive
  dependencies (a ref value is evaluated at attach, not treated as a tracked
  slot).
- admission: the intrinsic channel set gains event/ref while the
  component-reference prop set does not (a child callback/ref prop stays a
  second-API refusal), and custom-element hosts and list entry templates fail
  closed (host ownership / the M06b2c per-entry update hook own those shapes).
- mount/claim installation: handlers are evaluated once, validated callable,
  and installed as addEventListener listeners whose removal is queued on the
  mount/claim LifetimeScope; refs call the handler with the element, queue the
  class-path detach (returned unsubscribe when callable, then handler(null) in
  a finally), and are likewise scope-owned. Claim installation is emitted only
  in the phase-2 tail after the structural mismatch walk, and event/ref names
  are skipped by the static attribute seed. A throwing later installation step
  is released by the existing owner rollback (shell connect catch,
  claimRecipe rollback) while borrowed DOM is retained — bounded,
  nontransactional.
- SSR: event/ref render no bytes and are skipped by both the static and the
  dynamic producer, preserving the M08-1a static no-argument form/bytes. Local
  setup resources are now emitted only when the server output transitively
  reads them (a reachability walk over server-visible expression records plus
  recipe targets), so a helper reachable only from a client channel never makes
  __ssr request a callable; surviving locals keep their authored order.
- expression-local reads: a binding declared inside the lowered expression
  (handler/ref parameter, nested local) is no longer recorded as a host read,
  so onClick={(event) => ...} / ref={(el) => ...} do not create requireBinding
  lookups for their own parameters.
- tests: Node contract suite (26) covers admission/refusals, validation
  ordering, ref cleanup, SSR exclusion + transitive pruning, claim phase-2
  ordering, carry boundaries (imported handler / owned signal still refused by
  the generated-element lane), and the M08 byte-shape regression. The existing
  generated-leaf event-refusal row is converted to the new admission contract.

Signed-off-by: Zhi <146103794+SisyphusZheng@users.noreply.github.com>
Real authored sidecar -> real generated-element lane -> emitted module ->
Node __ssrHost bytes -> browser DSD parse -> generated class claim, on
Chromium:

- the claimed listener fires and the ref callback received the element; no
  onClick/ref DOM attributes and no attachShadow call exist;
- disconnect removes the listener and runs the ref teardown (unsubscribe then
  handler(null)); reconnect re-claims the retained root and re-installs both
  under the fresh scope; dispose(false) keeps the claimed tree and
  dispose(true) removes only the owned region;
- CSR mount installs both, and disconnect/reconnect re-installs them on the
  retained claim;
- a corrupted borrowed DOM fails the claim with OEIR_LEAF_CLAIM_MISMATCH and
  installs nothing (no listener, no ref), borrowed DOM retained;
- a phase-2 installation throw (non-callable ref binding supplied by the test
  through the shell's createBindings) releases the already-installed listener
  through the claim owner rollback while borrowed DOM stays — the bounded,
  nontransactional failure contract. The hand-supplied binding exercises the
  runtime precondition only and does not claim production binding carry.

One engine (Chromium), LOCAL; imported-handler/owned-signal carry remains
refused by the generated-element lane and is not claimed here.

Signed-off-by: Zhi <146103794+SisyphusZheng@users.noreply.github.com>
…e's report-only/meta contradiction

The matrix stated a blanket fact ("CSP covers prerendered pages") and the
security guide said reportOnly swaps the SSG meta tag. The source does neither:
CSP is opt-in per app on both channels, and the static meta channel refuses
report-only at build time.

Matrix (docs/architecture/security-baseline-matrix.md):
- CSP is a configured capability: request-time middleware only when
  middleware.csp is set (entry-descriptor.ts:370); SSG meta only when
  middleware.csp.policy is set (ssg-render.ts:470-478).
- The header channel carries the full directive set, report-only and
  per-request nonce (entry-server-codegen.ts:87-131).
- The static meta channel is policy-only: postprocess.ts refuses reportOnly and
  any policy carrying frame-ancestors/report-uri/report-to/sandbox with
  OE_SSG_CSP_META_UNSUPPORTED; nonce:true warns and falls back to policy-only.
- Static file responses write only content-type/cache-control
  (static-serve.ts:260-262); the 11 security headers and CSP headers stay
  request-time, and the pure-static 405 carries Allow (:384-388).
- Recorded as a dated correction (section 4) with the channel ownership
  (framework request-time / framework static meta when configured / host
  infrastructure) unchanged.

Security guide (en+zh): reportOnly now documents the request-time header only
and the SSG fail-closed refusal; the CSP intro states the opt-in condition and
the meta directive limits. content-dates stamps for guide/security move to
2026-11-14 (the content commit carries its own manifest edit).

No capability, policy, API, CI job, scanner or generated surface change. The
architecture README is untouched: the matrix front-matter topic/summary did not
change, so docs-index:check passes without regeneration (event lane coordination
noted in the report).

Checks (LOCAL, this worktree, base 089ace0): docs-index:check pass (33 active /
8 historical / 15 entries); check:content-dates pass (27 articles, en/zh);
check:doc-claims pass (68 files, 45 versions, 36 gates, 10 CI jobs);
markdownlint-cli2 on the four changed files 0 issues. No source, public API,
snapshot or generated artifact change; no push.

Signed-off-by: Zhi <146103794+SisyphusZheng@users.noreply.github.com>
…ient-only semantics.

Addresses the independent review's three must-fix items (no push; recheck
pending):

- Invalid callable event/ref values now throw the shared internal coded
  `OpenElementError` through `leafRuntimeError(code, detail, origin)` =
  `frameworkError(..., { phase: 'csr' })` with the ONE
  `locatedDetail`/`originArgs` authored `(file:line:column)` suffix. Codes stay
  internal identities (`OEIR_LEAF_*_HANDLER_INVALID`); no public catalogue
  growth. Node text tests pin the helper call + origin literal; Chromium pins
  name/code/phase/severity/recoverable/authored suffix.
- Client-only setup pruning is recorded as an explicit phase-labelled SSR
  contract in `jsx-value-and-children-model.md` §4.2a and the new M09-2c
  tracked record: setup reachable only from event/ref is 0 in SSR, 1 per
  client mount/claim; surviving locals keep authored order. Reachability tests
  cover component-reference prop records, children recipes, insertion-site
  targets and typed condition tests, plus dead-local exclusion and the
  SSR0/CSR1 counters in Chromium.
- Conditional-branch event/ref widening is documented and tested on the mount
  and SSR->claim->toggle paths: branch-owned scopes detach/reinstall exactly
  once; list/custom/component-prop refusals unchanged.
- Registers the new intrinsic event/ref seam row (coded-error formatter,
  install/pruning owner, guard tests) and the M09-2c tracked record; regenerates
  the docs index and the internal-runtime interface snapshot (`leafRuntimeError`).
- M08 S2/maps suites and full compiler/element/router suites re-run; no
  class-lane semantics changed; no version/tag/push.

Signed-off-by: Zhi <146103794+SisyphusZheng@users.noreply.github.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Deploying openelement with  Cloudflare Pages  Cloudflare Pages

Latest commit: b6644d3
Status: ✅  Deploy successful!
Preview URL: https://4e723881.lessjs.pages.dev
Branch Preview URL: https://feat-m09-event-ref-impl.lessjs.pages.dev

View logs

…y (CodeQL 4254)

CodeQL alert 4254 (js/bad-code-sanitization, CWE-79/94/116, medium) reports
that the intrinsic event/ref install emitter interpolates a
JSON.stringify-ed value into constructed JavaScript
(generate.ts:1737, columns 72-97 = \"JSON.stringify(eventName)\"). The rule's
documented remediation is post-stringify escaping of the script-breaking
characters; JSON.stringify alone is not accepted as a sanitizer even when the
value is allow-listed.

Assessment: not an exploitable injection in this lane. The event name derives
from the authored TSX attribute name; the frontend admits the event channel
only for /^on[A-Z]/, and clientOnlyEventName further narrows the value to
/^[a-z][a-z0-9-]*$/ (probed: onClick -> click; onX-foo -> x-foo; onX\$, onX_foo
and onX:y are refused with OEIR_LEAF_UNSUPPORTED_ATTRIBUTE before any code is
built). Quotes, backslashes, '<', '>', '/', line terminators and U+2028/U+2029
cannot be represented by an admitted name, and the generated artifact is an ES
module chunk rather than inline script HTML. The finding is therefore a
codegen-hygiene true positive, not a runtime vulnerability - but it is a real
merge-gate finding and is fixed as such (no dismissal, no thread resolution).

Fix: add the compiler-local codegen literal boundary
semantic-ir/generated-leaf/codegen-literals.ts, a mirror of the router's
accepted quoteGeneratedJavaScriptValue (JSON.stringify plus \\u003C, \\u003E,
\\u2028, \\u2029 escapes; the router dependency direction forbids importing
that helper). Use it at the four event/ref code-construction sites: the
invalid-callable detail literals and the add/removeEventListener names. No
public export, no package/snapshot change, no behaviour change for admitted
names.

Regression: unit escaping round-trip with '</script>', quote, backslash,
newline/tab and U+2028/U+2029 payload; emitted-code safe-literal assertions;
non-DOM-shaped event names refused before construction. Evidence: targeted
event-ref Node suite 35 tests, Chromium vertical 8 tests, full compiler project
1119 tests, compiler tsc, oxfmt/oxlint clean. Not pushed; PR #1788 remains
open and unmerged.

Signed-off-by: Zhi <146103794+SisyphusZheng@users.noreply.github.com>
@SisyphusZheng
SisyphusZheng merged commit 7581fbf into main Nov 13, 2026
14 checks passed
@SisyphusZheng
SisyphusZheng deleted the feat/m09-event-ref-impl branch November 13, 2026 18:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants