From 95db6cebccc65caa4cacc43c0ad1647a55d7ffef Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 15:11:54 -0600 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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 724c0e22b9b51d9f0c46a61d9d74c5d990989feb Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 11 Apr 2026 22:49:48 -0600 Subject: [PATCH 5/5] =?UTF-8?q?fix(framework):=20fix=2030=20bugs=20across?= =?UTF-8?q?=20core=20reactivity,=20data=20fetching,=20routing,=20lifecycle?= =?UTF-8?q?,=20forms,=20UI=20utilities,=20and=20browser=20composables=20?= =?UTF-8?q?=E2=80=94=20deepEqual=20shared-ref/Map/Set/cycle=20handling,=20?= =?UTF-8?q?deferredValue=20reactive=20subscription,=20optimistic=20concurr?= =?UTF-8?q?ent=20safety=20+=20pending=20exposure,=20router=20wildcard=20ma?= =?UTF-8?q?tching=20+=20guard=20cleanup,=20dispose=20error=20isolation,=20?= =?UTF-8?q?derived=20circular=20detection,=20drain=20queue=20loop=20guard,?= =?UTF-8?q?=20and=2020+=20cleanup/leak=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 97 +++++++++ package.json | 2 +- src/browser/fullscreen.ts | 8 +- src/browser/scroll.ts | 31 ++- src/browser/speech.ts | 8 +- src/browser/urlState.ts | 38 +++- src/build/staticAnalysis.ts | 2 +- src/components/ErrorBoundary.ts | 2 +- src/components/ErrorDisplay.ts | 10 +- src/core/rendering/action.ts | 3 +- src/core/rendering/context.ts | 22 +- src/core/rendering/createId.ts | 6 +- src/core/rendering/directives.ts | 8 +- src/core/rendering/dispose.ts | 10 +- src/core/rendering/dynamic.ts | 2 +- src/core/rendering/fragment.ts | 14 +- src/core/rendering/lazy.ts | 29 ++- src/core/rendering/lifecycle.ts | 44 ++-- src/core/rendering/portal.ts | 29 +-- src/core/signals/deepSignal.ts | 100 +++++++-- src/core/signals/derived.ts | 37 ++-- src/core/signals/signal.ts | 6 +- src/data/mutation.ts | 10 + src/data/query.ts | 3 +- src/data/resource.ts | 5 +- src/data/retry.ts | 8 +- src/devtools/signalGraph.ts | 47 ++--- src/ecosystem/adapters/mobx.ts | 2 +- src/ecosystem/adapters/redux.ts | 2 +- src/ecosystem/adapters/zustand.ts | 2 +- src/ecosystem/ui/componentAdapter.ts | 2 +- src/patterns/componentProps.ts | 13 +- src/patterns/globalStore.ts | 2 +- src/patterns/hoc.ts | 2 +- src/patterns/optimistic.ts | 147 ++++++++++--- src/patterns/persist.ts | 3 +- src/performance/concurrent.ts | 30 ++- src/platform/microfrontend.ts | 2 +- src/plugins/i18n.ts | 6 +- src/plugins/router.ts | 27 +-- src/reactivity/concurrent.ts | 2 +- src/reactivity/track.ts | 11 + src/ui/a11y.ts | 40 ++-- src/ui/a11yPrimitives.ts | 12 +- src/ui/dialog.ts | 34 ++- src/ui/form.ts | 21 +- src/ui/formAction.ts | 13 +- src/ui/inputMask.ts | 51 ++++- src/ui/scopedStyle.ts | 9 +- src/ui/socket.ts | 5 +- src/ui/transition.ts | 18 +- src/ui/virtualList.ts | 7 +- tests/deepSignal.test.ts | 300 ++++++++++++++++++++++++++- tests/optimistic.test.ts | 227 +++++++++++++++++--- tests/urlState.test.ts | 126 ++++++++++- 55 files changed, 1365 insertions(+), 332 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85977ac..9c79e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,103 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.5.0] — 2026-04-11 + +Comprehensive bug-fix and hardening release. **30 bugs fixed across 29 files**, covering the reactive core, data fetching, state management, routing, rendering, lifecycle, forms, UI utilities, browser composables, and devtools. Full framework audit with 2178/2178 tests passing, zero regressions. + +### Breaking + +- **`optimistic()` return shape changed** — previously returned a `[getter, setter]` tuple; now returns a named object `{ value, pending, update }`. The `pending` signal was created internally but never exposed (Bug: users had no way to show loading indicators). The `update` method now uses a version counter to prevent stale reverts from concurrent operations. Migration: + ```ts + // before + const [value, addOptimistic] = optimistic(0); + + // after + const { value, pending, update } = optimistic(0); + ``` + +- **`optimisticList()` method names shortened** — `addOptimistic` → `add`, `removeOptimistic` → `remove`, `updateOptimistic` → `update`. The old names are kept as deprecated aliases so existing code keeps working. + +### Fixed — Core Reactivity + +- **`deepEqual` shared-reference false positive** — the `seen` set tracked only `a`, not `(a, b)` pairs. Shared sub-objects compared against different partners were incorrectly treated as equal. Now tracks `Map>` pairs. +- **`deepEqual` constructor mismatch** — `deepEqual(new Date(), {})` returned `true` because Date has no enumerable keys. Added constructor guard before falling through to key comparison. +- **`deepEqual` Map/Set not compared** — `Map` and `Set` contents were invisible to `Object.keys`. Added explicit Map (deep value equality) and Set (shallow membership) branches, plus ArrayBuffer and TypedArray support. +- **`deepEqual` self-referential Map/Set** — cycle detection was placed after the Map/Set branches, causing infinite recursion on self-referential containers. Moved cycle detection before all container comparisons. +- **`derived` circular dependency** — circular derived chains caused silent stack overflow. Added an `evaluating` re-entrance flag that throws a clear `"Circular dependency detected"` error with the signal's debug name. +- **`drainNotificationQueue` infinite loop** — an effect writing to a signal it reads could loop forever. Added a `MAX_DRAIN_ITERATIONS = 1000` cap with a console error diagnostic. +- **`deferredValue` never updated** — had no reactive subscription on the source getter (no `effect`/`track`). Rewrote to use `effect()` for source tracking, scheduling LOW-priority updates via the scheduler. + +### Fixed — Data Fetching + +- **`resource.abort()` left `loading()` stuck at `true`** — the `AbortError` catch returned without resetting the loading signal. Now calls `setLoading(false)` in the abort path. +- **`query` subscriber leak on same-key re-run** — effect re-runs with an unchanged key double-counted `entry.subscribers`, preventing cache GC. Now only increments when the key actually changed or the entry has zero subscribers. +- **`mutation` concurrent state clobbering** — rapid `mutate()` calls raced without guard. Added a `runId` version counter; stale responses are silently ignored. +- **`withRetry` abort listener leak** — the `abort` event listener on `AbortSignal` was never removed when the delay timer resolved normally. Added `removeEventListener` in the timer resolve path. + +### Fixed — State Patterns + +- **`optimistic` concurrent stale reverts** — each operation now gets a version number; reverts only fire if no newer operation has started. Prevents stale snapshots from overwriting fresher optimistic state. +- **`optimistic` `pending` never exposed** — the `pending` signal was created but never returned. Now exposed in the return object for both `optimistic` and `optimisticList`. +- **`optimisticList.updateOptimistic` predicate failure after patch** — the success-path predicate re-ran against the already-mutated item. If the patch changed the matched property, the server result was silently dropped. Now captures patched references during the optimistic phase and matches by identity in the success path. +- **`persisted` effect not stopped by `dispose()`** — the persisting effect's return value was discarded, so `dispose()` only removed the storage listener but left the effect running. Now captured and called in `dispose()`. +- **`globalStore` shallow initial copy** — `reset()` could fail to fully restore nested objects if they were mutated in-place. Changed to `JSON.parse(JSON.stringify(...))` for a deep copy of initial state. + +### Fixed — Routing + +- **Wildcard route too permissive** — `/admin/*` incorrectly matched `/admin-panel` because the check used `path.startsWith(basePath)` without a segment boundary. Now requires `path === basePath || path.startsWith(basePath + "/")`. +- **Guard timeout/abort listener leak** — when `next()` was called asynchronously, the microtask-based cleanup had already run and missed it. Moved `clearTimeout` + `removeEventListener` into the `next()` callback itself. The abort handler now also clears the timeout timer. + +### Fixed — Rendering & Lifecycle + +- **`dispose()` one throwing disposer aborted entire subtree cleanup** — wrapped each disposer call in try/catch with a dev-mode warning. +- **`onMount` cleanup return discarded** — the type signature accepted a cleanup return function but `safeCall` discarded it. Now captured and registered via `registerDisposer(element, cleanup)`. +- **`onMount` MutationObserver leaked** — if an element was disposed before ever connecting to the DOM, the observer on `document.body` ran forever. Now registered for cleanup via `registerDisposer`. +- **`onUnmount` observer ran for element's entire lifetime** — the MutationObserver on `document.body` fired on every DOM mutation globally. Now registered for cleanup via `registerDisposer` and the callback itself is also wired through `registerDisposer` as the primary teardown path. +- **`Portal` cleanup via MutationObserver only** — didn't integrate with `dispose()`/`when()`/`match()`/`each()`. Replaced with `registerDisposer(anchor, ...)` so portal content is properly disposed and removed through the standard dispose system. +- **`lazy` stale load** — if the container was removed before the dynamic import resolved, the rendered component leaked subscriptions. Added a `disposed` guard that silently drops stale `.then()`/`.catch()` callbacks. Removed dead `_status`/`_error` signals that were created but never read. + +### Fixed — UI Utilities + +- **`bindField` merge order** — `{...fieldOn, ...extraOn}` let extras clobber field handlers (input/change/blur). Contradicted the 1.0.4 fix intent. Flipped to `{...extraOn, ...fieldOn}` so field handlers always win. +- **`form.handleSubmit` double-submit** — no guard against concurrent async submissions. Added a `submitting` signal; `handleSubmit` checks it before calling the callback and resets on resolve/reject. Exposed as `form.submitting()` on `FormReturn`. +- **`inputMask` cursor jump** — no cursor position restoration after mask application; cursor jumped to end on every keystroke. Added cursor tracking that counts raw chars before the old cursor position and places the cursor after that many filled slots in the masked output. +- **`inputMask` strip regex too aggressive** — `/[^a-zA-Z0-9]/g` stripped all special characters, making `*` mask slots unable to accept non-alphanumeric input. Now builds a pattern-aware strip regex: patterns with `*` only strip literal mask characters. +- **`transition` rapid enter/leave** — stale `setTimeout` callbacks from a previous enter/leave fired during the opposite animation, corrupting class state. Added `activeTimer` tracking with `cancelPending()` at the start of each enter/leave. +- **`scopedStyle` pseudo-element scoping** — scope attribute was appended after `::before`/`::after` pseudo-elements, producing invalid CSS selectors. Now splits at `::` and inserts `[attr]` before the pseudo-element. +- **`VirtualList` scroll listener leak** — the scroll event listener was never cleaned up. Added `registerDisposer` with `removeEventListener`. +- **`dialog` no dispose** — the global keydown listener leaked if the dialog was open when the component was destroyed. Added `dispose()` method that detaches the listener and resets state. +- **`FocusTrap` observer scope** — MutationObserver watched only the direct parent; ancestor removal leaked the observer and missed focus restore. Changed to `document.body` with `subtree: true`. Added `registerDisposer` integration for SPA cleanup. Zero-focusable-elements case now calls `e.preventDefault()` to prevent Tab from escaping the trap. + +### Fixed — Browser Composables + +- **`urlState` missing `hashchange` listener** — anchor clicks and `location.hash` assignments don't fire `popstate`, so `hash()` went stale. Added `hashchange` listener alongside `popstate`. Added deduplication guard to avoid unnecessary signal notifications. `setHash("#")` now clears the hash instead of keeping a bare `#`. +- **`scroll` non-reactive target** — the scroll target element was resolved once at creation and never re-evaluated. Rewrote to use `effect()` for reactive target tracking, re-attaching the listener when the element changes (same pattern as `resize`/`dragDrop`). +- **`socket.close()` auto-reconnected** — the `onclose` handler couldn't distinguish manual close from unexpected disconnect. Added a `manuallyClosed` flag set in `close()` and checked in `onclose` to suppress auto-reconnect. + +### Fixed — DevTools + +- **`createTraceProfiler` subscribed to non-existent events** — listened for `effect:start`/`effect:end`/`signal:set` but the core emits `effect:create`/`effect:destroy`/`signal:update`. Fixed event names and changed to instant (`"I"`) events since the core doesn't emit begin/end pairs. + +### Changed + +- **`optimistic()` returns a named object** — `{ value, pending, update }` instead of `[getter, setter]`. See Breaking section. +- **`optimisticList()` shorter method names** — `add`/`remove`/`update` with deprecated `addOptimistic`/`removeOptimistic`/`updateOptimistic` aliases. +- **`deepSignal` return type** — now infers from `signal()` directly, preserving the `Accessor` brand on the getter. +- **`hotkey` `global` option removed** — was declared but never used (dead code). +- **`context` JSDoc updated** — accurately describes global reactive store semantics instead of falsely promising subtree-scoped DI. +- **JSDoc examples across 17 source files** — ~35 code examples converted from legacy `{ nodes: }` form to canonical positional shorthand. +- **README** — updated to canonical shorthand authoring style; `$(pattern matching)$` typo fixed. + +### Tests + +- **`deepSignal.test.ts`** — expanded from 4 → 52 tests covering Map, Set, TypedArray, shared refs, cycles, constructor mismatch. +- **`urlState.test.ts`** — expanded from 6 → 20 tests covering hashchange, dedup, edge cases, SSR. +- **`optimistic.test.ts`** — expanded from 5 → 17 tests covering pending, concurrent guards, predicate-after-mutation. +- Full suite: **2178 / 2178 passing** (up from 2105 in 1.4.0). Zero regressions. + +--- + ## [1.4.0] — 2026-04-11 Cleanup release. Removes six public aliases that contradicted the SibuJS philosophy — plain verbs, no framework ceremony, no redundant synonyms for the same primitive. All of the removed APIs were either one-line forwards to an existing primitive or identity wrappers; every existing example can be rewritten by deleting the wrapper and calling the underlying primitive directly. diff --git a/package.json b/package.json index 3e288bf..e8b921a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.4.0", + "version": "1.5.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", diff --git a/src/browser/fullscreen.ts b/src/browser/fullscreen.ts index 4f52d18..8f31f55 100644 --- a/src/browser/fullscreen.ts +++ b/src/browser/fullscreen.ts @@ -11,10 +11,10 @@ import { signal } from "../core/signals/signal"; * @example * ```ts * const fs = fullscreen(); - * button({ - * nodes: () => (fs.isFullscreen() ? "Exit fullscreen" : "Enter fullscreen"), - * on: { click: () => fs.toggle(videoEl) }, - * }); + * button( + * { on: { click: () => fs.toggle(videoEl) } }, + * () => (fs.isFullscreen() ? "Exit fullscreen" : "Enter fullscreen"), + * ); * ``` */ export function fullscreen(): { diff --git a/src/browser/scroll.ts b/src/browser/scroll.ts index 5dae2a6..e731abc 100644 --- a/src/browser/scroll.ts +++ b/src/browser/scroll.ts @@ -1,3 +1,4 @@ +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { batch } from "../reactivity/batch"; @@ -6,6 +7,9 @@ import { batch } from "../reactivity/batch"; * Returns reactive x/y scroll positions and an isScrolling indicator * that resets after 150ms of inactivity. * + * If a reactive target getter is provided, the listener re-attaches + * whenever the target element changes (same pattern as resize/dragDrop). + * * @param target Optional reactive getter for the scroll target element. * If omitted or returns null, tracks window scroll. * @returns Object with reactive x, y, isScrolling getters and a dispose function @@ -20,6 +24,8 @@ export function scroll(target?: () => HTMLElement | null): { const [y, setY] = signal(0); const [isScrolling, setIsScrolling] = signal(false); let scrollTimer: ReturnType | null = null; + let currentTarget: EventTarget | null = null; + let effectCleanup: (() => void) | null = null; if (typeof window === "undefined") { return { x, y, isScrolling, dispose: () => {} }; @@ -45,12 +51,29 @@ export function scroll(target?: () => HTMLElement | null): { }, 150); }; - const scrollTarget = target ? target() : null; - const eventTarget = scrollTarget || window; - eventTarget.addEventListener("scroll", handler, { passive: true }); + function attachListener(eventTarget: EventTarget): void { + if (currentTarget === eventTarget) return; + if (currentTarget) currentTarget.removeEventListener("scroll", handler); + currentTarget = eventTarget; + currentTarget.addEventListener("scroll", handler, { passive: true }); + } + + if (target) { + // Reactive target — re-attach listener when element changes + effectCleanup = effect(() => { + const el = target(); + attachListener(el || window); + }); + } else { + attachListener(window); + } function dispose() { - eventTarget.removeEventListener("scroll", handler); + effectCleanup?.(); + if (currentTarget) { + currentTarget.removeEventListener("scroll", handler); + currentTarget = null; + } if (scrollTimer !== null) { clearTimeout(scrollTimer); scrollTimer = null; diff --git a/src/browser/speech.ts b/src/browser/speech.ts index 3205240..649fa32 100644 --- a/src/browser/speech.ts +++ b/src/browser/speech.ts @@ -23,10 +23,10 @@ export interface SpeakOptions { * @example * ```ts * const tts = speech(); - * button({ - * nodes: "Read it to me", - * on: { click: () => tts.speak("Hello, world!", { rate: 1.1 }) }, - * }); + * button( + * { on: { click: () => tts.speak("Hello, world!", { rate: 1.1 }) } }, + * "Read it to me", + * ); * ``` */ export function speech(): { diff --git a/src/browser/urlState.ts b/src/browser/urlState.ts index 0a0a60d..d41f7b3 100644 --- a/src/browser/urlState.ts +++ b/src/browser/urlState.ts @@ -8,7 +8,9 @@ import { signal } from "../core/signals/signal"; * to sync a handful of UI state bits with the URL (filters, tabs, modals) * without a full router setup. * - * Listens to `popstate` so browser back/forward updates the signals. + * Listens to both `popstate` (back/forward) and `hashchange` (anchor clicks, + * direct `location.hash` assignments) so the signals stay in sync regardless + * of how the URL was changed. * * @example * ```ts @@ -48,16 +50,27 @@ export function urlState(): { }; } - const [params, setParamsSignal] = signal(new URLSearchParams(window.location.search)); - const [hash, setHashSignal] = signal(window.location.hash); + let lastSearch = window.location.search; + let lastHash = window.location.hash; - const syncFromLocation = () => { - setParamsSignal(new URLSearchParams(window.location.search)); - setHashSignal(window.location.hash); - }; + const [params, setParamsSignal] = signal(new URLSearchParams(lastSearch)); + const [hash, setHashSignal] = signal(lastHash); - const onPopState = () => syncFromLocation(); - window.addEventListener("popstate", onPopState); + function syncFromLocation() { + const currentSearch = window.location.search; + const currentHash = window.location.hash; + if (currentSearch !== lastSearch) { + lastSearch = currentSearch; + setParamsSignal(new URLSearchParams(currentSearch)); + } + if (currentHash !== lastHash) { + lastHash = currentHash; + setHashSignal(currentHash); + } + } + + window.addEventListener("popstate", syncFromLocation); + window.addEventListener("hashchange", syncFromLocation); function setParams(next: URLSearchParams | Record, opts: UrlStateOptions = {}) { const p = next instanceof URLSearchParams ? next : new URLSearchParams(next); @@ -65,19 +78,22 @@ export function urlState(): { const newUrl = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`; if (opts.replace) window.history.replaceState(null, "", newUrl); else window.history.pushState(null, "", newUrl); + lastSearch = window.location.search; setParamsSignal(new URLSearchParams(p)); } function setHash(next: string, opts: UrlStateOptions = {}) { - const normalized = next.startsWith("#") ? next : next ? `#${next}` : ""; + const normalized = next && next !== "#" ? (next.startsWith("#") ? next : `#${next}`) : ""; const newUrl = `${window.location.pathname}${window.location.search}${normalized}`; if (opts.replace) window.history.replaceState(null, "", newUrl); else window.history.pushState(null, "", newUrl); + lastHash = normalized; setHashSignal(normalized); } function dispose() { - window.removeEventListener("popstate", onPopState); + window.removeEventListener("popstate", syncFromLocation); + window.removeEventListener("hashchange", syncFromLocation); } return { params, hash, setParams, setHash, dispose }; diff --git a/src/build/staticAnalysis.ts b/src/build/staticAnalysis.ts index af3361e..b8ff699 100644 --- a/src/build/staticAnalysis.ts +++ b/src/build/staticAnalysis.ts @@ -95,7 +95,7 @@ const VOID_ELEMENTS = new Set([ * converted to template cloning at build time. * * Detects patterns like: - * div({ class: "card", id: "main", nodes: "Hello" }) + * div({ class: "card", id: "main" }, "Hello") * * And identifies them as candidates for: * staticTemplate('
Hello
') diff --git a/src/components/ErrorBoundary.ts b/src/components/ErrorBoundary.ts index 19b492b..89f4171 100644 --- a/src/components/ErrorBoundary.ts +++ b/src/components/ErrorBoundary.ts @@ -29,7 +29,7 @@ export interface ErrorBoundaryProps { * const [route, setRoute] = signal("/"); * ErrorBoundary({ * resetKeys: [route], - * nodes: () => div({ nodes: riskyPageFor(route()) }), + * nodes: () => div(riskyPageFor(route())), * }); * ``` */ diff --git a/src/components/ErrorDisplay.ts b/src/components/ErrorDisplay.ts index d858890..56bc833 100644 --- a/src/components/ErrorDisplay.ts +++ b/src/components/ErrorDisplay.ts @@ -404,13 +404,13 @@ function renderMetadata(meta: Record { + * button( + * { on: { click: async () => { * try { await save(); } * catch (err) { mount(ErrorDisplay({ error: err, onRetry: save }), errorHost); } - * }}, - * nodes: "Save", - * }); + * }}}, + * "Save", + * ); * ``` */ export function ErrorDisplay(props: ErrorDisplayProps): Element { diff --git a/src/core/rendering/action.ts b/src/core/rendering/action.ts index c726972..f680b53 100644 --- a/src/core/rendering/action.ts +++ b/src/core/rendering/action.ts @@ -25,8 +25,7 @@ export type ActionFn = (element: HTMLElement, param: T) => (() => void * action(el, clickOutside, () => setOpen(false)); * action(el, longPress, { duration: 500, callback: onLongPress }); * }, - * nodes: "Content", - * }); + * }, "Content"); * ``` */ export function action(element: HTMLElement, actionFn: ActionFn, param: T): void; diff --git a/src/core/rendering/context.ts b/src/core/rendering/context.ts index 03f294e..e21b67b 100644 --- a/src/core/rendering/context.ts +++ b/src/core/rendering/context.ts @@ -1,30 +1,32 @@ import { signal } from "../signals/signal"; /** - * Context API for SibuJS — provides dependency injection across - * component trees without prop drilling. + * Context API for SibuJS — a reactive global value that any component + * can read without prop drilling. + * + * Note: this is a **global reactive store**, not a subtree-scoped DI + * system. Calling `provide()` sets the value for ALL consumers, not + * just descendants of the provider. For most apps this is sufficient + * — use separate `context()` instances for independent scopes. * * @example * ```ts * // Create a context with a default value * const ThemeContext = context("light"); * - * // Provide a value at a parent level - * function App() { - * ThemeContext.provide("dark"); - * return div({ nodes: [Child()] }); - * } + * // Set the value (global — affects all consumers) + * ThemeContext.provide("dark"); * - * // Consume the value anywhere below + * // Read reactively from any component * function Child() { * const theme = ThemeContext.use(); // reactive getter - * return div({ nodes: () => `Theme: ${theme()}` }); + * return div(() => `Theme: ${theme()}`); * } * ``` */ export interface Context { - /** Provide a value for this context. Overrides any parent provider. */ + /** Set the context value globally. Affects all consumers. */ provide(value: T): void; /** Get a reactive getter for the current context value. */ use(): () => T; diff --git a/src/core/rendering/createId.ts b/src/core/rendering/createId.ts index f020994..5cb7f18 100644 --- a/src/core/rendering/createId.ts +++ b/src/core/rendering/createId.ts @@ -16,10 +16,10 @@ let idCounter = 0; * ```ts * function Field(labelText: string) { * const id = createId("field"); - * return div({ nodes: [ - * label({ for: id, nodes: labelText }), + * return div([ + * label({ for: id }, labelText), * input({ id }), - * ]}); + * ]); * } * ``` */ diff --git a/src/core/rendering/directives.ts b/src/core/rendering/directives.ts index 746c6b0..ba25dd5 100644 --- a/src/core/rendering/directives.ts +++ b/src/core/rendering/directives.ts @@ -13,7 +13,7 @@ import type { NodeChild } from "./types"; * @example * ```ts * const [visible, setVisible] = signal(true); - * div({ nodes: [show(() => visible(), span({ nodes: "I toggle!" }))] }); + * div([show(() => visible(), span("I toggle!"))]); * ``` */ export function show(condition: () => boolean, element: T): T { @@ -37,8 +37,8 @@ export function show(condition: () => boolean, element: T): T * ```ts * when( * () => isLoggedIn(), - * () => div({ nodes: "Welcome!" }), - * () => div({ nodes: "Please log in" }) + * () => div("Welcome!"), + * () => div("Please log in") * ); * ``` */ @@ -105,7 +105,7 @@ export function when(condition: () => T, thenBranch: () => NodeChild, elseBra * error: () => ErrorMessage(), * success: () => Content(), * }, - * () => div({ nodes: "Unknown status" }) + * () => div("Unknown status") * ); * ``` */ diff --git a/src/core/rendering/dispose.ts b/src/core/rendering/dispose.ts index a5dff7f..8977f30 100644 --- a/src/core/rendering/dispose.ts +++ b/src/core/rendering/dispose.ts @@ -49,7 +49,15 @@ export function dispose(node: Node): void { const disposers = elementDisposers.get(current); if (disposers) { if (_isDev) activeBindingCount -= disposers.length; - for (const d of disposers) d(); + for (const d of disposers) { + try { + d(); + } catch (err) { + if (_isDev && typeof console !== "undefined") { + console.warn("[SibuJS] Disposer threw during cleanup:", err); + } + } + } elementDisposers.delete(current); } } diff --git a/src/core/rendering/dynamic.ts b/src/core/rendering/dynamic.ts index 41dba1d..fa7762b 100644 --- a/src/core/rendering/dynamic.ts +++ b/src/core/rendering/dynamic.ts @@ -43,7 +43,7 @@ export function unregisterComponent(name: string): void { * @example * ```ts * registerComponent("Widget", MyWidget); - * div({ nodes: [resolveComponent("Widget")] }); + * div([resolveComponent("Widget")]); * ``` */ export function resolveComponent(name: string): HTMLElement { diff --git a/src/core/rendering/fragment.ts b/src/core/rendering/fragment.ts index 3b11f6d..e7f2869 100644 --- a/src/core/rendering/fragment.ts +++ b/src/core/rendering/fragment.ts @@ -6,14 +6,12 @@ import type { NodeChildren } from "./types"; * * @example * ```ts - * div({ - * nodes: [ - * Fragment([ - * p({ nodes: "First" }), - * p({ nodes: "Second" }), - * ]) - * ] - * }); + * div([ + * Fragment([ + * p("First"), + * p("Second"), + * ]) + * ]); * ``` * * @param nodes Array of child nodes to include in the fragment diff --git a/src/core/rendering/lazy.ts b/src/core/rendering/lazy.ts index 5a1fccb..c0a56b4 100644 --- a/src/core/rendering/lazy.ts +++ b/src/core/rendering/lazy.ts @@ -1,4 +1,3 @@ -import { signal } from "../signals/signal"; import { div, span } from "./html"; type Component = () => HTMLElement; @@ -16,7 +15,7 @@ type LazyImport = () => Promise<{ default: Component }>; * // Use inside Suspense for custom loading UI * Suspense({ * nodes: () => LazyDashboard(), - * fallback: () => div({ nodes: "Loading dashboard..." }), + * fallback: () => div("Loading dashboard..."), * }); * * // Or use standalone — shows default "Loading..." text @@ -35,31 +34,31 @@ export function lazy(importFn: LazyImport): Component { return cached(); } - const [_status, setStatus] = signal<"loading" | "loaded" | "error">("loading"); - const [_error, setError] = signal(null); const container = div({ class: "sibu-lazy" }) as HTMLElement; + let disposed = false; importFn() .then((mod) => { + if (disposed) return; cached = mod.default; const rendered = cached(); container.replaceChildren(rendered); - setStatus("loaded"); }) .catch((err) => { + if (disposed) return; const errorObj = err instanceof Error ? err : new Error(String(err)); - setError(errorObj); - setStatus("error"); - container.replaceChildren( - div({ - class: "sibu-lazy-error", - nodes: `Failed to load component: ${errorObj.message}`, - }), - ); + container.replaceChildren(div("sibu-lazy-error", `Failed to load component: ${errorObj.message}`)); }); // Show loading placeholder initially - container.appendChild(span({ class: "sibu-lazy-loading", nodes: "Loading..." })); + container.appendChild(span("sibu-lazy-loading", "Loading...") as Node); + + // Guard against stale loads if container is disposed before import resolves + const origRemove = container.remove.bind(container); + container.remove = () => { + disposed = true; + origRemove(); + }; return container; }; @@ -72,7 +71,7 @@ export function lazy(importFn: LazyImport): Component { * ```ts * Suspense({ * nodes: () => LazyChart(), - * fallback: () => div({ nodes: "Loading chart..." }), + * fallback: () => div("Loading chart..."), * }); * ``` * diff --git a/src/core/rendering/lifecycle.ts b/src/core/rendering/lifecycle.ts index e847064..8fcf3d6 100644 --- a/src/core/rendering/lifecycle.ts +++ b/src/core/rendering/lifecycle.ts @@ -15,7 +15,7 @@ * console.log("Component was removed"); * }); * - * return div({ nodes: "Hello" }); + * return div("Hello"); * } * ``` */ @@ -25,12 +25,22 @@ import { registerDisposer } from "./dispose"; type CleanupFn = () => void; -/** Safely invoke a lifecycle callback, catching and logging errors in dev mode. */ -function safeCall(cb: () => unknown, hookName: string): void { +/** Safely invoke a lifecycle callback, catching and logging errors in dev mode. + * Returns the callback's return value (used to capture onMount cleanup functions). */ +function safeCall(cb: () => unknown, hookName: string): unknown { try { - cb(); + return cb(); } catch (err) { devWarn(`${hookName}: callback threw: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } +} + +/** Run onMount callback and register returned cleanup function (if any) on the element. */ +function runMountCallback(callback: () => undefined | CleanupFn, hookName: string, element?: HTMLElement): void { + const cleanup = safeCall(callback, hookName); + if (typeof cleanup === "function" && element) { + registerDisposer(element, cleanup as CleanupFn); } } @@ -52,9 +62,7 @@ export function onMount(callback: () => undefined | CleanupFn, element?: HTMLEle if (element) { // If element is already connected, run immediately (deferred) if (element.isConnected) { - queueMicrotask(() => { - safeCall(callback, "onMount"); - }); + queueMicrotask(() => runMountCallback(callback, "onMount", element)); return; } @@ -62,14 +70,18 @@ export function onMount(callback: () => undefined | CleanupFn, element?: HTMLEle const observer = new MutationObserver(() => { if (element.isConnected) { observer.disconnect(); - safeCall(callback, "onMount"); + runMountCallback(callback, "onMount", element); } }); - // Observe the document body for childList changes (subtree) + // Observe the document body for childList changes (subtree). + // Register a disposer so the observer is disconnected if the element + // is disposed before it ever gets connected (prevents leaked observer). + registerDisposer(element, () => observer.disconnect()); + queueMicrotask(() => { if (element.isConnected) { - safeCall(callback, "onMount"); + runMountCallback(callback, "onMount", element); } else { observer.observe(document.body, { childList: true, subtree: true }); } @@ -90,7 +102,12 @@ export function onMount(callback: () => undefined | CleanupFn, element?: HTMLEle * @param element The element to watch for removal */ export function onUnmount(callback: CleanupFn, element: HTMLElement): void { - // Wait until element is in the DOM before observing removal + // Primary path: use registerDisposer so dispose()/when()/match()/each() + // all trigger the callback without needing a MutationObserver. + registerDisposer(element, () => safeCall(callback, "onUnmount")); + + // Fallback: MutationObserver for cases where the element is removed from + // the DOM without going through dispose() (e.g., manual .remove() calls). const startObserving = () => { const observer = new MutationObserver(() => { if (!element.isConnected) { @@ -100,12 +117,13 @@ export function onUnmount(callback: CleanupFn, element: HTMLElement): void { }); observer.observe(document.body, { childList: true, subtree: true }); + // Clean up the observer when the element is disposed + registerDisposer(element, () => observer.disconnect()); }; if (element.isConnected) { startObserving(); } else { - // Wait for it to be mounted first, then observe for removal onMount(() => { startObserving(); return undefined; @@ -125,7 +143,7 @@ export function onUnmount(callback: CleanupFn, element: HTMLElement): void { * ```ts * function RealtimeBar(siteId: string) { * const ws = new WebSocket(`/ws/sites/${siteId}/realtime`); - * const root = div({ nodes: "Realtime data..." }); + * const root = div("Realtime data..."); * onCleanup(() => ws.close(), root); * return root; * } diff --git a/src/core/rendering/portal.ts b/src/core/rendering/portal.ts index 4c36eea..8df62d7 100644 --- a/src/core/rendering/portal.ts +++ b/src/core/rendering/portal.ts @@ -1,7 +1,13 @@ +import { dispose, registerDisposer } from "./dispose"; + /** * Portal renders nodes into a DOM node outside the parent component hierarchy. * Useful for modals, tooltips, dropdowns, and overlays. * + * Cleanup integrates with `dispose()` / `registerDisposer()` so portals + * are properly torn down when the anchor is disposed by `when()`, `match()`, + * `each()`, or manual `dispose(anchor)`. + * * @param nodes Function that returns the content to render * @param target Target DOM element (defaults to document.body) * @returns A Comment anchor node in the original position @@ -9,11 +15,11 @@ * @example * ```ts * // Render modal at document.body - * Portal(() => div({ class: "modal", nodes: "Modal content" })); + * Portal(() => div("modal", "Modal content")); * * // Render into specific container * const overlay = document.getElementById("overlay-root")!; - * Portal(() => div({ nodes: "Tooltip" }), overlay); + * Portal(() => div("Tooltip"), overlay); * ``` */ export function Portal(nodes: () => HTMLElement, target?: HTMLElement): Comment { @@ -26,22 +32,19 @@ export function Portal(nodes: () => HTMLElement, target?: HTMLElement): Comment portalContent = nodes(); container.appendChild(portalContent); } catch (err) { - console.error("[Portal] Render error:", err); + if (typeof console !== "undefined") { + console.error("[Portal] Render error:", err); + } } }); - // Cleanup when anchor is removed from DOM - const observer = new MutationObserver(() => { - if (!anchor.isConnected && portalContent) { + // Primary cleanup: registerDisposer on the anchor so `dispose()`, + // `when()`, `match()`, and `each()` all clean up portal content. + registerDisposer(anchor as unknown as HTMLElement, () => { + if (portalContent) { + dispose(portalContent); portalContent.remove(); portalContent = null; - observer.disconnect(); - } - }); - - queueMicrotask(() => { - if (anchor.parentNode) { - observer.observe(anchor.parentNode, { childList: true }); } }); diff --git a/src/core/signals/deepSignal.ts b/src/core/signals/deepSignal.ts index 219799a..7665eaa 100644 --- a/src/core/signals/deepSignal.ts +++ b/src/core/signals/deepSignal.ts @@ -3,38 +3,101 @@ import { signal } from "./signal"; /** * Deep equality comparison for objects and arrays. * Falls back to Object.is for primitives. - * Handles circular references and common built-in types (Date, RegExp). + * Handles circular references, shared sub-references, and common + * built-in types (Date, RegExp, Map, Set, ArrayBuffer, TypedArrays). + * + * The `seen` parameter tracks `(a, b)` pairs — not just `a` — so that + * a shared sub-object compared against two different partners is always + * fully checked, while genuine cycles (same a-with-same-b revisited) + * still terminate. */ -export function deepEqual(a: unknown, b: unknown, seen?: Set): boolean { +export function deepEqual(a: unknown, b: unknown, seen?: Map>): boolean { if (Object.is(a, b)) return true; if (a == null || b == null) return false; if (typeof a !== typeof b) return false; - if (typeof a !== "object") return false; - // Handle Date - if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); + const objA = a as object; + const objB = b as object; + + // Constructor mismatch → never equal (Date vs {}, Map vs Set, etc.) + if (objA.constructor !== objB.constructor) return false; + + // Date + if (a instanceof Date) return a.getTime() === (b as Date).getTime(); - // Handle RegExp - if (a instanceof RegExp && b instanceof RegExp) return a.toString() === b.toString(); + // RegExp + if (a instanceof RegExp) { + const rb = b as RegExp; + return a.source === rb.source && a.flags === rb.flags; + } + + // Cycle / shared-ref detection — track (a, b) pairs, not just a. + // Placed BEFORE Map/Set so self-referential containers don't infinite-recurse. + if (!seen) seen = new Map(); + let peers = seen.get(objA); + if (peers?.has(objB)) return true; + if (!peers) { + peers = new Set(); + seen.set(objA, peers); + } + peers.add(objB); - // Circular reference detection - if (!seen) seen = new Set(); - if (seen.has(a)) return true; // Circular: treat as equal to avoid infinite recursion - seen.add(a); + // Map + if (a instanceof Map) { + const mb = b as Map; + if (a.size !== mb.size) return false; + for (const [k, v] of a) { + if (!mb.has(k)) return false; + if (!deepEqual(v, mb.get(k), seen)) return false; + } + return true; + } + + // Set (shallow membership — deep Set equality is O(n²) and rarely wanted) + if (a instanceof Set) { + const sb = b as Set; + if (a.size !== sb.size) return false; + for (const item of a) { + if (!sb.has(item)) return false; + } + return true; + } + + // ArrayBuffer / TypedArray + if (a instanceof ArrayBuffer) { + const viewA = new Uint8Array(a); + const viewB = new Uint8Array(b as ArrayBuffer); + if (viewA.length !== viewB.length) return false; + for (let i = 0; i < viewA.length; i++) { + if (viewA[i] !== viewB[i]) return false; + } + return true; + } + if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { + const ta = a as unknown as { length: number; [i: number]: number }; + const tb = b as unknown as { length: number; [i: number]: number }; + if (ta.length !== tb.length) return false; + for (let i = 0; i < ta.length; i++) { + if (ta[i] !== tb[i]) return false; + } + return true; + } + // Array if (Array.isArray(a)) { if (!Array.isArray(b)) return false; - if (a.length !== b.length) return false; - return a.every((val, i) => deepEqual(val, b[i], seen)); + if (a.length !== (b as unknown[]).length) return false; + return a.every((val, i) => deepEqual(val, (b as unknown[])[i], seen)); } - const keysA = Object.keys(a as Record); - const keysB = Object.keys(b as Record); + // Plain object + const keysA = Object.keys(objA as Record); + const keysB = Object.keys(objB as Record); if (keysA.length !== keysB.length) return false; return keysA.every((key) => - deepEqual((a as Record)[key], (b as Record)[key], seen), + deepEqual((objA as Record)[key], (objB as Record)[key], seen), ); } @@ -44,7 +107,8 @@ export function deepEqual(a: unknown, b: unknown, seen?: Set): boolean * to a structurally identical value. * * @param initial Initial value - * @returns Tuple [getter, setter] + * @returns Tuple [getter, setter] — same shape as `signal()`, preserving + * the `Accessor` brand on the getter. * * @example * ```ts @@ -53,6 +117,6 @@ export function deepEqual(a: unknown, b: unknown, seen?: Set): boolean * setUser({ name: "Bob", age: 25 }); // Notifies — different value * ``` */ -export function deepSignal(initial: T): [() => T, (next: T | ((prev: T) => T)) => void] { +export function deepSignal(initial: T) { return signal(initial, { equals: (a, b) => deepEqual(a, b) }); } diff --git a/src/core/signals/derived.ts b/src/core/signals/derived.ts index 7199a56..49bc653 100644 --- a/src/core/signals/derived.ts +++ b/src/core/signals/derived.ts @@ -36,13 +36,25 @@ export function derived(getter: () => T, options?: { name?: string }): Access // DevTools: emit computed:create const hook = (globalThis as any).__SIBU_DEVTOOLS_GLOBAL_HOOK__; + let evaluating = false; + function computedGetter(): T { + if (evaluating) { + throw new Error( + `[SibuJS] Circular dependency detected in derived${debugName ? ` "${debugName}"` : ""}. ` + + "A derived signal cannot read itself (directly or through a chain).", + ); + } + if (trackingSuspended) { - // Called during another derived's re-evaluation (propagateDirty eager path). - // Re-evaluate if dirty but don't re-track (we're inside suspended context). if (cs._d) { - cs._d = false; - cs._v = getter(); + evaluating = true; + try { + cs._d = false; + cs._v = getter(); + } finally { + evaluating = false; + } } return cs._v; } @@ -53,14 +65,15 @@ export function derived(getter: () => T, options?: { name?: string }): Access if (cs._d) { const oldValue = cs._v; - // Re-evaluate AND re-track dependencies. - // This is the key fix: track() cleans old deps and registers new ones, - // so derived-of-derived chains (e.g. F6=SUM(F2:F4) where F2 is also - // a formula) always have up-to-date dependency links. - track(() => { - cs._d = false; - cs._v = getter(); - }, markDirty); + evaluating = true; + try { + track(() => { + cs._d = false; + cs._v = getter(); + }, markDirty); + } finally { + evaluating = false; + } // DevTools: emit computed recomputation if (hook && oldValue !== cs._v) { diff --git a/src/core/signals/signal.ts b/src/core/signals/signal.ts index b34d439..f90dab7 100644 --- a/src/core/signals/signal.ts +++ b/src/core/signals/signal.ts @@ -13,9 +13,9 @@ declare const __accessor: unique symbol; * ```ts * const [count, setCount] = signal(0); * - * div({ nodes: count }) // ✓ reactive — Accessor passed directly - * div({ nodes: () => count() }) // ✓ reactive — explicit arrow wrapper - * div({ nodes: count() }) // ✗ static — evaluated once, not reactive + * div(count) // ✓ reactive — Accessor passed directly + * div(() => count()) // ✓ reactive — explicit arrow wrapper + * div(count()) // ✗ static — evaluated once, not reactive * ``` */ export type Accessor = (() => T) & { readonly [__accessor]?: never }; diff --git a/src/data/mutation.ts b/src/data/mutation.ts index b7bfc2d..41253f6 100644 --- a/src/data/mutation.ts +++ b/src/data/mutation.ts @@ -53,7 +53,10 @@ export function mutation( const isSuccess = derived(() => status() === "success"); const isIdle = derived(() => status() === "idle"); + let runId = 0; + async function execute(variables: TVariables): Promise { + const myRun = ++runId; let context: TContext | undefined; batch(() => { @@ -69,6 +72,9 @@ export function mutation( const result = await withRetry(() => mutationFn(variables), options.retry); + // Ignore stale responses — a newer mutate() call is in flight + if (myRun !== runId) return result; + batch(() => { setData(result); setLoading(false); @@ -82,6 +88,9 @@ export function mutation( } catch (err) { const errorObj = err instanceof Error ? err : new Error(String(err)); + // Ignore stale errors — a newer mutate() call is in flight + if (myRun !== runId) throw errorObj; + batch(() => { setError(errorObj); setLoading(false); @@ -96,6 +105,7 @@ export function mutation( } function reset(): void { + runId++; batch(() => { setData(undefined); setError(undefined); diff --git a/src/data/query.ts b/src/data/query.ts index acf1fd1..24f98ce 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -227,9 +227,10 @@ export function query( } } + const keyChanged = currentKey !== key; currentKey = key; const entry = getOrCreateEntry(key, initialData); - entry.subscribers++; + if (keyChanged || entry.subscribers === 0) entry.subscribers++; if (entry.gcTimer !== null) { clearTimeout(entry.gcTimer); entry.gcTimer = null; diff --git a/src/data/resource.ts b/src/data/resource.ts index a7531e9..4b629ca 100644 --- a/src/data/resource.ts +++ b/src/data/resource.ts @@ -123,7 +123,10 @@ export function resource( options.onSuccess?.(result); } catch (err) { if (version !== fetchVersion || disposed) return; - if (err instanceof DOMException && err.name === "AbortError") return; + if (err instanceof DOMException && err.name === "AbortError") { + if (version === fetchVersion) setLoading(false); + return; + } const errorObj = err instanceof Error ? err : new Error(String(err)); batch(() => { diff --git a/src/data/retry.ts b/src/data/retry.ts index da93519..b7ae3d8 100644 --- a/src/data/retry.ts +++ b/src/data/retry.ts @@ -90,9 +90,13 @@ export async function withRetry( const delay = calculateDelay(attempt, strategy, baseDelay, maxDelay, jitter); onRetry?.(error, attempt, delay); await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, delay); + let onAbort: (() => void) | null = null; + const timer = setTimeout(() => { + if (onAbort && signal) signal.removeEventListener("abort", onAbort); + resolve(); + }, delay); if (signal) { - const onAbort = () => { + onAbort = () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }; diff --git a/src/devtools/signalGraph.ts b/src/devtools/signalGraph.ts index d1bce5f..e69183b 100644 --- a/src/devtools/signalGraph.ts +++ b/src/devtools/signalGraph.ts @@ -191,57 +191,56 @@ export function createTraceProfiler(): TraceProfilerHandle { const start = typeof performance !== "undefined" ? performance.now() : Date.now(); let recording = true; - const onEffectStart = (payload: unknown) => { + function now(): number { + return (typeof performance !== "undefined" ? performance.now() : Date.now()) - start; + } + + const onEffectCreate = (payload: unknown) => { if (!recording) return; - const now = (typeof performance !== "undefined" ? performance.now() : Date.now()) - start; - const label = (payload as { name?: string }).name ?? "effect"; events.push({ - name: label, + name: (payload as { name?: string }).name ?? "effect", cat: "effect", - ph: "B", - ts: Math.floor(now * 1000), + ph: "I", + ts: Math.floor(now() * 1000), tid: 0, pid: 0, }); }; - const onEffectEnd = (payload: unknown) => { + const onEffectDestroy = (payload: unknown) => { if (!recording) return; - const now = (typeof performance !== "undefined" ? performance.now() : Date.now()) - start; - const label = (payload as { name?: string }).name ?? "effect"; events.push({ - name: label, + name: (payload as { name?: string }).name ?? "effect:destroy", cat: "effect", - ph: "E", - ts: Math.floor(now * 1000), + ph: "I", + ts: Math.floor(now() * 1000), tid: 0, pid: 0, }); }; - const onSignalSet = (payload: unknown) => { + const onSignalUpdate = (payload: unknown) => { if (!recording) return; - const now = (typeof performance !== "undefined" ? performance.now() : Date.now()) - start; - const label = (payload as { name?: string }).name ?? "signal"; + const p = payload as { name?: string; oldValue?: unknown; newValue?: unknown }; events.push({ - name: label, + name: p.name ?? "signal", cat: "signal", ph: "I", - ts: Math.floor(now * 1000), + ts: Math.floor(now() * 1000), tid: 0, pid: 0, - args: (payload as { args?: Record }).args, + args: p.oldValue !== undefined ? { oldValue: String(p.oldValue), newValue: String(p.newValue) } : undefined, }); }; - const offStart = hook.on("effect:start", onEffectStart); - const offEnd = hook.on("effect:end", onEffectEnd); - const offSet = hook.on("signal:set", onSignalSet); + const offCreate = hook.on("effect:create", onEffectCreate); + const offDestroy = hook.on("effect:destroy", onEffectDestroy); + const offUpdate = hook.on("signal:update", onSignalUpdate); function stop(): ProfilerEvent[] { if (!recording) return events; recording = false; - offStart(); - offEnd(); - offSet(); + offCreate(); + offDestroy(); + offUpdate(); return events; } diff --git a/src/ecosystem/adapters/mobx.ts b/src/ecosystem/adapters/mobx.ts index f2a6737..95a4a1d 100644 --- a/src/ecosystem/adapters/mobx.ts +++ b/src/ecosystem/adapters/mobx.ts @@ -55,7 +55,7 @@ export interface MobXAdapterAPI { * * const mobx = inject("mobx"); * const todoCount = mobx.fromMobX(() => todoStore.todos.length); - * div({ nodes: () => `Todos: ${todoCount()}` }); + * div(() => `Todos: ${todoCount()}`); * ``` */ export function mobXAdapter(options: MobXAdapterOptions): SibuPlugin { diff --git a/src/ecosystem/adapters/redux.ts b/src/ecosystem/adapters/redux.ts index 8efe04f..5cf67bd 100644 --- a/src/ecosystem/adapters/redux.ts +++ b/src/ecosystem/adapters/redux.ts @@ -43,7 +43,7 @@ export interface ReduxAdapterAPI { * * const redux = inject("redux"); * const count = redux.useSelector(s => s.counter); - * div({ nodes: () => `Count: ${count()}` }); + * div(() => `Count: ${count()}`); * ``` */ export function reduxAdapter(options: ReduxAdapterOptions): SibuPlugin { diff --git a/src/ecosystem/adapters/zustand.ts b/src/ecosystem/adapters/zustand.ts index 4b8111a..907e15a 100644 --- a/src/ecosystem/adapters/zustand.ts +++ b/src/ecosystem/adapters/zustand.ts @@ -44,7 +44,7 @@ export interface ZustandAdapterAPI { * * const zs = inject("zustand"); * const bears = zs.useSelector(s => s.bears); - * div({ nodes: () => `Bears: ${bears()}` }); + * div(() => `Bears: ${bears()}`); * ``` */ export function zustandAdapter(options: ZustandAdapterOptions): SibuPlugin { diff --git a/src/ecosystem/ui/componentAdapter.ts b/src/ecosystem/ui/componentAdapter.ts index b505422..596532c 100644 --- a/src/ecosystem/ui/componentAdapter.ts +++ b/src/ecosystem/ui/componentAdapter.ts @@ -103,7 +103,7 @@ export type AdaptedComponent = (props?: AdaptedComponentProps) => Element; * }); * * const { Button } = adapter.components; - * Button({ variant: "raised", nodes: "Click me" }); + * Button({ variant: "raised" }, "Click me"); * ``` */ export function componentAdapter(config: AdapterConfig): { diff --git a/src/patterns/componentProps.ts b/src/patterns/componentProps.ts index dd11500..7d47f2e 100644 --- a/src/patterns/componentProps.ts +++ b/src/patterns/componentProps.ts @@ -45,11 +45,10 @@ type WithNodes = Props & { nodes?: Node | Node[] }; * const Button = defineComponent<{ label: string; variant?: 'primary' | 'secondary'; disabled?: boolean }>({ * defaults: { variant: 'primary', disabled: false }, * setup(props) { - * return button({ - * class: `btn btn-${props.variant}`, - * disabled: props.disabled, - * nodes: props.label - * }); + * return button( + * { class: `btn btn-${props.variant}`, disabled: props.disabled }, + * props.label, + * ); * } * }); * @@ -88,7 +87,7 @@ export function defineComponent>(config: { * const Card = defineSlottedComponent<{ title: string }>({ * setup(props) { * const el = div({ class: 'card' }); - * el.appendChild(h2({ nodes: props.title })); + * el.appendChild(h2(props.title)); * if (props.nodes) { * const nodes = Array.isArray(props.nodes) ? props.nodes : [props.nodes]; * nodes.forEach(child => el.appendChild(child)); @@ -97,7 +96,7 @@ export function defineComponent>(config: { * } * }); * - * // Usage: Card({ title: 'Hello', nodes: p({ nodes: 'World' }) }) + * // Usage: Card({ title: 'Hello', nodes: p('World') }) * ``` */ export function defineSlottedComponent>(config: { diff --git a/src/patterns/globalStore.ts b/src/patterns/globalStore.ts index c9f312f..f879300 100644 --- a/src/patterns/globalStore.ts +++ b/src/patterns/globalStore.ts @@ -28,7 +28,7 @@ export function globalStore< S extends Record, A extends Record Partial>, >(config: { state: S; actions: A; middleware?: Middleware[] }): GlobalStore { - const initialState = { ...config.state }; + const initialState = JSON.parse(JSON.stringify(config.state)) as S; const [getState, setState] = signal({ ...initialState }); const listeners: Set<(state: S) => void> = new Set(); const middlewares = config.middleware || []; diff --git a/src/patterns/hoc.ts b/src/patterns/hoc.ts index 5f31104..c244ddd 100644 --- a/src/patterns/hoc.ts +++ b/src/patterns/hoc.ts @@ -37,7 +37,7 @@ export function withWrapper

( * @example * ```ts * const Button = withDefaults(RawButton, { type: "button", disabled: false }); - * Button({ nodes: "Click" }); // type="button", disabled=false automatically + * Button("Click"); // type="button", disabled=false automatically * ``` */ export function withDefaults

>( diff --git a/src/patterns/optimistic.ts b/src/patterns/optimistic.ts index e7f278e..55b7d65 100644 --- a/src/patterns/optimistic.ts +++ b/src/patterns/optimistic.ts @@ -4,42 +4,59 @@ import { signal } from "../core/signals/signal"; // OPTIMISTIC UPDATES // ============================================================================ -export interface OptimisticAction { - optimisticValue: T; - revert: T; -} - /** * optimistic provides optimistic UI updates that can be reverted on failure. * The value updates immediately, then reverts if the async operation fails. * - * Note: concurrent optimistic updates on the same value are inherently - * ambiguous. If multiple operations are in flight, a failed revert restores - * the value captured before that specific operation started. + * Returns a named object with `value`, `pending`, and `update`. + * + * Concurrent safety: each operation is tagged with a version counter. + * A failing operation only reverts if no newer operation has started since, + * preventing stale snapshots from overwriting fresher optimistic state. + * + * @example + * ```ts + * const likes = optimistic(42); + * + * button( + * { on: { click: () => likes.update(likes.value() + 1, () => api.like()) } }, + * () => `${likes.value()} ${likes.pending() ? "(saving…)" : ""}`, + * ); + * ``` */ -export function optimistic( - initialValue: T, -): [() => T, (optimisticValue: T, asyncAction: () => Promise) => Promise] { +export function optimistic(initialValue: T): { + value: () => T; + pending: () => boolean; + update: (optimisticValue: T, asyncAction: () => Promise) => Promise; +} { const [value, setValue] = signal(initialValue); - const [_pending, setPending] = signal(false); + const [pending, setPending] = signal(false); + let inflightCount = 0; + let version = 0; - async function addOptimistic(optimisticValue: T, asyncAction: () => Promise): Promise { + async function update(optimisticValue: T, asyncAction: () => Promise): Promise { + const myVersion = ++version; const previousValue = value(); setValue(optimisticValue); + inflightCount++; setPending(true); try { const result = await asyncAction(); - setValue(result); + if (version === myVersion) { + setValue(result); + } } catch { - // Revert on failure - setValue(previousValue); + if (version === myVersion) { + setValue(previousValue); + } } finally { - setPending(false); + inflightCount--; + if (inflightCount === 0) setPending(false); } } - return [value, addOptimistic]; + return { value, pending, update }; } /** @@ -47,69 +64,131 @@ export function optimistic( * * Uses functional updates (setItems(current => ...)) for the success path * to avoid losing changes made by concurrent operations. The failure path - * reverts to a snapshot taken before the operation started. + * uses a version guard so stale reverts don't overwrite newer state. + * + * @example + * ```ts + * const todos = optimisticList([]); + * + * todos.add( + * { id: tempId(), text: "New" }, + * async () => api.createTodo("New"), + * ); + * + * div([ + * () => todos.pending() ? span("Saving…") : null, + * each(() => todos.items(), (t) => div(t().text), { key: (t) => t.id }), + * ]); + * ``` */ export function optimisticList(initialValue: T[]): { items: () => T[]; + pending: () => boolean; + add: (item: T, asyncAction: () => Promise) => Promise; + remove: (predicate: (item: T) => boolean, asyncAction: () => Promise) => Promise; + update: (predicate: (item: T) => boolean, patch: Partial, asyncAction: () => Promise) => Promise; + /** @deprecated Use `add` instead */ addOptimistic: (item: T, asyncAction: () => Promise) => Promise; + /** @deprecated Use `remove` instead */ removeOptimistic: (predicate: (item: T) => boolean, asyncAction: () => Promise) => Promise; + /** @deprecated Use `update` instead */ updateOptimistic: ( predicate: (item: T) => boolean, - update: Partial, + patch: Partial, asyncAction: () => Promise, ) => Promise; } { const [items, setItems] = signal([...initialValue]); + const [pending, setPending] = signal(false); + let inflightCount = 0; + let version = 0; + + function begin(): number { + const v = ++version; + inflightCount++; + setPending(true); + return v; + } - async function addOptimistic(item: T, asyncAction: () => Promise): Promise { + function end(myVersion: number, revertFn?: () => void) { + if (revertFn && version === myVersion) { + revertFn(); + } + inflightCount--; + if (inflightCount === 0) setPending(false); + } + + async function add(item: T, asyncAction: () => Promise): Promise { const prev = items(); setItems([...prev, item]); + const myVersion = begin(); try { const result = await asyncAction(); - // Use functional update for success to preserve concurrent additions setItems((current) => { - // Find and replace the optimistic item at the position it was added const idx = current.lastIndexOf(item); if (idx >= 0) { const next = [...current]; next[idx] = result; return next; } - return [...current.filter((i) => i !== item), result]; + return [...current, result]; }); + end(myVersion); } catch { - setItems(prev); + end(myVersion, () => setItems(prev)); } } - async function removeOptimistic(predicate: (item: T) => boolean, asyncAction: () => Promise): Promise { + async function remove(predicate: (item: T) => boolean, asyncAction: () => Promise): Promise { const prev = items(); setItems(prev.filter((item) => !predicate(item))); + const myVersion = begin(); try { await asyncAction(); + end(myVersion); } catch { - setItems(prev); + end(myVersion, () => setItems(prev)); } } - async function updateOptimistic( + async function updateItem( predicate: (item: T) => boolean, - update: Partial, + patch: Partial, asyncAction: () => Promise, ): Promise { const prev = items(); - setItems(prev.map((item) => (predicate(item) ? { ...item, ...update } : item))); + const patchedRefs: T[] = []; + setItems( + prev.map((item) => { + if (predicate(item)) { + const patched = { ...item, ...patch } as T; + patchedRefs.push(patched); + return patched; + } + return item; + }), + ); + const myVersion = begin(); try { const result = await asyncAction(); - // Use functional update for success to preserve concurrent changes - setItems((current) => current.map((item) => (predicate(item) ? result : item))); + setItems((current) => current.map((item) => (patchedRefs.includes(item) ? result : item))); + end(myVersion); } catch { - setItems(prev); + end(myVersion, () => setItems(prev)); } } - return { items, addOptimistic, removeOptimistic, updateOptimistic }; + return { + items, + pending, + add, + remove, + update: updateItem, + addOptimistic: add, + removeOptimistic: remove, + updateOptimistic: updateItem, + }; } diff --git a/src/patterns/persist.ts b/src/patterns/persist.ts index e261ad6..6b55037 100644 --- a/src/patterns/persist.ts +++ b/src/patterns/persist.ts @@ -93,7 +93,7 @@ export function persisted( let applyingFromStorage = false; // Persist on every change - effect(() => { + const stopPersistEffect = effect(() => { const current = value(); if (applyingFromStorage) return; try { @@ -146,6 +146,7 @@ export function persisted( // so. Exposed as a non-enumerable property on the setter to keep the // tuple's public shape identical to `signal()`'s return type. const dispose = () => { + stopPersistEffect(); if (storageListener && typeof window !== "undefined") { window.removeEventListener("storage", storageListener); storageListener = null; diff --git a/src/performance/concurrent.ts b/src/performance/concurrent.ts index 1720c5e..b86fe1d 100644 --- a/src/performance/concurrent.ts +++ b/src/performance/concurrent.ts @@ -2,6 +2,7 @@ // CONCURRENT RENDERING UTILITIES // ============================================================================ +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { Priority, scheduleUpdate } from "./scheduler"; @@ -19,27 +20,22 @@ export function startTransition(callback: () => void): void { * The deferred value mirrors the source but updates at LOW priority, * allowing the UI to remain responsive while expensive derived state * catches up. + * + * Uses an effect to subscribe to the source getter. When the source + * changes, a LOW-priority update is scheduled. The deferred signal + * only updates when the scheduler flushes, so fast bursts of source + * changes collapse into a single deferred update. */ export function deferredValue(getter: () => T): () => T { const [deferred, setDeferred] = signal(getter()); + let latest: T = deferred(); - // Schedule a LOW-priority update that syncs the deferred value - // with the latest source value whenever it is called. - const sync = (): void => { - const current = getter(); - setDeferred(current); - }; - - // Kick off the initial deferred sync - scheduleUpdate(Priority.LOW, sync); + effect(() => { + latest = getter(); + scheduleUpdate(Priority.LOW, () => setDeferred(latest)); + }); - // Return a getter that, each time it's read, also enqueues a - // LOW-priority re-sync so the deferred value eventually converges - // with the source. - return (): T => { - scheduleUpdate(Priority.LOW, sync); - return deferred(); - }; + return deferred; } /** @@ -105,7 +101,7 @@ export function setIdPrefix(prefix: string): void { * @example * ```ts * const id = id(); - * label({ htmlFor: id, nodes: "Name" }); + * label({ htmlFor: id }, "Name"); * input({ id, type: "text" }); * ``` */ diff --git a/src/platform/microfrontend.ts b/src/platform/microfrontend.ts index 201ac73..7fc73fc 100644 --- a/src/platform/microfrontend.ts +++ b/src/platform/microfrontend.ts @@ -58,7 +58,7 @@ const moduleCache = new Map>(); * const app = createMicroApp({ name: "widget", shadow: true }); * document.body.appendChild(app.element); * - * app.mount(() => div({ nodes: "Hello from micro-app!" })); + * app.mount(() => div("Hello from micro-app!")); * * // Later, tear it down: * app.unmount(); diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 8bbcf55..9fdba57 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -43,14 +43,12 @@ export function t(key: string, params?: Params): string { * registerTranslations("en", { greeting: "Hello, {name}!" }); * registerTranslations("es", { greeting: "Hola, {name}!" }); * - * div({ nodes: [Trans("greeting", { name: "World" })] }); + * div([Trans("greeting", { name: "World" })]); * // When locale changes, the text updates automatically * ``` */ export function Trans(key: string, params?: Params): HTMLElement { - return span({ - nodes: () => t(key, params), - }) as HTMLElement; + return span(() => t(key, params)) as HTMLElement; } /** diff --git a/src/plugins/router.ts b/src/plugins/router.ts index 2decb09..657e610 100644 --- a/src/plugins/router.ts +++ b/src/plugins/router.ts @@ -294,9 +294,12 @@ class RouteMatcher { private matchPattern(path: string, routePath: string): { params: Params } | null { // Handle wildcard routes - if (routePath === "*" || routePath.endsWith("/*")) { + if (routePath === "*") { + return { params: { pathMatch: path } }; + } + if (routePath.endsWith("/*")) { const basePath = routePath.slice(0, -2); - if (path.startsWith(basePath)) { + if (path === basePath || path.startsWith(`${basePath}/`)) { return { params: { pathMatch: path.slice(basePath.length) } }; } return null; @@ -452,6 +455,8 @@ class GuardManager { const next: NavigationNext = (result) => { if (resolved || signal.aborted) return; resolved = true; + clearTimeout(timeoutId); + signal.removeEventListener("abort", abortHandler); if (result instanceof Error) { reject(result); @@ -468,10 +473,10 @@ class GuardManager { const abortHandler = () => { if (!resolved) { resolved = true; + clearTimeout(timeoutId); reject(new Error("Navigation aborted")); } }; - signal.addEventListener("abort", abortHandler); // Set up timeout const timeoutId = setTimeout(() => { @@ -482,6 +487,8 @@ class GuardManager { } }, this.timeout); + signal.addEventListener("abort", abortHandler); + try { guard(to, from, next); } catch (error) { @@ -493,13 +500,9 @@ class GuardManager { } } - // Cleanup when resolved - Promise.resolve().then(() => { - if (resolved) { - clearTimeout(timeoutId); - signal.removeEventListener("abort", abortHandler); - } - }); + // Note: cleanup (clearTimeout + removeEventListener) happens in the + // next() callback, the catch block, the timeout handler, or the abort + // handler — whichever resolves the guard first. }); } @@ -1482,11 +1485,11 @@ export function Route(): Node { * ```ts * // Cache all routes (max 10) * createRouter(routes, { keepAlive: 10 }); - * mount(() => div({ nodes: [nav, KeepAliveRoute()] }), root); + * mount(() => div([nav, KeepAliveRoute()]), root); * * // Or cache specific routes by name * createRouter(routes, { keepAlive: ["dashboard", "settings"] }); - * mount(() => div({ nodes: [nav, KeepAliveRoute()] }), root); + * mount(() => div([nav, KeepAliveRoute()]), root); * * // Or override per-outlet * KeepAliveRoute({ max: 5, include: ["dashboard"] }) diff --git a/src/reactivity/concurrent.ts b/src/reactivity/concurrent.ts index 0bce97d..c2ee969 100644 --- a/src/reactivity/concurrent.ts +++ b/src/reactivity/concurrent.ts @@ -43,7 +43,7 @@ import { track } from "./track"; * input({ on: { input: e => setQuery(e.target.value) } }); * * // heavy list reads deferredQuery() and updates one frame later - * each(() => heavyFilter(items, deferredQuery()), row => li({ nodes: row.name })); + * each(() => heavyFilter(items, deferredQuery()), row => li(row.name)); * ``` */ export function defer(getter: () => T): () => T { diff --git a/src/reactivity/track.ts b/src/reactivity/track.ts index ab1dbfc..64acea3 100644 --- a/src/reactivity/track.ts +++ b/src/reactivity/track.ts @@ -182,12 +182,23 @@ export function queueSignalNotification(signal: ReactiveSignal): void { /** * Process all pending subscriber notifications. */ +const MAX_DRAIN_ITERATIONS = 1000; + export function drainNotificationQueue(): void { if (notifyDepth > 0) return; notifyDepth++; try { let i = 0; while (i < pendingQueue.length) { + if (i >= MAX_DRAIN_ITERATIONS) { + if (typeof console !== "undefined") { + console.error( + `[SibuJS] Notification queue exceeded ${MAX_DRAIN_ITERATIONS} iterations — ` + + "likely an effect that writes to a signal it reads. Breaking to prevent infinite loop.", + ); + } + break; + } safeInvoke(pendingQueue[i]); i++; } diff --git a/src/ui/a11y.ts b/src/ui/a11y.ts index b84c712..ac1c455 100644 --- a/src/ui/a11y.ts +++ b/src/ui/a11y.ts @@ -1,3 +1,4 @@ +import { registerDisposer } from "../core/rendering/dispose"; import { signal } from "../core/signals/signal"; import { track } from "../reactivity/track"; @@ -72,7 +73,10 @@ export function FocusTrap( const focusable = container.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); - if (focusable.length === 0) return; + if (focusable.length === 0) { + e.preventDefault(); + return; + } const first = focusable[0]; const last = focusable[focusable.length - 1]; @@ -99,27 +103,36 @@ export function FocusTrap( }); } - // Restore focus on removal + // Restore focus on removal — observe document.body with subtree so ancestor + // removal (not just direct parent removal) is detected. + let trapObserver: MutationObserver | null = null; + + function restoreFocusAndCleanup(): void { + if (options.restoreFocus !== false) previouslyFocused?.focus(); + if (trapObserver) { + trapObserver.disconnect(); + trapObserver = null; + } + } + if (options.restoreFocus !== false) { - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const removed of Array.from(mutation.removedNodes)) { - if (removed === container || removed.contains(container)) { - previouslyFocused?.focus(); - observer.disconnect(); - return; - } - } + trapObserver = new MutationObserver(() => { + if (!container.isConnected) { + restoreFocusAndCleanup(); } }); queueMicrotask(() => { - if (container.parentNode) { - observer.observe(container.parentNode, { childList: true }); + if (container.isConnected) { + trapObserver!.observe(document.body, { childList: true, subtree: true }); } }); } + // Integrate with dispose() so SPA navigations and when()/match()/each() + // clean up the observer even without a DOM removal event. + registerDisposer(container, restoreFocusAndCleanup); + return container; } @@ -139,7 +152,6 @@ export function hotkey( shift?: boolean; alt?: boolean; meta?: boolean; - global?: boolean; preventDefault?: boolean; } = {}, ): () => void { diff --git a/src/ui/a11yPrimitives.ts b/src/ui/a11yPrimitives.ts index fd313d8..8863f1e 100644 --- a/src/ui/a11yPrimitives.ts +++ b/src/ui/a11yPrimitives.ts @@ -136,10 +136,10 @@ export interface ListboxHandle { * * @example * ```ts - * const container = ul({ nodes: [ - * li({ role: "option", "data-value": "a", nodes: "Apple" }), - * li({ role: "option", "data-value": "b", nodes: "Banana" }), - * ]}) as HTMLElement; + * const container = ul([ + * li({ role: "option", "data-value": "a" }, "Apple"), + * li({ role: "option", "data-value": "b" }, "Banana"), + * ]) as HTMLElement; * * const lb = createListbox(container, { onSelect: v => console.log(v) }); * ``` @@ -315,8 +315,8 @@ export interface DialogAriaHandle { * const dlg = document.createElement("div"); * const aria = createDialogAria(dlg, { alert: false }); * dlg.append( - * h2({ id: aria.titleId, nodes: "Delete?" }), - * p({ id: aria.descriptionId, nodes: "This cannot be undone." }), + * h2({ id: aria.titleId }, "Delete?"), + * p({ id: aria.descriptionId }, "This cannot be undone."), * ); * ``` */ diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index 35474b8..0090a81 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -2,12 +2,16 @@ import { signal } from "../core/signals/signal"; /** * dialog provides reactive dialog state management with escape-to-close support. + * + * Call `dispose()` when the owning component unmounts to ensure the global + * keydown listener is removed even if the dialog is still open. */ export function dialog(): { open: () => void; close: () => void; isOpen: () => boolean; toggle: () => void; + dispose: () => void; } { const [isOpen, setIsOpen] = signal(false); let listenerAttached = false; @@ -18,29 +22,39 @@ export function dialog(): { } } - function open(): void { - setIsOpen(true); + function attachListener(): void { if (typeof window !== "undefined" && !listenerAttached) { window.addEventListener("keydown", handleKeydown); listenerAttached = true; } } - function close(): void { - setIsOpen(false); + function detachListener(): void { if (typeof window !== "undefined" && listenerAttached) { window.removeEventListener("keydown", handleKeydown); listenerAttached = false; } } + function open(): void { + setIsOpen(true); + attachListener(); + } + + function close(): void { + setIsOpen(false); + detachListener(); + } + function toggle(): void { - if (isOpen()) { - close(); - } else { - open(); - } + if (isOpen()) close(); + else open(); + } + + function dispose(): void { + detachListener(); + setIsOpen(false); } - return { open, close, isOpen, toggle }; + return { open, close, isOpen, toggle, dispose }; } diff --git a/src/ui/form.ts b/src/ui/form.ts index c35ced9..7ae0777 100644 --- a/src/ui/form.ts +++ b/src/ui/form.ts @@ -30,6 +30,8 @@ export interface FormReturn> { errors: () => Partial>; isValid: () => boolean; isDirty: () => boolean; + /** True while an async handleSubmit callback is in flight. Prevents double-submit. */ + submitting: () => boolean; touched: () => Partial>; values: () => T; handleSubmit: (onSubmit: (values: T) => void | Promise) => (e?: Event) => void; @@ -161,11 +163,12 @@ export function bindField(field: FormField, extras?: Record field.touch(), }; - // Merge extras.on with field handlers so extras can't accidentally clobber them + // Merge extras.on with field handlers — field handlers always win so extras + // can't accidentally clobber the value/change/blur wiring. const { on: extraOn, value: _ignoreValue, ...restExtras } = (extras ?? {}) as Record; const mergedOn = extraOn && typeof extraOn === "object" - ? { ...fieldOn, ...(extraOn as Record void>) } + ? { ...(extraOn as Record void>), ...fieldOn } : fieldOn; return { @@ -252,15 +255,24 @@ export function form>(config: FormConfig): return result; }); + const [submitting, setSubmitting] = signal(false); + function handleSubmit(onSubmit: (values: T) => void | Promise) { return (e?: Event) => { if (e) e.preventDefault(); - // Touch all fields + if (submitting()) return; for (const field of Object.values(fieldMap) as FormField[]) { field.touch(); } if (isValid()) { - onSubmit(values()); + const result = onSubmit(values()); + if (result && typeof (result as Promise).then === "function") { + setSubmitting(true); + (result as Promise).then( + () => setSubmitting(false), + () => setSubmitting(false), + ); + } } }; } @@ -280,6 +292,7 @@ export function form>(config: FormConfig): errors, isValid, isDirty, + submitting, touched: touchedState, values, handleSubmit, diff --git a/src/ui/formAction.ts b/src/ui/formAction.ts index 253b964..867016f 100644 --- a/src/ui/formAction.ts +++ b/src/ui/formAction.ts @@ -52,14 +52,11 @@ export interface FormActionHandle { * return res.json(); * }); * - * form({ - * on: { submit: save.onSubmit }, - * nodes: [ - * input({ name: "title" }), - * button({ disabled: save.pending, nodes: () => (save.pending() ? "Saving..." : "Save") }), - * when(() => save.error() != null, () => div({ class: "error", nodes: () => String(save.error()) })), - * ], - * }); + * form({ on: { submit: save.onSubmit } }, [ + * input({ name: "title" }), + * button({ disabled: save.pending }, () => (save.pending() ? "Saving..." : "Save")), + * when(() => save.error() != null, () => div("error", () => String(save.error()))), + * ]); * ``` */ export function formAction( diff --git a/src/ui/inputMask.ts b/src/ui/inputMask.ts index 651e6a1..3d040f9 100644 --- a/src/ui/inputMask.ts +++ b/src/ui/inputMask.ts @@ -67,13 +67,62 @@ export function inputMask(options: MaskOptions): { return raw; } + function isSlot(c: string): boolean { + return c === "9" || c === "A" || c === "*"; + } + + // Build strip regex based on mask slots: + // - Pattern has only 9 → keep digits only + // - Pattern has only A → keep letters only + // - Pattern has * → keep all chars (only strip literal chars from the mask) + function buildStripRegex(): RegExp { + const hasDigit = options.pattern.includes("9"); + const hasLetter = options.pattern.includes("A"); + const hasAny = options.pattern.includes("*"); + if (hasAny) { + // Collect literal chars from the pattern to strip (they're auto-inserted by applyMask) + const literals = new Set(); + for (const c of options.pattern) { + if (!isSlot(c)) literals.add(c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + } + return literals.size > 0 ? new RegExp(`[${Array.from(literals).join("")}]`, "g") : /(?!)/g; + } + if (hasDigit && hasLetter) return /[^a-zA-Z0-9]/g; + if (hasDigit) return /[^0-9]/g; + if (hasLetter) return /[^a-zA-Z]/g; + return /[^a-zA-Z0-9]/g; + } + const stripRegex = buildStripRegex(); + const rawCharTest = options.pattern.includes("*") ? () => true : (c: string) => /[a-zA-Z0-9]/.test(c); + function bind(input: HTMLInputElement): void { input.addEventListener("input", () => { - const raw = input.value.replace(/[^a-zA-Z0-9]/g, ""); + const cursorBefore = input.selectionStart ?? input.value.length; + const oldValue = input.value; + const raw = oldValue.replace(stripRegex, ""); const masked = applyMask(raw); setValue(masked); setRawValue(extractRaw(masked)); input.value = masked; + + // Restore cursor: count raw chars before the old cursor, then find + // the position in the masked string after that many filled slots. + let rawBefore = 0; + for (let i = 0; i < cursorBefore && i < oldValue.length; i++) { + if (rawCharTest(oldValue[i])) rawBefore++; + } + let newCursor = 0; + let counted = 0; + for (; newCursor < masked.length; newCursor++) { + if (newCursor < options.pattern.length && isSlot(options.pattern[newCursor])) { + counted++; + if (counted >= rawBefore) { + newCursor++; + break; + } + } + } + input.setSelectionRange(newCursor, newCursor); }); input.addEventListener("focus", () => { diff --git a/src/ui/scopedStyle.ts b/src/ui/scopedStyle.ts index 2ad0f32..96809d0 100644 --- a/src/ui/scopedStyle.ts +++ b/src/ui/scopedStyle.ts @@ -81,13 +81,20 @@ export function scopedStyle(css: string): { scope: string; attr: string } { // Sanitize CSS to prevent data exfiltration attacks const safeCss = sanitizeCSS(css); - // Prefix all CSS selectors with the scope attribute + // Prefix all CSS selectors with the scope attribute. + // Insert [attr] BEFORE any pseudo-element (::before, ::after, etc.) + // since attribute selectors after pseudo-elements are invalid CSS. const scopedCSS = safeCss.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|\s*{)/g, (match, selector, delimiter) => { const trimmed = selector.trim(); // Skip @-rules and keyframe selectors if (trimmed.startsWith("@") || trimmed.startsWith("from") || trimmed.startsWith("to") || /^\d+%$/.test(trimmed)) { return match; } + // Split at pseudo-element boundary (::) and insert scope before it + const pseudoIdx = trimmed.indexOf("::"); + if (pseudoIdx >= 0) { + return `${trimmed.slice(0, pseudoIdx)}[${attr}]${trimmed.slice(pseudoIdx)}${delimiter}`; + } return `${trimmed}[${attr}]${delimiter}`; }); diff --git a/src/ui/socket.ts b/src/ui/socket.ts index 8a122f2..c814394 100644 --- a/src/ui/socket.ts +++ b/src/ui/socket.ts @@ -56,6 +56,7 @@ export function socket( let reconnectTimer: ReturnType | null = null; let heartbeatTimer: ReturnType | null = null; let disposed = false; + let manuallyClosed = false; function getUrl(): string { return typeof url === "function" ? url() : url; @@ -87,12 +88,13 @@ export function socket( ws.onclose = () => { setStatus("closed"); stopHeartbeat(); - if (autoReconnect && !disposed && reconnectCount < maxReconnects) { + if (autoReconnect && !disposed && !manuallyClosed && reconnectCount < maxReconnects) { reconnectCount++; reconnectTimer = setTimeout(() => { connect(); }, reconnectDelay); } + manuallyClosed = false; }; ws.onerror = () => { @@ -124,6 +126,7 @@ export function socket( } function close(): void { + manuallyClosed = true; if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; diff --git a/src/ui/transition.ts b/src/ui/transition.ts index 2817cd4..8bc294f 100644 --- a/src/ui/transition.ts +++ b/src/ui/transition.ts @@ -34,7 +34,7 @@ export interface TransitionOptions { * * @example * ```ts - * const box = div({ class: "box", nodes: "Hello" }); + * const box = div("box", "Hello"); * const { enter, leave } = transition(box, { * duration: 300, * enterClass: "fade-in", @@ -62,9 +62,18 @@ export function transition( } = options; const transitionValue = `${property} ${duration}ms ${easing} ${delay}ms`; + let activeTimer: ReturnType | null = null; + + function cancelPending(): void { + if (activeTimer !== null) { + clearTimeout(activeTimer); + activeTimer = null; + } + } function enter(): Promise { return new Promise((resolve) => { + cancelPending(); element.style.transition = transitionValue; if (enterClass) element.classList.add(enterClass); @@ -76,13 +85,14 @@ export function transition( if (activeClass) element.classList.add(activeClass); const done = () => { + activeTimer = null; if (enterClass) element.classList.remove(enterClass); onEnterDone?.(); resolve(); }; if (duration > 0) { - setTimeout(done, duration + delay); + activeTimer = setTimeout(done, duration + delay); } else { done(); } @@ -91,6 +101,7 @@ export function transition( function leave(): Promise { return new Promise((resolve) => { + cancelPending(); element.style.transition = transitionValue; if (activeClass) element.classList.remove(activeClass); @@ -98,13 +109,14 @@ export function transition( if (enterClass) element.classList.remove(enterClass); const done = () => { + activeTimer = null; if (leaveClass) element.classList.remove(leaveClass); onLeaveDone?.(); resolve(); }; if (duration > 0) { - setTimeout(done, duration + delay); + activeTimer = setTimeout(done, duration + delay); } else { done(); } diff --git a/src/ui/virtualList.ts b/src/ui/virtualList.ts index 53a2564..7fe2add 100644 --- a/src/ui/virtualList.ts +++ b/src/ui/virtualList.ts @@ -1,3 +1,4 @@ +import { registerDisposer } from "../core/rendering/dispose"; import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; @@ -38,9 +39,9 @@ export function VirtualList(props: VirtualListProps): HTMLElement { spacer.appendChild(content); container.appendChild(spacer); - container.addEventListener("scroll", () => { - setScrollTop(container.scrollTop); - }); + const onScroll = () => setScrollTop(container.scrollTop); + container.addEventListener("scroll", onScroll); + registerDisposer(container, () => container.removeEventListener("scroll", onScroll)); const update = () => { const items = props.items(); diff --git a/tests/deepSignal.test.ts b/tests/deepSignal.test.ts index b3ef1ee..9e7d7e1 100644 --- a/tests/deepSignal.test.ts +++ b/tests/deepSignal.test.ts @@ -1,7 +1,266 @@ import { describe, expect, it } from "vitest"; -import { deepSignal } from "../src/core/signals/deepSignal"; +import { deepEqual, deepSignal } from "../src/core/signals/deepSignal"; import { effect } from "../src/core/signals/effect"; +// ============================================================================ +// deepEqual — unit tests +// ============================================================================ + +describe("deepEqual", () => { + // ---- primitives --------------------------------------------------------- + it("returns true for identical primitives", () => { + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual("a", "a")).toBe(true); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(null, null)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + }); + + it("returns false for different primitives", () => { + expect(deepEqual(1, 2)).toBe(false); + expect(deepEqual("a", "b")).toBe(false); + expect(deepEqual(true, false)).toBe(false); + expect(deepEqual(null, undefined)).toBe(false); + expect(deepEqual(0, false)).toBe(false); + }); + + it("handles NaN", () => { + expect(deepEqual(NaN, NaN)).toBe(true); + }); + + it("distinguishes +0 and -0", () => { + expect(deepEqual(+0, -0)).toBe(false); + }); + + // ---- flat objects ------------------------------------------------------- + it("flat objects: equal", () => { + expect(deepEqual({ x: 1, y: 2 }, { x: 1, y: 2 })).toBe(true); + }); + + it("flat objects: different value", () => { + expect(deepEqual({ x: 1 }, { x: 2 })).toBe(false); + }); + + it("flat objects: different keys", () => { + expect(deepEqual({ x: 1 }, { y: 1 })).toBe(false); + }); + + it("flat objects: different key count", () => { + expect(deepEqual({ x: 1 }, { x: 1, y: 2 })).toBe(false); + }); + + // ---- nested objects ----------------------------------------------------- + it("deep objects: equal", () => { + const a = { a: { b: { c: 1 } } }; + const b = { a: { b: { c: 1 } } }; + expect(deepEqual(a, b)).toBe(true); + }); + + it("deep objects: different leaf", () => { + const a = { a: { b: { c: 1 } } }; + const b = { a: { b: { c: 2 } } }; + expect(deepEqual(a, b)).toBe(false); + }); + + // ---- arrays ------------------------------------------------------------- + it("arrays: equal", () => { + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true); + }); + + it("arrays: different element", () => { + expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false); + }); + + it("arrays: different length", () => { + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false); + }); + + it("nested arrays", () => { + expect(deepEqual([[1, 2], [3]], [[1, 2], [3]])).toBe(true); + expect(deepEqual([[1, 2], [3]], [[1, 2], [4]])).toBe(false); + }); + + // ---- Date --------------------------------------------------------------- + it("dates: same timestamp", () => { + expect(deepEqual(new Date("2024-01-01"), new Date("2024-01-01"))).toBe(true); + }); + + it("dates: different timestamp", () => { + expect(deepEqual(new Date("2024-01-01"), new Date("2025-01-01"))).toBe(false); + }); + + it("date vs plain object → false (Bug #2 regression)", () => { + expect(deepEqual(new Date(), {})).toBe(false); + }); + + it("date vs map → false (Bug #2 regression)", () => { + expect(deepEqual(new Date(), new Map())).toBe(false); + }); + + // ---- RegExp ------------------------------------------------------------- + it("regex: same pattern and flags", () => { + expect(deepEqual(/abc/gi, /abc/gi)).toBe(true); + }); + + it("regex: different pattern", () => { + expect(deepEqual(/abc/, /def/)).toBe(false); + }); + + it("regex: different flags", () => { + expect(deepEqual(/abc/i, /abc/g)).toBe(false); + }); + + it("regex vs plain object → false (Bug #2 regression)", () => { + expect(deepEqual(/abc/, {})).toBe(false); + }); + + // ---- Map ---------------------------------------------------------------- + it("maps: equal entries", () => { + expect( + deepEqual( + new Map([ + ["a", 1], + ["b", 2], + ]), + new Map([ + ["a", 1], + ["b", 2], + ]), + ), + ).toBe(true); + }); + + it("maps: different value (Bug #3 regression)", () => { + expect(deepEqual(new Map([["a", 1]]), new Map([["a", 999]]))).toBe(false); + }); + + it("maps: different size", () => { + expect(deepEqual(new Map([["a", 1]]), new Map())).toBe(false); + }); + + it("maps: deep value equality", () => { + expect(deepEqual(new Map([["x", { nested: true }]]), new Map([["x", { nested: true }]]))).toBe(true); + }); + + // ---- Set ---------------------------------------------------------------- + it("sets: equal members", () => { + expect(deepEqual(new Set([1, 2, 3]), new Set([1, 2, 3]))).toBe(true); + }); + + it("sets: different members (Bug #3 regression)", () => { + expect(deepEqual(new Set([1, 2, 3]), new Set([99]))).toBe(false); + }); + + it("sets: different size", () => { + expect(deepEqual(new Set([1]), new Set([1, 2]))).toBe(false); + }); + + it("map vs set → false", () => { + expect(deepEqual(new Map(), new Set())).toBe(false); + }); + + // ---- TypedArrays -------------------------------------------------------- + it("typed arrays: equal", () => { + expect(deepEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true); + }); + + it("typed arrays: different", () => { + expect(deepEqual(new Uint8Array([1, 2, 3]), new Uint8Array([9, 9, 9]))).toBe(false); + }); + + it("typed arrays: different length", () => { + expect(deepEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); + }); + + // ---- ArrayBuffer -------------------------------------------------------- + it("array buffers: equal", () => { + const a = new Uint8Array([1, 2]).buffer; + const b = new Uint8Array([1, 2]).buffer; + expect(deepEqual(a, b)).toBe(true); + }); + + it("array buffers: different", () => { + const a = new Uint8Array([1, 2]).buffer; + const b = new Uint8Array([3, 4]).buffer; + expect(deepEqual(a, b)).toBe(false); + }); + + // ---- Constructor mismatch ----------------------------------------------- + it("constructor mismatch → false (Bug #2 regression)", () => { + class Foo {} + class Bar {} + expect(deepEqual(new Foo(), new Bar())).toBe(false); + }); + + // ---- Shared sub-references (Bug #1 regression) -------------------------- + it("shared sub-ref compared against different partner → false", () => { + const shared = { x: 1 }; + const a = { left: shared, right: shared }; + const b = { left: shared, right: { x: 999 } }; + expect(deepEqual(a, b)).toBe(false); + }); + + it("shared sub-ref compared against identical partner → true", () => { + const shared = { x: 1 }; + const a = { left: shared, right: shared }; + const b = { left: { x: 1 }, right: { x: 1 } }; + expect(deepEqual(a, b)).toBe(true); + }); + + // ---- Circular references (Bug #4 regression) ---------------------------- + it("symmetric cycles → true", () => { + const a: Record = {}; + a.self = a; + const b: Record = {}; + b.self = b; + expect(deepEqual(a, b)).toBe(true); + }); + + it("asymmetric cycle vs different object → false", () => { + const a: Record = {}; + a.self = a; + expect(deepEqual(a, { self: { real: 1 } })).toBe(false); + }); + + it("cycle in nested structure", () => { + const a: Record = { val: 1 }; + a.next = { val: 2, next: a }; + const b: Record = { val: 1 }; + b.next = { val: 2, next: b }; + expect(deepEqual(a, b)).toBe(true); + }); + + it("cycle in nested structure — different leaf", () => { + const a: Record = { val: 1 }; + a.next = { val: 2, next: a }; + const b: Record = { val: 1 }; + b.next = { val: 999, next: b }; + expect(deepEqual(a, b)).toBe(false); + }); + + // ---- Mixed / edge cases ------------------------------------------------- + it("null vs object → false", () => { + expect(deepEqual(null, {})).toBe(false); + expect(deepEqual({}, null)).toBe(false); + }); + + it("array vs object → false", () => { + expect(deepEqual([], {})).toBe(false); + }); + + it("empty objects → true", () => { + expect(deepEqual({}, {})).toBe(true); + }); + + it("empty arrays → true", () => { + expect(deepEqual([], [])).toBe(true); + }); +}); + +// ============================================================================ +// deepSignal — integration tests +// ============================================================================ + describe("deepSignal", () => { it("should hold initial value", () => { const [value] = deepSignal({ name: "Alice", age: 25 }); @@ -58,4 +317,43 @@ describe("deepSignal", () => { setArr([1, 2, 4]); expect(calls).toBe(2); }); + + it("should detect Map changes (Bug #3 regression)", () => { + const [m, setM] = deepSignal(new Map([["a", 1]])); + let calls = 0; + + effect(() => { + m(); + calls++; + }); + + expect(calls).toBe(1); + + setM(new Map([["a", 1]])); + expect(calls).toBe(1); + + setM(new Map([["a", 999]])); + expect(calls).toBe(2); + }); + + it("should detect shared-ref differences (Bug #1 regression)", () => { + const shared = { x: 1 }; + const [v, setV] = deepSignal({ left: shared, right: shared }); + let calls = 0; + + effect(() => { + v(); + calls++; + }); + + expect(calls).toBe(1); + + // Same structure — no notification + setV({ left: { x: 1 }, right: { x: 1 } }); + expect(calls).toBe(1); + + // Different right — should notify + setV({ left: { x: 1 }, right: { x: 999 } }); + expect(calls).toBe(2); + }); }); diff --git a/tests/optimistic.test.ts b/tests/optimistic.test.ts index b344ed1..3fc5807 100644 --- a/tests/optimistic.test.ts +++ b/tests/optimistic.test.ts @@ -1,65 +1,226 @@ import { describe, expect, it } from "vitest"; import { optimistic, optimisticList } from "../src/patterns/optimistic"; +// Helpers +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ============================================================================ +// optimistic (single value) +// ============================================================================ + describe("optimistic", () => { - it("should initialize with value", () => { - const [value] = optimistic("hello"); - expect(value()).toBe("hello"); + it("initializes with value", () => { + const o = optimistic("hello"); + expect(o.value()).toBe("hello"); + expect(o.pending()).toBe(false); }); - it("should apply optimistic value immediately", async () => { - const [value, addOptimistic] = optimistic("initial"); + it("applies optimistic value immediately", async () => { + const o = optimistic("initial"); + const p = o.update("optimistic", async () => "confirmed"); + expect(o.value()).toBe("optimistic"); + expect(o.pending()).toBe(true); + await p; + expect(o.value()).toBe("confirmed"); + expect(o.pending()).toBe(false); + }); - const promise = addOptimistic("optimistic", async () => { - return "confirmed"; + it("reverts on failure", async () => { + const o = optimistic("initial"); + const p = o.update("optimistic", async () => { + throw new Error("fail"); }); - - // Value should be optimistic immediately - expect(value()).toBe("optimistic"); - - await promise; - expect(value()).toBe("confirmed"); + expect(o.value()).toBe("optimistic"); + await p; + expect(o.value()).toBe("initial"); + expect(o.pending()).toBe(false); }); - it("should revert on failure", async () => { - const [value, addOptimistic] = optimistic("initial"); + it("pending is true while any operation is in flight", async () => { + const o = optimistic(0); + const p1 = o.update(1, () => delay(50).then(() => 1)); + const p2 = o.update(2, () => delay(100).then(() => 2)); + expect(o.pending()).toBe(true); + await p1; + expect(o.pending()).toBe(true); + await p2; + expect(o.pending()).toBe(false); + }); - const promise = addOptimistic("optimistic", async () => { + it("concurrent: stale revert is suppressed when a newer operation exists", async () => { + const o = optimistic(0); + // First op: will fail after 50ms + const p1 = o.update(10, async () => { + await delay(50); throw new Error("fail"); }); + // Second op: starts immediately, captures prev=10, succeeds after 100ms + const p2 = o.update(20, () => delay(100).then(() => 25)); + + expect(o.value()).toBe(20); + + await p1; + // First op failed — but second op is newer, so revert is suppressed + expect(o.value()).toBe(20); + + await p2; + expect(o.value()).toBe(25); + expect(o.pending()).toBe(false); + }); + + it("concurrent: stale success is suppressed when a newer operation exists", async () => { + const o = optimistic(0); + const p1 = o.update(10, () => delay(100).then(() => 10)); + const p2 = o.update(20, () => delay(50).then(() => 20)); - expect(value()).toBe("optimistic"); + await p2; + expect(o.value()).toBe(20); - await promise; - expect(value()).toBe("initial"); // Reverted + await p1; + // First op succeeded — but second op was newer, so result is ignored + expect(o.value()).toBe(20); }); }); -describe("optimisticList", () => { - it("should add items optimistically", async () => { - const { items, addOptimistic } = optimisticList([1, 2, 3]); +// ============================================================================ +// optimisticList +// ============================================================================ - await addOptimistic(4, async () => 4); - expect(items()).toEqual([1, 2, 3, 4]); +describe("optimisticList", () => { + it("initializes with items", () => { + const o = optimisticList([1, 2, 3]); + expect(o.items()).toEqual([1, 2, 3]); + expect(o.pending()).toBe(false); }); - it("should revert added items on failure", async () => { - const { items, addOptimistic } = optimisticList([1, 2, 3]); + // ---- add ---------------------------------------------------------------- + + it("add: applies item immediately and replaces with result on success", async () => { + const o = optimisticList([1, 2]); + await o.add(3, async () => 30); + expect(o.items()).toEqual([1, 2, 30]); + }); - await addOptimistic(4, async () => { + it("add: reverts on failure", async () => { + const o = optimisticList([1, 2]); + await o.add(3, async () => { throw new Error("fail"); }); + expect(o.items()).toEqual([1, 2]); + }); - expect(items()).toEqual([1, 2, 3]); + it("add: pending tracks in-flight state", async () => { + const o = optimisticList([]); + const p = o.add(1, () => delay(20).then(() => 1)); + expect(o.pending()).toBe(true); + await p; + expect(o.pending()).toBe(false); }); - it("should remove items optimistically", async () => { - const { items, removeOptimistic } = optimisticList([1, 2, 3]); + // ---- remove ------------------------------------------------------------- - await removeOptimistic( - (item) => item === 2, + it("remove: filters immediately and keeps on success", async () => { + const o = optimisticList([1, 2, 3]); + await o.remove( + (i) => i === 2, async () => {}, ); - expect(items()).toEqual([1, 3]); + expect(o.items()).toEqual([1, 3]); + }); + + it("remove: reverts on failure", async () => { + const o = optimisticList([1, 2, 3]); + await o.remove( + (i) => i === 2, + async () => { + throw new Error("fail"); + }, + ); + expect(o.items()).toEqual([1, 2, 3]); + }); + + // ---- update ------------------------------------------------------------- + + it("update: patches immediately and replaces with result on success", async () => { + type Item = { id: number; name: string }; + const o = optimisticList([ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); + + await o.update( + (i) => i.id === 2, + { name: "Bobby" }, + async () => ({ id: 2, name: "Robert" }), + ); + + expect(o.items()).toEqual([ + { id: 1, name: "Alice" }, + { id: 2, name: "Robert" }, + ]); + }); + + it("update: reverts on failure", async () => { + type Item = { id: number; name: string }; + const o = optimisticList([ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); + + await o.update( + (i) => i.id === 2, + { name: "Bobby" }, + async () => { + throw new Error("fail"); + }, + ); + + expect(o.items()).toEqual([ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); + }); + + it("update: predicate that changes the matched property still resolves (Bug #3 regression)", async () => { + type Item = { id: number; status: string }; + const o = optimisticList([{ id: 1, status: "draft" }]); + + await o.update( + (i) => i.id === 1, + { status: "published" }, + async () => ({ id: 1, status: "published" }), + ); + + expect(o.items()).toEqual([{ id: 1, status: "published" }]); + }); + + // ---- concurrent version guard ------------------------------------------- + + it("concurrent: stale revert suppressed when newer op exists", async () => { + const o = optimisticList([1, 2, 3]); + + const p1 = o.add(4, async () => { + await delay(50); + throw new Error("fail"); + }); + const p2 = o.add(5, () => delay(100).then(() => 50)); + + expect(o.items()).toEqual([1, 2, 3, 4, 5]); + + await p1; + // First op failed but second is newer — revert suppressed, 5 stays + expect(o.items()).toContain(5); + + await p2; + expect(o.items()).toContain(50); + }); + + // ---- deprecated aliases ------------------------------------------------- + + it("addOptimistic/removeOptimistic/updateOptimistic are aliases", () => { + const o = optimisticList([]); + expect(o.addOptimistic).toBe(o.add); + expect(o.removeOptimistic).toBe(o.remove); + expect(o.updateOptimistic).toBe(o.update); }); }); diff --git a/tests/urlState.test.ts b/tests/urlState.test.ts index 27be2f5..8db97b1 100644 --- a/tests/urlState.test.ts +++ b/tests/urlState.test.ts @@ -42,16 +42,24 @@ describe("urlState", () => { location.hash = hash ? `#${hash}` : ""; } + function fireEvent(name: string) { + for (const h of handlers[name] || []) h({} as Event); + } + afterEach(() => { vi.unstubAllGlobals(); }); + // ---- initial state ------------------------------------------------------ + it("reads initial search params and hash", () => { const u = urlState(); expect(u.params().get("q")).toBe("hello"); expect(u.hash()).toBe("#top"); }); + // ---- setParams ---------------------------------------------------------- + it("setParams pushes by default and updates signal", () => { const u = urlState(); u.setParams({ q: "world", page: "2" }); @@ -66,24 +74,138 @@ describe("urlState", () => { expect(historyCalls.at(-1)?.method).toBe("replace"); }); + it("setParams accepts URLSearchParams instance", () => { + const u = urlState(); + const p = new URLSearchParams(); + p.set("foo", "bar"); + u.setParams(p); + expect(u.params().get("foo")).toBe("bar"); + }); + + it("setParams with empty params produces clean URL (no trailing ?)", () => { + const u = urlState(); + u.setParams(new URLSearchParams()); + expect(historyCalls.at(-1)?.url).toBe("/home#top"); + }); + + it("setParams preserves the current hash", () => { + const u = urlState(); + u.setParams({ q: "new" }); + expect(historyCalls.at(-1)?.url).toContain("#top"); + }); + + // ---- setHash ------------------------------------------------------------ + it("setHash normalizes with # prefix", () => { const u = urlState(); u.setHash("section"); expect(u.hash()).toBe("#section"); }); + it("setHash keeps # prefix if already present", () => { + const u = urlState(); + u.setHash("#footer"); + expect(u.hash()).toBe("#footer"); + }); + + it("setHash('') clears the hash", () => { + const u = urlState(); + u.setHash(""); + expect(u.hash()).toBe(""); + expect(historyCalls.at(-1)?.url).toBe("/home?q=hello"); + }); + + it("setHash('#') clears the hash (bare # treated as empty)", () => { + const u = urlState(); + u.setHash("#"); + expect(u.hash()).toBe(""); + expect(historyCalls.at(-1)?.url).toBe("/home?q=hello"); + }); + + it("setHash preserves the current params", () => { + const u = urlState(); + u.setHash("bottom"); + expect(historyCalls.at(-1)?.url).toContain("?q=hello"); + }); + + it("setHash with replace uses replaceState", () => { + const u = urlState(); + u.setHash("x", { replace: true }); + expect(historyCalls.at(-1)?.method).toBe("replace"); + }); + + // ---- popstate sync ------------------------------------------------------ + it("syncs from popstate events", () => { const u = urlState(); location.search = "?q=changed"; location.hash = "#new"; - for (const h of handlers["popstate"] || []) h({} as Event); + fireEvent("popstate"); expect(u.params().get("q")).toBe("changed"); expect(u.hash()).toBe("#new"); }); - it("dispose removes popstate listener", () => { + it("popstate with unchanged URL does not create new URLSearchParams (dedup)", () => { + const u = urlState(); + const paramsBefore = u.params(); + fireEvent("popstate"); + expect(u.params()).toBe(paramsBefore); + }); + + // ---- hashchange sync (Bug fix regression) ------------------------------- + + it("syncs from hashchange events (anchor clicks, location.hash = ...)", () => { + const u = urlState(); + location.hash = "#anchor"; + fireEvent("hashchange"); + expect(u.hash()).toBe("#anchor"); + }); + + it("hashchange with unchanged hash does not notify (dedup)", () => { const u = urlState(); + const hashBefore = u.hash(); + fireEvent("hashchange"); + expect(u.hash()).toBe(hashBefore); + }); + + it("hashchange listener is registered on construction", () => { + urlState(); + expect(handlers["hashchange"]?.length).toBe(1); + }); + + // ---- rapid sequential calls --------------------------------------------- + + it("setParams then setHash produces correct final URL", () => { + const u = urlState(); + u.setParams({ q: "test" }); + u.setHash("section"); + expect(u.params().get("q")).toBe("test"); + expect(u.hash()).toBe("#section"); + expect(historyCalls.at(-1)?.url).toBe("/home?q=test#section"); + }); + + // ---- dispose ------------------------------------------------------------ + + it("dispose removes both popstate and hashchange listeners", () => { + const u = urlState(); + expect(handlers["popstate"]?.length).toBe(1); + expect(handlers["hashchange"]?.length).toBe(1); u.dispose(); expect(handlers["popstate"]?.length ?? 0).toBe(0); + expect(handlers["hashchange"]?.length ?? 0).toBe(0); + }); + + // ---- SSR fallback ------------------------------------------------------- + + it("returns inert signals when window is undefined", () => { + vi.stubGlobal("window", undefined); + const u = urlState(); + expect(u.params().toString()).toBe(""); + expect(u.hash()).toBe(""); + u.setParams({ x: "1" }); + u.setHash("y"); + expect(u.params().toString()).toBe(""); + expect(u.hash()).toBe(""); + u.dispose(); }); });