Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
95db6ce
Updated CHANGELOG and package.json
hexplus Mar 28, 2026
56080d8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
7eeec49
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
14a9cd4
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
9487727
ci: use npm install instead of npm ci
hexplus Mar 29, 2026
6b4bd83
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0b9a0cc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0777184
trusted-publisher
hexplus Mar 29, 2026
4d46e82
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
bea9788
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
825a8dc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
55c4436
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0d2c7e0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
8da81e8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
325ce5d
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0cad329
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 1, 2026
aea6787
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 4, 2026
00e5e88
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
b10a2c5
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
639eae0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 9, 2026
405e4fe
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
ee7cf48
Updated main
hexplus Apr 11, 2026
8c77fca
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
da6d752
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
c047837
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
a52fffc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
43b5675
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 14, 2026
44df880
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 18, 2026
aba311a
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
4bf3286
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
709afe8
fix: re-track DOM binding deps on every run, not just the first
hexplus May 29, 2026
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ This project follows [Semantic Versioning](https://semver.org/).

---

## [Unreleased]

### Fixed

- **Per-run dependency tracking for DOM bindings** — a reactive child (`() => value`), a reactive `class`/`style` getter, and `watch` now re-track their dependencies on **every** run, matching `derived` and `effect`. Previously these bindings subscribed only to the signals read on their *first* evaluation: a signal first read on a later run (e.g. a conditional branch that becomes live after a state change) was never subscribed, so updates to it did not re-render. Signals no longer read on the latest run are also pruned (no over-subscription). The fix is centralized in `track()` — any eagerly-re-running binding registered without an explicit subscriber now uses a self-re-tracking subscriber.

```ts
const [total, setTotal] = signal(0);
const [bytes, setBytes] = signal(0);
const el = div(() => (total() ? `${bytes()} / ${total()}` : "waiting"));
mount(() => el, root);
setTotal(100); // re-runs; bytes() first read here
setBytes(42); // now "42 / 100" — previously stayed "0 / 100"
```

This also fixes `query()` data not reaching a status-branching consumer (`if (q.loading()) …; return List(q.data())`) where `data()` was only read once not loading.

### Added

- **Dev warning for a misplaced lone class string** — `tag("space-y-6")` still renders the string as a text child (unchanged), but development builds now warn when a lone string looks like a CSS class list, hinting `tag({ class: "…" })` or `tag("…", children)`. Prose strings never trigger the warning.

---

## [3.0.0] — 2026-04-19

### Breaking
Expand Down
30 changes: 30 additions & 0 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,36 @@ const text = `Count: ${count()}`; // captured once, never updates
div({ nodes: text }); // static, won't react to changes
```

### Reactive reads are per-run dependency tracking

A reactive getter — a reactive child `() => value`, a `class`/`style` getter, `derived`, `effect`, `watch` — is reactive to exactly the signals it reads on its **most recent** run, not the union of every signal it has ever read. The engine recomputes the dependency set on every evaluation: signals read on the latest run are subscribed (even if a conditional branch read them for the first time), and signals no longer read are unsubscribed.

```ts
const [total, setTotal] = signal(0);
const [bytes, setBytes] = signal(0);

const el = div(() => {
// First run: total() === 0 → else branch → bytes() is never read.
return total() ? `${bytes()} / ${total()}` : "waiting";
});
mount(() => el, root);

setTotal(100); // re-runs the getter; NOW bytes() is read for the first time
setBytes(42); // text becomes "42 / 100" — bytes is now a tracked dependency
```

This means two things in practice:

- **You can rely on it.** A branch that becomes live later subscribes its signals automatically; you do not need to "pre-read" every signal to keep them reactive. Conversely, a branch you stop taking is pruned, so abandoned signals no longer trigger re-renders (no over-subscription).
- **If you want a *stable* subscription regardless of branch**, read the conditionally-needed signal up front:

```ts
div(() => {
const b = bytes(); // always read → always subscribed
return total() ? `${b} / ${total()}` : "waiting";
});
```

### Use `batch()` for multiple updates

When updating several signals at once, wrap them in `batch()` to coalesce into a single notification pass.
Expand Down
33 changes: 33 additions & 0 deletions src/core/rendering/tagFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ export interface TagProps {
[attr: string]: unknown;
}

// Heuristic: does a lone string argument look like a CSS class list rather
// than human-readable text? Used ONLY to emit a dev warning — never to change
// behavior. A lone string is always a text child (see tagFactory), but a value
// like "space-y-6" or "h-6 w-48" is almost certainly a misplaced className, so
// we surface the footgun loudly. Conservative on purpose: prose words ("Hello
// world") never trip it; only strings whose every token is class-shaped AND at
// least one token carries a class-indicator char (hyphen, colon, slash, digit)
// — the Tailwind-utility shape that bit downstream users.
function looksLikeClassList(s: string): boolean {
const t = s.trim();
if (!t) return false;
const tokens = t.split(/\s+/);
let sawClassish = false;
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i];
// Every token must be a plausible CSS class token.
if (!/^-?[A-Za-z_][A-Za-z0-9_:/.-]*$/.test(tok)) return false;
// A hyphen / colon / slash / digit marks a utility-class token
// (h-6, md:flex, w-1/2). Plain words ("Hello") do not qualify.
if (/[-:/0-9]/.test(tok)) sawClassish = true;
}
return sawClassish;
}

// Cache for camelCase → kebab-case conversions
const kebabCache = new Map<string, string>();

Expand Down Expand Up @@ -240,6 +264,15 @@ export const tagFactory = (tag: string, ns?: string) => {
appendChildren(el, second);
return el;
}
// Lone string → text child (unchanged). Warn in dev if it looks like a
// misplaced class list so a styled empty wrapper doesn't silently render
// its class names as visible text.
if (_isDev && looksLikeClassList(first)) {
devWarn(
`tagFactory: lone string "${first}" looks like a class list but is being rendered as TEXT. ` +
`For a class, use ${tag}({ class: "${first}" }) — or ${tag}("${first}", children) to set the class AND add children.`,
);
}
el.textContent = first;
return el;
}
Expand Down
13 changes: 7 additions & 6 deletions src/reactivity/bindAttribute.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { devWarn, isDev } from "../core/dev";
import { isUrlAttribute, sanitizeUrl } from "../utils/sanitize";
import { track } from "./track";
import { reactiveBinding } from "./track";

const _isDev = isDev();

Expand Down Expand Up @@ -80,9 +80,9 @@ export function bindAttribute(el: HTMLElement, attr: string, getter: () => unkno
}
}

// Initial run + reactive updates
const teardown = track(commit);
return teardown;
// Initial run + reactive updates. Re-tracks deps every run so a signal first
// read on a later run is subscribed (per-run dependency tracking).
return reactiveBinding(commit);
}

/**
Expand Down Expand Up @@ -141,8 +141,9 @@ export function bindDynamic(
prevName = name;
}

// Initial run + reactive updates
const teardown = track(commit);
// Initial run + reactive updates. Re-tracks deps every run so a signal first
// read on a later run is subscribed (per-run dependency tracking).
const teardown = reactiveBinding(commit);

// Return a combined teardown: stop tracking and clean up the current attribute
return () => {
Expand Down
9 changes: 6 additions & 3 deletions src/reactivity/bindChildNode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { devWarn, isDev } from "../core/dev";
import type { NodeChild } from "../core/rendering/types";
import { track } from "./track";
import { reactiveBinding } from "./track";

const _isDev = isDev();

Expand Down Expand Up @@ -101,6 +101,9 @@ export function bindChildNode(placeholder: Comment, getter: () => NodeChild | No
lastNodes = newNodes;
}

// Initial render and reactive subscription
return track(commit);
// Initial render and reactive subscription. `reactiveBinding` re-tracks
// dependencies on every run, so a signal first read on a later run (e.g. a
// conditional branch that only becomes live after a state change) is still
// subscribed.
return reactiveBinding(commit);
}
8 changes: 4 additions & 4 deletions src/reactivity/bindTextNode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { devWarn, isDev } from "../core/dev";
import { track } from "./track";
import { reactiveBinding } from "./track";

/**
* Binds a reactive getter to a Text node, updating its content reactively.
Expand All @@ -25,7 +25,7 @@ export function bindTextNode(textNode: Text, getter: () => string | number): ()
textNode.textContent = String(value);
}

// Initial render and reactive subscription
const teardown = track(commit);
return teardown;
// Initial render and reactive subscription. Re-tracks deps every run so a
// signal first read on a later run is subscribed (per-run dependency tracking).
return reactiveBinding(commit);
}
72 changes: 71 additions & 1 deletion src/reactivity/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,16 @@ export function retrack(effectFn: () => void, subscriber: Subscriber): void {
// the current subscriber directly, so no shared stack is needed.
// ---------------------------------------------------------------------------
export function track(effectFn: () => void, subscriber?: Subscriber): () => void {
if (!subscriber) subscriber = effectFn;
// No explicit subscriber → this is an eagerly-re-running reactive binding
// (the common `track(commit)` form used by class/style getters, directives,
// router views, `watch`, `each`, etc.). Route it through `reactiveBinding`
// so every re-run re-tracks dependencies. Using the body itself as the
// subscriber (the old behavior) meant re-runs were invoked WITHOUT a tracking
// context, so signals first read on a later run were never subscribed — the
// per-run-tracking correctness bug. An EXPLICIT subscriber (e.g. `derived`'s
// `markDirty`) keeps the run-once semantics below; such callers drive their
// own re-evaluation via `retrack`.
if (!subscriber) return reactiveBinding(effectFn);
cleanup(subscriber);

const prev = currentSubscriber;
Expand Down Expand Up @@ -356,6 +365,67 @@ export function track(effectFn: () => void, subscriber?: Subscriber): () => void
return sub._dispose ?? (sub._dispose = () => cleanup(subscriber));
}

// ---------- reactiveBinding ------------------------------------------------
//
// Eagerly re-running reactive binding used by the DOM binding paths
// (bindChildNode / bindTextNode / bindAttribute). The subtlety it fixes:
//
// A bare `track(commit)` registers `commit` ITSELF as the subscriber. On the
// first run `commit` records its deps, but when a signal later notifies, the
// drain invokes `commit()` DIRECTLY — with no `currentSubscriber` set and no
// epoch reset. So `recordDependency` is a no-op on every re-run: deps read
// for the FIRST time on a later run are never subscribed, and deps no longer
// read are never pruned. The binding is reactive only to whatever it read on
// its very first evaluation.
//
// The fix mirrors `effect()` / `derived()`: register a self-retracking
// subscriber. Every notification re-runs `commit` through `retrack`, which
// re-establishes the dependency set per run — adding newly-read deps and
// pruning stale ones. Returns a disposer that tears down all edges.
//
// The `_reentrant` guard makes every `retrack` of a given subscriber mutually
// exclusive. It is REQUIRED for correctness, not just loop safety: a `commit`
// that writes to one of its own deps mid-run (e.g. ErrorBoundary's content
// getter calls `setError` when a child render throws) would otherwise trigger
// the drain to re-invoke the subscriber synchronously, nesting a second
// `retrack` inside the first. The nested run bumps shared dep edges to a newer
// epoch, and the OUTER run's post-walk then prunes those edges as "stale" —
// silently dropping live subscriptions. Skipping the synchronous re-entry
// keeps the epoch bookkeeping single-threaded; the write still re-enqueues the
// subscriber, so the drain re-runs it once the outer run unwinds (eventual
// consistency, bounded by the drain's `tickRepeat` cap).
//
// The guard wraps BOTH the initial run and every notification-driven run.
// ---------------------------------------------------------------------------
export function reactiveBinding(commit: () => void): () => void {
const run = (): void => {
const s = subscriber as SubWithList & { _reentrant?: boolean };
if (s._reentrant) return;
s._reentrant = true;
try {
retrack(commit, subscriber);
} finally {
s._reentrant = false;
}
};
const subscriber = run as SubWithList & { _reentrant?: boolean };

// Pre-initialize every field the core touches so all binding subscribers
// share one hidden class (monomorphic inline caches in retrack / cleanup).
subscriber.depsHead = null;
subscriber.depsTail = null;
subscriber._epoch = 0;
subscriber._structDirty = false;
subscriber._runEpoch = 0;
subscriber._runs = 0;
subscriber._reentrant = false;

// Initial run establishes the first dependency set (guarded, see above).
run();

return subscriber._dispose ?? (subscriber._dispose = () => cleanup(subscriber));
}

// ---------- recordDependency ----------------------------------------------
//
// Called for every signal read inside a tracking context. O(1) in all cases
Expand Down
59 changes: 59 additions & 0 deletions tests/loneStringClassWarning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { div, span } from "../src/core/rendering/html";

// BUG 2 — a lone string argument is a TEXT child (unchanged behavior), but the
// framework warns in dev when that string looks like a misplaced class list so
// a styled empty wrapper doesn't silently render its class names as text.

describe("BUG 2 — lone class-like string warning (core tag)", () => {
let warn: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});

it("renders a lone string as text (behavior unchanged)", () => {
const el = div("space-y-6");
expect(el.textContent).toBe("space-y-6");
});

it("warns when a lone string looks like a class list", () => {
div("space-y-6");
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("looks like a class list");
expect(warn.mock.calls[0][0]).toContain('class: "space-y-6"');
});

it("warns for multi-token Tailwind-shaped strings", () => {
div("h-6 w-48");
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("h-6 w-48");
});

it("does NOT warn for prose text", () => {
div("Hello world");
span("Click here to continue");
expect(warn).not.toHaveBeenCalled();
});

it("does NOT warn for a single plain word (could be legit text)", () => {
span("New");
expect(warn).not.toHaveBeenCalled();
});

it("does NOT warn when a class string is passed positionally with children", () => {
const el = div("space-y-6", [span("child")]);
expect(el.getAttribute("class")).toBe("space-y-6");
expect(el.textContent).toBe("child");
expect(warn).not.toHaveBeenCalled();
});

it("does NOT warn when the class is passed via props", () => {
const el = div({ class: "space-y-6" });
expect(el.getAttribute("class")).toBe("space-y-6");
expect(warn).not.toHaveBeenCalled();
});
});
Loading
Loading