Skip to content

fix(tagFactory): stamp owner node on reactive class/style bindings so… - #62

Merged
hexplus merged 2 commits into
mainfrom
fix/class-style-error-owner-node
Aug 30, 2026
Merged

fix(tagFactory): stamp owner node on reactive class/style bindings so…#62
hexplus merged 2 commits into
mainfrom
fix/class-style-error-owner-node

Conversation

@hexplus

@hexplus hexplus commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Description

Reactive class and style bindings created by tagFactory were registered with a bare track(commit). A one-argument track delegates to reactiveBinding(commit), which correctly stamps _errorPhase = "binding" but leaves _errorNode = undefined because no owner node was supplied.

Consequence: when a reactive class/style getter threw on a later scheduled rerun, the drain reported it with node: undefined. reportError then had no DOM position to dispatch its boundary-propagation event from, claimedByBoundary bailed at its if (!raw) guard, and the failure skipped the nearest enclosing ErrorBoundary entirely — falling straight through to the global runtime handler or console.error. Every other reactive attribute goes through bindAttribute, which passes the element, so class and style were the only reactive props with unroutable errors.

Affected paths, all in src/core/rendering/tagFactory.ts:

  • applyStyle() — whole reactive style getter
  • applyStyle() — individual reactive style properties
  • applyClass() — reactive class getter
  • applyClass() — conditional class object containing reactive getters

Fix: use the existing DOM-binding primitive directly — reactiveBinding(commit, el) — at those four call sites. track()'s signature is untouched (its second parameter is an explicit subscriber, not an owner node), no behaviour is routed through bindAttribute, and all four registerDisposer(el, teardown) calls are preserved.

Production diff is 4 call sites + 1 import + an explanatory comment (+21/−8). The rest of the change is regression coverage.

Baseline reproduction (all four forms, before the fix): initial render succeeds and reports nothing; a later signal-driven rerun reports phase: "binding" ✓ with node: undefined ✗. A class getter failing inside a mounted ErrorBoundary rendered no fallback while the global handler fired once. After the fix, all four carry the owning element and the boundary claims the error with zero global-handler calls.

Regression coverage added (44 tests in tests/tagFactory-class-style-error-owner-node.test.ts) — 15 of them fail against the unmodified baseline:

  • Metadata (16) — 4 forms × { phase === "binding" and node === el; exactly one report carrying the original Error by reference identity; the previously committed class/style value survives the failed commit; unrelated subscribers of the same signal keep running }.
  • ErrorBoundary integration (5) — all four forms genuinely mounted into the document: initial render succeeds, a post-mount signal update throws, the boundary renders its fallback, the global handler is not called, the error is not printed through the console fallback, and a sibling binding outside the boundary still updates. Plus a nested case where the inner boundary claims and the outer stays untouched.
  • Unclaimed fallback (9) — a DOM node must not make an error disappear: 4 forms mounted with no boundary reach the runtime handler (carrying the node), 4 forms reach console.error exactly once with no handler installed, plus a detached-element case.
  • Cleanup and retention (6) — 4 forms: subscriber present → dispose(el) → subscriber removed → a later signal update neither reruns the getter nor reports anything; a two-binding subtree released together; and _errorNode confirmed cleared on disposal via the existing inspectSignal + forEachSubscriber seams (no new production internals exposed).
  • Normal rendering (8) — reactive class strings and conditional class-object entries still update; whole-style and per-property sanitization still strip url(javascript:…); per-run dependency switching still subscribes newly read signals and prunes stale ones (class and style); static values create no subscriber; synchronous initial-render throws still propagate to the caller.

Preserved: public API signatures, bundle entry points, browser support floor, Node compatibility, class/style sanitization, reactive batching and scheduling, initial-render semantics, error object identity, runtime error phases, cleanup registration, per-run dependency tracking, duplicate-instance behaviour, tree-shaking. No version bump.

Verification — every gate green:

Gate Result
npm ci PASS
npm run lint PASS — 661 files
npm run typecheck PASS
npm run typecheck:tests PASS
npm test -- --run PASS — 449 files, 6320 passed, 1 skipped (baseline 448 / 6276 / 1 → +1 file, +44 tests)
npm run build PASS
git diff --check PASS
npm run test:browser PASS — 309 passed (Chromium + Firefox + WebKit)
npm run certify:rc PASS 15 / FAIL 0 / NOT TESTED 0
npm run test:soak PASS — 25 passed, 1 skipped

Certification detail: build, TS(src), lint, full suite, TS(tests+entry), wrapper invariants 26, browser floor 24, query/router/SSR fuzzing 21, browser matrix 309, lifecycle+SSR soak 25, npm pack + tarball install, packed subpath exports 157/157 across 16 subpaths, bundler matrix 12/12 builds and 12/12 runtime (tree-shake 8/12, unchanged from baseline), Node matrix 22.3.0 / 22 / 24 all PASS.

Related Issue

Closes #

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have read CONTRIBUTING.md
  • My code builds without errors
  • I have tested my changes
  • I have updated documentation if needed

@hexplus
hexplus merged commit e01c7bc into main Aug 30, 2026
5 checks passed
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.

1 participant