Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ This project follows [Semantic Versioning](https://semver.org/).
---
---

## [4.0.1] — 2026-08-29

An error-routing fix for reactive `class` and `style` bindings. No breaking changes.

### Fixed

- **A reactive `class` or `style` getter that throws on a later update now reaches the enclosing `ErrorBoundary`** — `tagFactory` registered all four reactive forms (`class: () => …`, `class: { active: () => … }`, `style: () => …` and `style: { color: () => … }`) with a bare one-argument `track(commit)`. That builds the correct self-retracking binding and correctly labels its failures `phase: "binding"`, but it supplies no owner node, so the subscriber carried `node: undefined`. When such a getter then threw on a scheduled re-run, the runtime reported the failure with no DOM position to work from: the boundary-propagation event had nowhere to dispatch from, no enclosing `ErrorBoundary` could be located, and the error skipped the boundary entirely — falling straight through to the configured runtime error handler, or `console.error` when none was installed. Every other reactive attribute is bound through `bindAttribute`, which passes the element, so `class` and `style` were the only reactive props whose scheduled failures a boundary could never catch.

All four now bind through `reactiveBinding(commit, el)` and report the element they belong to, so a boundary above the failing element claims the error and renders its fallback. An error that no boundary claims still falls through to the handler and then the console exactly as before — carrying a DOM node does not make a failure disappear. Class and style sanitization, disposal and cleanup registration, batching and scheduling, per-run dependency tracking, and synchronous initial-render semantics are all unchanged.

---

## [4.0.0] — 2026-08-28

SibuJS 4.0 is a stability release. The public API is the 3.x API: nothing was
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sibujs",
"version": "4.0.0",
"version": "4.0.1",
"description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.",
"keywords": [
"frontend",
Expand Down
29 changes: 21 additions & 8 deletions src/core/rendering/tagFactory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { devWarn, isDev } from "../../core/dev";
import { bindAttribute } from "../../reactivity/bindAttribute";
import { bindChildNode } from "../../reactivity/bindChildNode";
import { track } from "../../reactivity/track";
import { reactiveBinding } from "../../reactivity/track";
import { isEventHandlerAttr, sanitizeCSSValue, sanitizeStyleAttribute } from "../../utils/sanitize";
import { setSafeAttribute } from "../../utils/setSafeAttribute";
import { registerDisposer } from "./dispose";
Expand Down Expand Up @@ -92,15 +92,28 @@ function toKebab(prop: string): string {
return cached;
}

// Reactive class/style getters are DOM bindings, so they are registered with
// `reactiveBinding(commit, el)` rather than a bare `track(commit)`. Both create
// the same self-retracking subscriber — `track(commit)` delegates straight to
// `reactiveBinding(commit)` — but only the two-argument form stamps the owning
// node on it. Without that node, a getter that throws on a LATER scheduled run
// is reported from the drain with `node: undefined`, so `reportError` has no
// DOM position to dispatch from, no enclosing `ErrorBoundary` can be found, and
// the failure skips straight past the boundary to the global handler/console —
// unlike every other reactive attribute, which goes through `bindAttribute`.
//
// `track`'s second parameter is an explicit SUBSCRIBER, not an owner node, so
// it is not the right seam here.

function applyStyle(el: Element, style: TagProps["style"]) {
// A whole style STRING is a declaration list and gets the same per-property
// policy the object form below already applies. Writing it raw made the
// string form a security escape hatch from the object form — identical
// authoring intent with two different policies.
if (typeof style === "function") {
const teardown = track(() => {
const teardown = reactiveBinding(() => {
el.setAttribute("style", sanitizeStyleAttribute(String((style as () => string)())));
});
}, el);
registerDisposer(el, teardown);
return;
}
Expand All @@ -116,9 +129,9 @@ function applyStyle(el: Element, style: TagProps["style"]) {
const name = toKebab(prop);
if (typeof val === "function") {
const getter = val as () => string | number;
const teardown = track(() => {
const teardown = reactiveBinding(() => {
htmlEl.style.setProperty(name, sanitizeCSSValue(String(getter())));
});
}, el);
registerDisposer(el, teardown);
} else {
htmlEl.style.setProperty(name, sanitizeCSSValue(String(val)));
Expand All @@ -133,9 +146,9 @@ function applyClass(el: Element, cls: TagProps["class"]) {
}

if (typeof cls === "function") {
const teardown = track(() => {
const teardown = reactiveBinding(() => {
el.setAttribute("class", (cls as () => string)());
});
}, el);
registerDisposer(el, teardown);
return;
}
Expand Down Expand Up @@ -163,7 +176,7 @@ function applyClass(el: Element, cls: TagProps["class"]) {
}
el.setAttribute("class", r);
};
const teardown = track(update);
const teardown = reactiveBinding(update, el);
registerDisposer(el, teardown);
} else {
el.setAttribute("class", result);
Expand Down
Loading
Loading