From 95db6cebccc65caa4cacc43c0ad1647a55d7ffef Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 15:11:54 -0600 Subject: [PATCH 1/6] Updated CHANGELOG and package.json --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c767a55..64c424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.0.3] — 2026-03-28 + +### Added + +- **Wider `NodeChild` / `NodeChildren` types** — `NodeChild` now accepts `boolean`; `NodeChildren` accepts nested arrays and full reactive functions. Conditional patterns like `condition && element` work without `as any` casts. Boolean values are filtered out in `appendChildren`, `bindChildNode`, `Fragment()`, `htm.ts`, and `resolveChild`. +- **`onCleanup()` lifecycle hook** — `onCleanup(callback, element)` registers teardown logic (closing sockets, clearing timers, removing listeners) tied to an element's disposal. Integrates with the existing `dispose()` system so cleanup runs automatically when `when()`, `match()`, or `each()` swap content. +- **`query()` `select` option** — Optional `select` function that transforms cached data before returning it to consumers. Raw response stays in cache; `select` runs on read, enabling derived views without extra signals. +- **`formatNumber()` and `formatCurrency()`** — `Intl`-based formatting utilities exported from `sibujs/browser`. `formatNumber` wraps `Intl.NumberFormat`; `formatCurrency` is a convenience shorthand that sets `style: "currency"`. + +### Fixed + +- **Boolean values no longer render as text** — `false`, `true` are filtered in all rendering paths (`tagFactory`, `bindChildNode`, `Fragment`, `htm.ts`, `resolveChild`) preventing visible `"false"` text nodes. +- **Lint fixes** — Resolved unused variable in `router.basic.test.ts` and formatting issues flagged by Biome. + +--- + ## [1.0.2] — 2026-03-27 ### Fixed diff --git a/package.json b/package.json index 4a30d20..a3cd741 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.0.2", + "version": "1.0.3", "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", From 9487727c338809848170d361ea8775a3fa149ad9 Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:30:29 -0600 Subject: [PATCH 2/6] ci: use npm install instead of npm ci --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aab4d99..e156d9e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Install dependencies - run: npm ci + run: npm install - name: Run tests run: npm test From 077718418208d14423f9aeddb63876ce57f6454c Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:51:26 -0600 Subject: [PATCH 3/6] trusted-publisher --- .github/workflows/publish.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cdf4e5b..f25d1f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,21 @@ on: release: types: [published] +permissions: + id-token: write + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - - name: Checkout código + - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" - name: Install dependencies @@ -28,5 +32,3 @@ jobs: - name: Publish to npm run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From ee7cf487a4e8438c2238b7ed54bc652e48b10b6d Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 11 Apr 2026 09:51:07 -0600 Subject: [PATCH 4/6] Updated main --- README.md | 54 +++++++++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 633c67a..61c59bc 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,10 @@ import { div, h1, button, signal, mount } from "sibujs"; function Counter() { const [count, setCount] = signal(0); - return div({ - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ - nodes: "Increment", - on: { click: () => setCount(c => c + 1) } - }) - ] - }); + return div({ class: "counter" }, [ + h1(() => `Count: ${count()}`), + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), + ]); } mount(Counter, document.getElementById("app")); @@ -43,32 +38,41 @@ mount(Counter, document.getElementById("app")); SibuJS gives you maximum flexibility with three interoperable styles: -#### 1. Tag Factory (Full Props) -Maximum control with an explicit properties object. Perfect for complex elements. +#### 1. Tag Factory +The canonical form: a props object followed by children as a second +positional argument. No `nodes:` key required at any level of the tree — +children can be a string, a number, a single node, an array, or a +reactive getter. ```javascript -import { div, h1, button } from "sibujs"; - -const [count, setCount] = signal(0); - -return div({ - class: "counter", - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) - ] -}); +import { div, h1, label, input, button } from "sibujs"; + +return div({ class: "counter" }, [ + h1({ class: "title" }, () => `Count: ${count()}`), + label({ for: "amount" }, "Step"), + input({ id: "amount", type: "number", value: 1 }), + button( + { class: "primary", on: { click: () => setCount(c => c + 1) } }, + "Increment", + ), +]); ``` -#### 2. Shorthand API -Concise and readable for common layouts. Class and children passed as positional arguments. +All legacy forms — `tag({ class, nodes })`, `tag("className", children)`, +`tag("text")`, `tag([children])`, `tag(node)`, `tag(() => reactive)` — +continue to work unchanged. When both `props.nodes` and the positional +second argument are present, the positional wins. + +#### 2. Positional Shorthand +The tersest form. Class and children as positional arguments, for +layouts with no event handlers or custom props. ```javascript import { div, h1, button } from "sibujs"; return div("counter", [ h1(() => `Count: ${count()}`), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), ]); ``` From 709afe884f54bc04988329247e61f03c8acc0898 Mon Sep 17 00:00:00 2001 From: hexplus Date: Fri, 29 May 2026 16:48:14 -0600 Subject: [PATCH 5/6] fix: re-track DOM binding deps on every run, not just the first --- CHANGELOG.md | 23 +++ docs/best-practices.md | 30 ++++ src/core/rendering/tagFactory.ts | 33 +++++ src/reactivity/bindAttribute.ts | 13 +- src/reactivity/bindChildNode.ts | 9 +- src/reactivity/bindTextNode.ts | 8 +- src/reactivity/track.ts | 72 +++++++++- tests/loneStringClassWarning.test.ts | 59 ++++++++ tests/perRunTracking.test.ts | 207 +++++++++++++++++++++++++++ tests/queryReactiveConsumer.test.ts | 94 ++++++++++++ 10 files changed, 534 insertions(+), 14 deletions(-) create mode 100644 tests/loneStringClassWarning.test.ts create mode 100644 tests/perRunTracking.test.ts create mode 100644 tests/queryReactiveConsumer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 07492d0..9253fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/best-practices.md b/docs/best-practices.md index a675361..6f0f0dd 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -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. diff --git a/src/core/rendering/tagFactory.ts b/src/core/rendering/tagFactory.ts index 3f3f5e0..c0d5741 100644 --- a/src/core/rendering/tagFactory.ts +++ b/src/core/rendering/tagFactory.ts @@ -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(); @@ -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; } diff --git a/src/reactivity/bindAttribute.ts b/src/reactivity/bindAttribute.ts index 0f46a1e..48f3ee4 100644 --- a/src/reactivity/bindAttribute.ts +++ b/src/reactivity/bindAttribute.ts @@ -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(); @@ -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); } /** @@ -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 () => { diff --git a/src/reactivity/bindChildNode.ts b/src/reactivity/bindChildNode.ts index a0c9a3e..55cc91f 100644 --- a/src/reactivity/bindChildNode.ts +++ b/src/reactivity/bindChildNode.ts @@ -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(); @@ -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); } diff --git a/src/reactivity/bindTextNode.ts b/src/reactivity/bindTextNode.ts index e3f1345..95cbc1f 100644 --- a/src/reactivity/bindTextNode.ts +++ b/src/reactivity/bindTextNode.ts @@ -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. @@ -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); } diff --git a/src/reactivity/track.ts b/src/reactivity/track.ts index 52d4502..8d45cf6 100644 --- a/src/reactivity/track.ts +++ b/src/reactivity/track.ts @@ -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; @@ -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 diff --git a/tests/loneStringClassWarning.test.ts b/tests/loneStringClassWarning.test.ts new file mode 100644 index 0000000..820b854 --- /dev/null +++ b/tests/loneStringClassWarning.test.ts @@ -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; + + 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(); + }); +}); diff --git a/tests/perRunTracking.test.ts b/tests/perRunTracking.test.ts new file mode 100644 index 0000000..f562c04 --- /dev/null +++ b/tests/perRunTracking.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { div } from "../src/core/rendering/html"; +import { derived } from "../src/core/signals/derived"; +import { effect } from "../src/core/signals/effect"; +import { signal } from "../src/core/signals/signal"; +import { watch } from "../src/core/signals/watch"; +import { bindAttribute } from "../src/reactivity/bindAttribute"; +import { bindChildNode } from "../src/reactivity/bindChildNode"; +import { bindTextNode } from "../src/reactivity/bindTextNode"; + +// Per-run dependency tracking: a reactive getter must subscribe to EVERY +// signal read on its MOST RECENT run, even signals first read on a later run, +// and must prune signals it stops reading. + +let parent: HTMLElement; +let placeholder: Comment; + +beforeEach(() => { + parent = document.createElement("div"); + placeholder = document.createComment("placeholder"); + parent.appendChild(placeholder); +}); + +describe("BUG 1 — per-run dependency tracking", () => { + describe("bindChildNode", () => { + it("subscribes to a signal first read on a LATER run (the repro)", () => { + const [total, setTotal] = signal(0); + const [bytes, setBytes] = signal(0); + + bindChildNode(placeholder, () => (total() ? `${bytes()} / ${total()}` : "waiting")); + + expect(parent.textContent).toBe("waiting"); + + setTotal(100); // re-runs; NOW bytes() is read for the first time + expect(parent.textContent).toBe("0 / 100"); + + setBytes(42); // must re-render because bytes is now a dependency + expect(parent.textContent).toBe("42 / 100"); + }); + + it("prunes a signal that STOPS being read on a later run (no over-subscription)", () => { + const [showA, setShowA] = signal(true); + const [a, setA] = signal("A"); + let runs = 0; + + bindChildNode(placeholder, () => { + runs++; + return showA() ? a() : "static"; + }); + + expect(parent.textContent).toBe("A"); + const runsAfterInit = runs; + + setShowA(false); // stops reading a() + expect(parent.textContent).toBe("static"); + const runsAfterSwitch = runs; + expect(runsAfterSwitch).toBeGreaterThan(runsAfterInit); + + setA("A2"); // a() is no longer a dependency — must NOT re-run + expect(runs).toBe(runsAfterSwitch); + expect(parent.textContent).toBe("static"); + }); + + it("handles nested ternaries first read on a later run", () => { + const [mode, setMode] = signal(0); + const [x, setX] = signal("x"); + const [y, setY] = signal("y"); + + bindChildNode(placeholder, () => (mode() === 0 ? "none" : mode() === 1 ? x() : y())); + + expect(parent.textContent).toBe("none"); + + setMode(1); + expect(parent.textContent).toBe("x"); + setX("X!"); + expect(parent.textContent).toBe("X!"); + + setMode(2); + expect(parent.textContent).toBe("y"); + setY("Y!"); + expect(parent.textContent).toBe("Y!"); + }); + + it("handles short-circuit (a() && b()) first read on a later run", () => { + const [enabled, setEnabled] = signal(false); + const [value, setValue] = signal("v0"); + + bindChildNode(placeholder, () => (enabled() && value()) || "off"); + + expect(parent.textContent).toBe("off"); + + setEnabled(true); // now value() is read for the first time + expect(parent.textContent).toBe("v0"); + + setValue("v1"); + expect(parent.textContent).toBe("v1"); + }); + }); + + describe("bindTextNode", () => { + it("subscribes to a signal first read on a later run", () => { + const [total, setTotal] = signal(0); + const [bytes, setBytes] = signal(0); + const text = document.createTextNode(""); + parent.appendChild(text); + + bindTextNode(text, () => (total() ? `${bytes()} / ${total()}` : "waiting")); + + expect(text.textContent).toBe("waiting"); + setTotal(100); + expect(text.textContent).toBe("0 / 100"); + setBytes(42); + expect(text.textContent).toBe("42 / 100"); + }); + }); + + describe("bindAttribute", () => { + it("subscribes to a class/style signal first read on a later run", () => { + const [active, setActive] = signal(false); + const [color, setColor] = signal("red"); + const el = document.createElement("div"); + + bindAttribute(el, "class", () => (active() ? `c-${color()}` : "idle")); + + expect(el.getAttribute("class")).toBe("idle"); + setActive(true); + expect(el.getAttribute("class")).toBe("c-red"); + setColor("blue"); + expect(el.getAttribute("class")).toBe("c-blue"); + }); + }); + + describe("tagFactory class getter (routes through applyClass, not bindAttribute)", () => { + it("subscribes to a class signal first read on a later run", () => { + const [active, setActive] = signal(false); + const [color, setColor] = signal("red"); + const el = div({ class: () => (active() ? `c-${color()}` : "idle") }); + + expect(el.getAttribute("class")).toBe("idle"); + setActive(true); + expect(el.getAttribute("class")).toBe("c-red"); + setColor("blue"); + expect(el.getAttribute("class")).toBe("c-blue"); + }); + }); + + describe("tagFactory style getter (routes through applyStyle)", () => { + it("subscribes to a style signal first read on a later run", () => { + const [on, setOn] = signal(false); + const [w, setW] = signal("10px"); + const el = div({ style: () => (on() ? `width:${w()}` : "") }) as HTMLElement; + + expect(el.getAttribute("style") ?? "").toBe(""); + setOn(true); + expect(el.getAttribute("style")).toBe("width:10px"); + setW("20px"); + expect(el.getAttribute("style")).toBe("width:20px"); + }); + }); + + describe("watch", () => { + it("fires for a signal first read on a later run", () => { + const [total, setTotal] = signal(0); + const [bytes, setBytes] = signal(0); + const seen: string[] = []; + watch( + () => (total() ? `${bytes()} / ${total()}` : "waiting"), + (v) => seen.push(v), + ); + + setTotal(100); // first time bytes() is read + setBytes(42); // must fire because bytes is now a dependency + expect(seen).toEqual(["0 / 100", "42 / 100"]); + }); + }); + + describe("derived (regression — must already work)", () => { + it("subscribes to a signal first read on a later run", () => { + const [total, setTotal] = signal(0); + const [bytes, setBytes] = signal(0); + const label = derived(() => (total() ? `${bytes()} / ${total()}` : "waiting")); + + expect(label()).toBe("waiting"); + setTotal(100); + expect(label()).toBe("0 / 100"); + setBytes(42); + expect(label()).toBe("42 / 100"); + }); + }); + + describe("effect (regression — must already work)", () => { + it("subscribes to a signal first read on a later run", () => { + const [total, setTotal] = signal(0); + const [bytes, setBytes] = signal(0); + let seen = ""; + effect(() => { + seen = total() ? `${bytes()} / ${total()}` : "waiting"; + }); + + expect(seen).toBe("waiting"); + setTotal(100); + expect(seen).toBe("0 / 100"); + setBytes(42); + expect(seen).toBe("42 / 100"); + }); + }); +}); diff --git a/tests/queryReactiveConsumer.test.ts b/tests/queryReactiveConsumer.test.ts new file mode 100644 index 0000000..3e596cd --- /dev/null +++ b/tests/queryReactiveConsumer.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { div } from "../src/core/rendering/html"; +import { signal } from "../src/core/signals/signal"; +import { clearQueryCache, query } from "../src/data/query"; + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +// BUG 3 — query data must reach a reactive consumer that reads q.data() +// only on a NON-FIRST run (status-branching: loading → error → data). This is +// the per-run dependency-tracking bug (BUG 1) observed through `query`: on the +// first render only q.loading() is read, so q.data() must be subscribed when +// it is first read on the later, not-loading run. + +describe("BUG 3 — query → DOM through a status-branching consumer", () => { + let parent: HTMLElement; + + beforeEach(() => { + clearQueryCache(); + parent = document.createElement("div"); + document.body.appendChild(parent); + }); + + afterEach(() => { + parent.remove(); + }); + + it("renders fetched data after the loading branch resolves", async () => { + const q = query("bug3:list", async () => ["a", "b", "c"]); + + const el = div(() => { + if (q.loading()) return "loading"; + if (q.error()) return `error: ${q.error()?.message}`; + const items = q.data() ?? []; + return items.join(","); + }); + parent.appendChild(el); + + // First render: loading → only q.loading() read. + expect(el.textContent).toBe("loading"); + + await tick(); + + // Fetch resolved → consumer must re-render with the data. + expect(el.textContent).toBe("a,b,c"); + q.dispose(); + }); + + it("propagates a later data update (setQueryData / refetch) to the consumer", async () => { + const [n, setN] = signal(1); + const q = query( + () => `bug3:k:${n()}`, + async ({ key }) => `data-for-${key}`, + ); + + const el = div(() => { + if (q.loading()) return "loading"; + if (q.error()) return "error"; + return q.data() ?? "empty"; + }); + parent.appendChild(el); + + expect(el.textContent).toBe("loading"); + await tick(); + expect(el.textContent).toBe("data-for-bug3:k:1"); + + // Changing the key re-fetches; consumer must follow. + setN(2); + await tick(); + expect(el.textContent).toBe("data-for-bug3:k:2"); + q.dispose(); + }); + + it("renders the error branch when the fetch rejects", async () => { + const q = query( + "bug3:err", + async () => { + throw new Error("boom"); + }, + { retry: { maxRetries: 0 } }, + ); + + const el = div(() => { + if (q.loading()) return "loading"; + if (q.error()) return `error: ${q.error()?.message}`; + return "data"; + }); + parent.appendChild(el); + + expect(el.textContent).toBe("loading"); + await tick(); + expect(el.textContent).toBe("error: boom"); + q.dispose(); + }); +}); From f3615f20feffa4293b6f0c6dae169e16489b509c Mon Sep 17 00:00:00 2001 From: hexplus Date: Fri, 29 May 2026 17:20:28 -0600 Subject: [PATCH 6/6] Updated version --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9253fe0..ca2b44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This project follows [Semantic Versioning](https://semver.org/). --- -## [Unreleased] +## [3.1.0] — 2026-05-29 ### Fixed diff --git a/package.json b/package.json index 3de3c54..176a247 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "3.0.0", + "version": "3.1.0", "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",