diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c79e15..f68fb21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,167 @@ This project follows [Semantic Versioning](https://semver.org/).
---
+## [2.0.0] — 2026-04-14
+
+Major hardening + features release. Spans reactivity, rendering, SSR, widgets, security, and build tooling. **2187/2187 tests passing, zero lint errors, zero type errors.**
+
+### Breaking
+
+- **Adapter method renames** — `redux.useSelector` → `redux.select`, `zustand.useSelector` → `zustand.select`. The `use*` prefix is no longer used anywhere in the framework.
+ ```ts
+ // before
+ const count = redux.useSelector(s => s.count);
+ // after
+ const count = redux.select(s => s.count);
+ ```
+
+- **`useDefaultPluginRegistry` renamed to `setDefaultPluginRegistry`** — aligns with the verb-based convention used elsewhere.
+
+- **`loadRemoteModule()` now refuses un-allowlisted URLs** — previously warned in dev and loaded anyway. Now rejects unless `{ allowedOrigins: [...] }` or `{ unsafelyAllowAnyOrigin: true }` is passed (CWE-829 supply-chain hardening).
+
+- **`loadWasmModule()` / `preloadWasm()` require origin allowlist** — same policy as `loadRemoteModule`. Options bag now disambiguated via `allowedOrigins`/`unsafelyAllowAnyOrigin` keys only.
+
+- **`compiled.staticTemplate()` / `precompile()` require `TrustedHTML`** — arbitrary strings no longer accepted to prevent silent `innerHTML` XSS sinks. Mint via `trustHTML(raw)` after your own sanitization pass.
+
+- **Router refuses protocol-relative redirects** — `"//evil.com/path"` style redirect targets now throw `NavigationFailureError` instead of logging a warning (CWE-601 open redirect).
+
+- **`hydrate()` / `hydrateIslands` / `hydrateProgressively` use replace strategy** — the prior in-place attribute-reconciliation silently orphaned reactive bindings to the discarded client tree, leaving the visible DOM frozen. The client subtree now replaces the server subtree (island markers and `data-sibu-hydrated` preserved) so reactive bindings actually drive the DOM.
+
+- **`socket()` / `stream()` default `maxReconnects` is now 10** — was effectively unbounded. Permanently broken URLs no longer hammer servers forever. Exponential backoff with jitter added.
+
+- **`optimisticList()` deprecated aliases removed** — `addOptimistic`/`removeOptimistic`/`updateOptimistic` were deprecated in 1.5.0 and are now gone. Use `add`/`remove`/`update`.
+
+- **`contentEditable().setContent` signature widened** — takes either a string (raw HTML, legacy) or `{ text, html, sanitize }`. The options form is the recommended path.
+
+### Added
+
+- **`retrack()`** reactivity primitive for derived pull-path — skips the `track()` cleanup pass; uses save/restore of `currentSubscriber` instead of stackTop push/pop. Steady-state chains avoid Set.delete+add churn.
+
+- **`effect((onCleanup) => { … })`** — canonical teardown pattern now built in. User cleanups run in reverse registration order before every re-run and on dispose; throwing cleanups are isolated and logged.
+ ```ts
+ effect((onCleanup) => {
+ const handler = () => { … };
+ window.addEventListener("resize", handler);
+ onCleanup(() => window.removeEventListener("resize", handler));
+ });
+ ```
+
+- **`derived(getter, { equals })`** — custom equality suppresses notifications when the recomputed value is equivalent to the previous.
+
+- **`Dispose` canonical type** exported from `sibu`.
+
+- **Widget ARIA `bind()` layer** — every headless widget now ships a `bind(els)` that wires roles, keyboard, and idempotent teardown per WAI-ARIA APG:
+ - `Tabs` — role=tablist, roving tabindex, Arrow/Home/End
+ - `Accordion` — aria-expanded/controls, Enter/Space
+ - `Tooltip` — role=tooltip, aria-describedby splice, Escape dismiss, hoverable grace
+ - `Popover` — role=dialog, aria-haspopup, Escape + click-outside
+ - `Combobox` — Combobox 1.2 pattern, aria-activedescendant, typeahead
+ - `Select` — role=listbox, aria-multiselectable, typeahead, disabled-aware
+ - `FileUpload` — labeling, aria-describedby splice, drop-zone keyboard
+ - `datePicker` — role=grid, arrow/Home/End, PageUp/Down, Shift+PageUp/Down (year)
+ - All `bind()` returns are idempotent via WeakMap and restore every touched attribute on dispose.
+
+- **`takePendingError()` exported** — ErrorBoundary now scans mounted subtrees for stashed errors from `lazy()` rejections that beat any boundary to mount. Multiple pending errors wrapped in `AggregateError`.
+
+- **`trustHTML(html)` + `TrustedHTML` type** re-exported from `sibu` (was only on `sibu/ssr` and `sibu/performance`, which minted incompatible brands).
+
+- **Test-reset helpers** — `__resetQueryCache`, `__resetDialogStack`, `__removeRouterPagehideHandler`.
+
+- **Build/release hardening** — `tsup --clean`; `./cdn` subpath export; `publishConfig.access=public` + `provenance=true`; `publish.mjs` publishes BEFORE git commit/tag (so a publish failure leaves no orphan commit).
+
+- **10 new tests** — `keepAlive.test.ts`, `pluginRegistry.test.ts`, `widgetsAria.test.ts`.
+
+### Fixed — Reactivity
+
+- **`derived` pull-path correctness under `suspendTracking`** — new conditional deps register their markDirty subscription even when the outer caller is in `untracked()` context.
+- **`propagateDirty`** is iterative (no recursion) with already-dirty skip — closes O(depth²) walk on deep chains.
+- **`batch.flushBatch`** wrapped in try/finally — a throwing subscriber can't strand `pendingSignals` for the next batch.
+- **`effect()` disposer idempotent** — double-dispose no longer re-emits `effect:destroy` or re-walks subs lists.
+- **`effect()` re-entry detection** — a re-entering update now warns in dev and drops (was silent).
+- **`bindChildNode` diff** — O(n²) nested scan replaced with O(n+m) Set-based reuse detection; dedupes duplicate node refs in the output array.
+- **Dead `signalSubscribers` WeakMap** removed (the `__s` property cache is authoritative).
+
+### Fixed — Rendering & Lifecycle
+
+- **`dispose()` re-entry safe** — snapshot-then-delete + bounded extra-pass drain. `Array.from(childNodes)` snapshot guards against disposers mutating the tree mid-walk.
+- **`onUnmount` false-fires on same-tick re-parent** — `fireUnmount` defers one microtask and re-checks `isConnected`.
+- **`lifecycle` descendant walk** short-circuited for leaf insertions.
+- **`keepAlive` disposed-flag** prevents post-dispose microtask writes; cached subtrees properly disposed on anchor teardown.
+- **`each` itemGetter** wraps in `untracked()` so per-row consumers don't subscribe to the whole-array signal.
+- **`each`, `portal`, `lazy.Suspense` error propagation** — CustomEvent dispatched on the anchor's Element parent (Comment anchors don't bubble); deferred one microtask for pre-mount races.
+- **`lazy()` pending-error stash** (`PENDING_ERROR` marker) — ErrorBoundary scans descendants on mount so failures before any boundary mounts aren't silently lost.
+- **`hydrateProgressively` island marker preserved** on replacement.
+
+### Fixed — Data & Platform
+
+- **`workerFn` pool crosstalk** — per-worker FIFO queue with `addEventListener`; terminate-on-error so concurrent `run()` calls can't mis-route results.
+- **`worker()` top-level** uses `addEventListener` + terminate-on-error.
+- **`infiniteQuery` run-id generation** — stale responses discarded; `AbortController.abort()` at top of effect.
+- **`offlineStore` atomic writes** — `idbPutWithChange` / `idbDeleteWithChange` single-transaction across `items`+`_changes`; cursor-snapshotted sync; pull skips items with pending local edits (conflict avoidance); `idbPutMany` batches remote items; `closed` flag checked between awaits; `sync()` error now logs via `devWarn` (was silent).
+- **`query` dedup** captures `entry.promise` locally and re-checks identity after await; sync-throw from `withRetry` cleaned up; `onSettled` in `finally`; `dispose()` idempotent + gcTimer deduplicated.
+- **`chunkLoader`** true LRU with `lastAccess`; `invalidate(id)` clears `preloaded`; `this.load` replaced with closure reference (destructure-safe); preload `.delete(id)` on failure.
+- **`serviceWorker`** listener refs tracked; prior `statechange` detached before reassignment; all detached in `unregister()`.
+- **`incrementalRegeneration`, `routerSSR`, `wakeLock`, `clearQueryCache` refetchers** — `.catch` instead of silent.
+- **`mutation.mutate()`** fire-and-forget rejection now warns (was silent `catch(() => {})`).
+
+### Fixed — SSR
+
+- **`runInSSRContext`** uses Node's `AsyncLocalStorage` when available so concurrent requests don't share `ssrMode`/suspense counters.
+- **`serializeState`** byte cap via `TextEncoder`; escapes U+2028/9; drops the `__SIBU_SSR_STATE_RAW__` fallback (defeated escape).
+- **`deserializeState`** dev-warns when no `validate` guard is passed.
+
+### Fixed — Widgets & UI
+
+- **`datePicker` month/year overflow** — uses day-1 anchor (no Jan-31→Mar-3 drift).
+- **`form.wrappedSet`** clears `manualErrors` on edit (server-side "email taken" errors no longer stick after user edits).
+- **`Tooltip.bind()` teardown** splices its id out of the current `aria-describedby` so ids added by other libraries survive.
+- **`a11y.FocusTrap`** `keydown` removed on dispose; announce live region checks `isConnected` before writing.
+- **`inputMask.bind()`** returns a dispose function that removes input/focus listeners.
+- **`customElement._teardown`** runs `dispose()` on rendered subtree before reconnect (reactive bindings no longer leak across reconnects).
+
+### Fixed — Plugins & Router
+
+- **`router.cleanupNodes`** calls `dispose(node)` before detaching — every reactive binding inside a route subtree is torn down on navigation.
+- **`Route()` / `KeepAliveRoute()` / `Outlet()`** `track()` teardowns stored in `routeCleanups` (was leaking effects).
+- **`RouterLink` click listener** removed via `registerDisposer`; navigate failures `.catch`'d.
+- **Router `pagehide` listener lazy-initialized** on first `createRouter()` call (honors `sideEffects: false`).
+
+### Fixed — Security
+
+- **`URL_ATTRIBUTES`** expanded: `xlink:href`, `formtarget`, `ping`, `data` now run through `sanitizeUrl()` (was bypassed).
+- **`persist` + `dragDrop` `JSON.parse`** revivers block `__proto__`/`constructor`/`prototype` (CWE-1321).
+- **`each` error dispatch** logs via `devWarn` when anchor is detached (no silent swallow).
+
+### Fixed — Performance
+
+- **Spring animation** is `dt`-aware (`REF_DT_MS`, `MAX_STEP_RATIO=4`, NaN-guard) — frame-rate-independent; no runaway on tab-throttle.
+- **`speech.ts` setInterval** polls only while actively speaking (was constant 5Hz).
+- **`socket` / `stream` auto-reconnect** — exponential backoff with jitter.
+
+### Fixed — DX
+
+- **Error prefix standardized to `[SibuJS]`** (was mix of `[Sibu]` / `[Sibu strict]` / `[Sibu hydration]`).
+- **`devtools.hmr`** calls `disposeNode` on replaced subtrees so HMR reloads don't leak effects/listeners.
+- **`testing.unmount` / `unmountAll`** call `dispose()` before clearing DOM (was `innerHTML = ""`, leaked every effect/binding).
+- **`tsconfig.json`** drops `"types": ["vitest"]` — zero `src/` deps on test-only types.
+- **Unused `biome-ignore` suppressions** removed; unused variables cleaned.
+
+### Migration
+
+Most apps need no changes. If you hit any of these:
+
+- **`redux.useSelector` / `zustand.useSelector`** → rename to `select`.
+- **`useDefaultPluginRegistry`** → rename to `setDefaultPluginRegistry`.
+- **`loadRemoteModule(url)` without options** → pass `{ allowedOrigins: [...] }` (recommended) or `{ unsafelyAllowAnyOrigin: true }` for opt-in.
+- **`loadWasmModule(url)`** → same.
+- **`compiled.staticTemplate(html)`** → wrap via `trustHTML(html)` after your sanitization.
+- **`hydrate()` consumers relying on preserved server DOM refs** → client tree replaces server tree; grab refs after mount.
+- **`socket({ autoReconnect: true })`** → now caps at 10 reconnect attempts; pass `maxReconnects: Infinity` to restore prior behavior.
+- **Router redirects to `//other-host/path`** → now throw; rewrite as relative or absolute `https://` within an allowed origin.
+- **`optimisticList().addOptimistic/removeOptimistic/updateOptimistic`** → rename to `add`/`remove`/`update`.
+
+---
+
## [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.
diff --git a/bench.mjs b/bench.mjs
index a79b55d..650d75b 100644
--- a/bench.mjs
+++ b/bench.mjs
@@ -21,14 +21,37 @@
import { JSDOM } from "jsdom";
// ── Bootstrap jsdom ──────────────────────────────────────────────────────────
+//
+// This script is standalone (run via `node bench.mjs`) so mutating globalThis
+// here is safe. The installBenchGlobals/restoreBenchGlobals pair is exported
+// shape for anyone importing this module from a test runner that cares about
+// global cleanup. Note: queueMicrotask is already available natively in Node.
const dom = new JSDOM("
");
-globalThis.document = dom.window.document;
-globalThis.HTMLElement = dom.window.HTMLElement;
-globalThis.Element = dom.window.Element;
-globalThis.Node = dom.window.Node;
-globalThis.Comment = dom.window.Comment;
-// Note: queueMicrotask is already available natively in Node.js
+
+const BENCH_GLOBAL_KEYS = ["document", "HTMLElement", "Element", "Node", "Comment"];
+const _savedGlobals = {};
+
+export function installBenchGlobals() {
+ for (const key of BENCH_GLOBAL_KEYS) {
+ _savedGlobals[key] = Object.prototype.hasOwnProperty.call(globalThis, key)
+ ? globalThis[key]
+ : undefined;
+ globalThis[key] = dom.window[key];
+ }
+}
+
+export function restoreBenchGlobals() {
+ for (const key of BENCH_GLOBAL_KEYS) {
+ if (_savedGlobals[key] === undefined) {
+ delete globalThis[key];
+ } else {
+ globalThis[key] = _savedGlobals[key];
+ }
+ }
+}
+
+installBenchGlobals();
// ── Import Sibu (from source via tsup output) ────────────────────────────────
diff --git a/index.ts b/index.ts
index 20da411..840f700 100644
--- a/index.ts
+++ b/index.ts
@@ -32,7 +32,7 @@ export type {
export { html } from "./src/core/rendering/htm";
// Rendering types
-export type { NodeChild, NodeChildren } from "./src/core/rendering/types";
+export type { Dispose, NodeChild, NodeChildren } from "./src/core/rendering/types";
// Mounting & rendering
export * from "./src/core/rendering/mount";
@@ -78,9 +78,13 @@ export { untracked } from "./src/reactivity/track";
export { bindDynamic } from "./src/reactivity/bindAttribute";
// Lazy loading & Suspense
-export { lazy, Suspense } from "./src/core/rendering/lazy";
+export { lazy, Suspense, takePendingError } from "./src/core/rendering/lazy";
export type { SuspenseProps } from "./src/core/rendering/lazy";
+// Trusted HTML brand for opt-in unsafe-HTML APIs (compiled.staticTemplate, ssr.headExtra)
+export { trustHTML } from "./src/platform/ssr";
+export type { TrustedHTML } from "./src/platform/ssr";
+
// Components
export * from "./src/components/ErrorBoundary";
export * from "./src/components/ErrorDisplay";
diff --git a/package.json b/package.json
index e8b921a..283455c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sibujs",
- "version": "1.5.0",
+ "version": "2.0.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",
@@ -33,7 +33,7 @@
"format": "biome format --write src/ tests/",
"test": "vitest",
"test:ui": "vitest --ui",
- "build": "tsup index.ts data.ts browser.ts patterns.ts motion.ts ui.ts widgets.ts ssr.ts devtools.ts performance.ts ecosystem.ts plugins.ts build.ts testing.ts extras.ts --dts --format esm,cjs --out-dir dist && tsup cdn.ts --format iife --globalName Sibu --out-dir dist --no-dts --minify",
+ "build": "tsup index.ts data.ts browser.ts patterns.ts motion.ts ui.ts widgets.ts ssr.ts devtools.ts performance.ts ecosystem.ts plugins.ts build.ts testing.ts extras.ts --dts --format esm,cjs --out-dir dist --clean && tsup cdn.ts --format iife --globalName Sibu --out-dir dist --no-dts --minify",
"bench": "node bench.mjs",
"bench:save": "node bench.mjs --save",
"bench:check": "node bench.mjs --compare",
@@ -116,8 +116,15 @@
"types": "./dist/extras.d.ts",
"import": "./dist/extras.js",
"require": "./dist/extras.cjs"
+ },
+ "./cdn": {
+ "default": "./dist/cdn.global.js"
}
},
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
"browserslist": [
"Chrome >= 80",
"Firefox >= 78",
diff --git a/publish.mjs b/publish.mjs
index 79c37ff..2a41029 100644
--- a/publish.mjs
+++ b/publish.mjs
@@ -1,6 +1,6 @@
#!/usr/bin/env node
-import { execSync } from "node:child_process";
+import { execSync, execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
@@ -20,6 +20,15 @@ function run(cmd, opts = {}) {
}
}
+// Argv-array form: safe against shell-interpolation of version strings.
+// Usage: runArgs("git", ["commit", "-m", msg])
+function runArgs(file, args, opts = {}) {
+ // Throw on failure so callers (release flow) can abort instead of silently
+ // proceeding to tag/publish with broken state. The previous swallow-and-return-null
+ // could leave a half-committed release on a pre-commit hook failure.
+ return execFileSync(file, args, { stdio: "inherit", cwd: __dirname, ...opts });
+}
+
function runSilent(cmd) {
try {
return execSync(cmd, { cwd: __dirname, encoding: "utf-8" }).trim();
@@ -151,38 +160,62 @@ async function main() {
// 1. Pre-flight
await preflight();
- // 2. Version bump
+ // 2. Version bump — capture pre-bump version so we can roll back on failure
const newVersion = await selectVersion();
+ if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) {
+ abort(`Invalid version format: "${newVersion}"`);
+ }
const pkg = readPkg();
+ const previousVersion = pkg.version;
pkg.version = newVersion;
writePkg(pkg);
console.log(` Updated package.json to v${newVersion}`);
+ // Helper: restore package.json to its pre-bump state
+ const restoreVersion = () => {
+ const current = readPkg();
+ current.version = previousVersion;
+ writePkg(current);
+ console.log(` Restored package.json to v${previousVersion}`);
+ };
+
// 3. Build
log("Building...");
if (run("npm run build") === null) {
- // Restore version on failure
- pkg.version = readPkg().version;
+ restoreVersion();
abort("Build failed.");
}
// 4. Tests
log("Running tests...");
if (run("npm run test -- --run") === null) {
- abort("Tests failed. Version was already bumped in package.json — revert if needed.");
+ restoreVersion();
+ abort("Tests failed.");
}
- // 5. Git commit & tag
- log("Creating git commit and tag...");
- run(`git add package.json`);
- run(`git commit -m "release: v${newVersion}"`);
- run(`git tag v${newVersion}`);
-
- // 6. Publish
+ // 5. Publish FIRST so a publish failure leaves no orphaned commit/tag
+ // behind. OTP is handled interactively by npm if required.
log("Publishing to npm...");
+ // publishConfig.access + provenance are set in package.json so the CLI flag
+ // is redundant; keep --access explicit as a belt-and-braces guard against
+ // private-by-default registries.
if (run("npm publish --access public") === null) {
+ restoreVersion();
+ abort("Publish failed. Reverted package.json; no git commit/tag created.");
+ }
+
+ // 6. Git commit & tag — only after publish succeeds. Args as array so the
+ // version string cannot be shell-interpreted. runArgs throws on failure
+ // (e.g. pre-commit hook), aborting before push.
+ log("Creating git commit and tag...");
+ try {
+ runArgs("git", ["add", "package.json"]);
+ runArgs("git", ["commit", "-m", `release: v${newVersion}`]);
+ runArgs("git", ["tag", `v${newVersion}`]);
+ } catch (err) {
abort(
- `Publish failed. Git commit and tag v${newVersion} were created — you may need to revert them.`
+ `Publish succeeded but git commit/tag failed (${err && err.message ? err.message : err}). ` +
+ `You'll need to commit and tag v${newVersion} manually.`,
);
}
diff --git a/release.sh b/release.sh
index 27449ad..886c951 100644
--- a/release.sh
+++ b/release.sh
@@ -1,14 +1,22 @@
#!/bin/bash
set -e
+set -u
+set -o pipefail
# ─── Validate argument ───────────────────────────────────────────────────────
-if [ -z "$1" ]; then
+if [ -z "${1:-}" ]; then
echo "Usage: ./release.sh "
echo "Example: ./release.sh 1.0.5 or ./release.sh v1.0.5"
exit 1
fi
+if ! echo "$1" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'; then
+ echo "Error: invalid version format: $1"
+ echo "Expected: 1.2.3 or v1.2.3 (optional -prerelease suffix)"
+ exit 1
+fi
+
VERSION="${1#v}"
TAG="v$VERSION"
@@ -25,13 +33,13 @@ echo ""
echo "🏷️ Creating and pushing tag $TAG..."
# Delete local tag if already exists
-if git tag | grep -q "^$TAG$"; then
+if git tag | grep -Fxq "$TAG"; then
echo " Tag $TAG already exists locally, deleting it..."
git tag -d "$TAG"
fi
# Delete remote tag if already exists (explicit refs/tags/ to avoid ambiguity)
-if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then
+if git ls-remote --tags origin | awk '{print $2}' | grep -Fxq "refs/tags/$TAG"; then
echo " Tag $TAG already exists on remote, deleting it..."
git push origin --delete "refs/tags/$TAG"
fi
diff --git a/src/browser/broadcast.ts b/src/browser/broadcast.ts
index be68231..5e2d915 100644
--- a/src/browser/broadcast.ts
+++ b/src/browser/broadcast.ts
@@ -1,6 +1,11 @@
import { signal } from "../core/signals/signal";
/**
+ * Note on trust model: `BroadcastChannel` only delivers messages between
+ * same-origin browsing contexts (tabs, iframes, workers). We therefore treat
+ * incoming payloads as same-origin-trusted and pass them through unmodified.
+ * Do not use this transport for cross-origin messaging.
+ *
* broadcast wraps the BroadcastChannel API as a reactive signal.
* Unlike the `storage` event (which only fires for localStorage writes and
* sends only the serialized value), a `BroadcastChannel` can send arbitrary
diff --git a/src/browser/dragDrop.ts b/src/browser/dragDrop.ts
index c8dc61b..a5b9c12 100644
--- a/src/browser/dragDrop.ts
+++ b/src/browser/dragDrop.ts
@@ -130,7 +130,11 @@ export function dropZone(
const raw = e.dataTransfer.getData("application/json");
if (raw) {
try {
- transferData = JSON.parse(raw);
+ // Reviver blocks __proto__/constructor/prototype to prevent
+ // prototype pollution from a foreign drag source (CWE-1321).
+ transferData = JSON.parse(raw, (k, v) =>
+ k === "__proto__" || k === "constructor" || k === "prototype" ? undefined : v,
+ );
} catch {
transferData = raw;
}
diff --git a/src/browser/imageLoader.ts b/src/browser/imageLoader.ts
index 37197df..7de253a 100644
--- a/src/browser/imageLoader.ts
+++ b/src/browser/imageLoader.ts
@@ -1,3 +1,4 @@
+import { effect } from "../core/signals/effect";
import { signal } from "../core/signals/signal";
export interface ImageLoaderState {
@@ -74,16 +75,24 @@ export function imageLoader(src: string | (() => string)): ImageLoaderState {
img.src = url;
}
+ let srcEffectTeardown: (() => void) | null = null;
if (typeof src === "function") {
- // Reactive src — we don't pull in `effect` to keep this module browser-only
- // and cheap. Callers that want reactive src should wrap in an effect.
- start(src());
+ // Re-run when the reactive src changes; abandons the prior in-flight load
+ // via the `current !== img` guard inside start().
+ srcEffectTeardown = effect(() => {
+ const url = (src as () => string)();
+ start(url);
+ });
} else {
start(src);
}
function dispose() {
disposed = true;
+ if (srcEffectTeardown) {
+ srcEffectTeardown();
+ srcEffectTeardown = null;
+ }
if (current) {
current.onload = null;
current.onerror = null;
diff --git a/src/browser/speech.ts b/src/browser/speech.ts
index 649fa32..2445054 100644
--- a/src/browser/speech.ts
+++ b/src/browser/speech.ts
@@ -55,14 +55,24 @@ export function speech(): {
const synth = window.speechSynthesis;
- // Poll the synth state — the spec doesn't dispatch events for every
- // transition on every browser, so periodic sync is the safest approach.
- const interval = setInterval(() => {
- setSpeaking(synth.speaking);
- setPaused(synth.paused);
- }, 200);
+ // Poll the synth state ONLY while something is actively speaking. Avoids
+ // a 5-Hz wake-up forever on pages that may never call speak().
+ let interval: ReturnType | null = null;
+ function startPolling(): void {
+ if (interval !== null) return;
+ interval = setInterval(() => {
+ setSpeaking(synth.speaking);
+ setPaused(synth.paused);
+ if (!synth.speaking && !synth.paused) {
+ clearInterval(interval as ReturnType);
+ interval = null;
+ }
+ }, 200);
+ }
+ let disposed = false;
function speak(text: string, options: SpeakOptions = {}): void {
+ if (disposed) return;
const u = new SpeechSynthesisUtterance(text);
if (options.lang) u.lang = options.lang;
if (options.rate != null) u.rate = options.rate;
@@ -73,20 +83,44 @@ export function speech(): {
const match = voices.find((v) => v.name === options.voice);
if (match) u.voice = match;
}
- u.addEventListener("start", () => setSpeaking(true));
- u.addEventListener("end", () => {
- setSpeaking(false);
- setPaused(false);
- });
- u.addEventListener("error", () => {
- setSpeaking(false);
- setPaused(false);
- });
+ // { once: true } on end/error + a disposed-guard on start prevent signal
+ // writes after dispose when synth.cancel() fires queued error events.
+ u.addEventListener(
+ "start",
+ () => {
+ if (!disposed) setSpeaking(true);
+ },
+ { once: true },
+ );
+ u.addEventListener(
+ "end",
+ () => {
+ if (disposed) return;
+ setSpeaking(false);
+ setPaused(false);
+ },
+ { once: true },
+ );
+ u.addEventListener(
+ "error",
+ () => {
+ if (disposed) return;
+ setSpeaking(false);
+ setPaused(false);
+ },
+ { once: true },
+ );
synth.speak(u);
+ setSpeaking(true);
+ startPolling();
}
function dispose(): void {
- clearInterval(interval);
+ disposed = true;
+ if (interval !== null) {
+ clearInterval(interval);
+ interval = null;
+ }
synth.cancel();
}
diff --git a/src/browser/wakeLock.ts b/src/browser/wakeLock.ts
index 0f22e71..a656f05 100644
--- a/src/browser/wakeLock.ts
+++ b/src/browser/wakeLock.ts
@@ -71,14 +71,22 @@ export function wakeLock(): {
// Re-acquire on visibility return (browsers auto-release when hidden)
const onVisibility = () => {
if (sentinel?.released && !document.hidden) {
- void request();
+ request().catch((err) => {
+ if (typeof console !== "undefined") {
+ console.warn("[SibuJS wakeLock] re-acquire failed:", err);
+ }
+ });
}
};
document.addEventListener("visibilitychange", onVisibility);
function dispose(): void {
document.removeEventListener("visibilitychange", onVisibility);
- void release();
+ release().catch((err) => {
+ if (typeof console !== "undefined") {
+ console.warn("[SibuJS wakeLock] release failed:", err);
+ }
+ });
}
return { active, request, release, dispose };
diff --git a/src/components/ErrorBoundary.ts b/src/components/ErrorBoundary.ts
index 89f4171..9ebbc5d 100644
--- a/src/components/ErrorBoundary.ts
+++ b/src/components/ErrorBoundary.ts
@@ -1,4 +1,7 @@
+import { registerDisposer } from "../core/rendering/dispose";
import { div, span, style } from "../core/rendering/html";
+import { takePendingError } from "../core/rendering/lazy";
+import { onMount } from "../core/rendering/lifecycle";
import { effect } from "../core/signals/effect";
import { signal } from "../core/signals/signal";
import { ErrorDisplay } from "./ErrorDisplay";
@@ -203,8 +206,13 @@ function injectStyles() {
}
}
-// Memoization cache for fallback elements keyed by error message
-const fallbackCache = new WeakMap<(...args: never[]) => unknown, Map>();
+// Memoization cache for fallback renderers keyed by error message.
+// We cache a *factory* (bound to the error) rather than a live Element to
+// avoid re-inserting the same DOM node into multiple parents and to bound
+// memory growth. Each fallback function gets its own LRU Map capped at
+// FALLBACK_CACHE_MAX entries — oldest key evicted when full.
+const FALLBACK_CACHE_MAX = 50;
+const fallbackCache = new WeakMap<(...args: never[]) => unknown, Map Element>>();
function getMemoizedFallback(
fallbackFn: (error: Error, retry: () => void) => Element,
@@ -217,10 +225,22 @@ function getMemoizedFallback(
fallbackCache.set(fallbackFn, cache);
}
const key = error.message;
- if (!cache.has(key)) {
- cache.set(key, fallbackFn(error, retry));
+ let factory = cache.get(key);
+ if (factory) {
+ // LRU touch: move to most-recently-used end
+ cache.delete(key);
+ cache.set(key, factory);
+ } else {
+ factory = () => fallbackFn(error, retry);
+ cache.set(key, factory);
+ // Evict oldest if over limit
+ if (cache.size > FALLBACK_CACHE_MAX) {
+ const oldestKey = cache.keys().next().value;
+ if (oldestKey !== undefined) cache.delete(oldestKey);
+ }
}
- return cache.get(key) as Element;
+ // Always return a *fresh* Element so the same node is never inserted twice
+ return factory();
}
// Stack parsing is now handled by ErrorDisplay. The helper used to
@@ -244,9 +264,13 @@ export function ErrorBoundary({ nodes, fallback, onError, resetKeys }: ErrorBoun
const [error, setError] = signal(null);
const retry = () => {
- // Clear memoized fallback cache on retry so fresh fallback is created
+ // Drop only the cached factory bound to the current error message, so
+ // memoized fallbacks for OTHER errors (e.g. unrelated boundary instances
+ // sharing the same fallback fn) survive.
if (fallback) {
- fallbackCache.delete(fallback);
+ const cur = error();
+ const inner = fallbackCache.get(fallback);
+ if (cur && inner) inner.delete(cur.message);
}
setError(null);
};
@@ -254,16 +278,21 @@ export function ErrorBoundary({ nodes, fallback, onError, resetKeys }: ErrorBoun
// Wire `resetKeys` — when any listed getter changes after an error has
// been caught, clear the error and re-render. Skip the first run so we
// do not retry before an error has even occurred.
+ // Capture the effect teardown so it can be disposed with the boundary.
+ let resetKeysTeardown: (() => void) | null = null;
if (resetKeys && resetKeys.length > 0) {
let initialized = false;
- effect(() => {
+ resetKeysTeardown = effect(() => {
// Read every key so each one is tracked as a dependency
for (const k of resetKeys) {
try {
k();
- } catch {
+ } catch (err) {
// A key getter that throws is still a valid dependency — we
// just ignore the value. Do not let it crash the effect.
+ if (typeof console !== "undefined") {
+ console.warn("[SibuJS ErrorBoundary] resetKeys getter threw:", err);
+ }
}
}
if (!initialized) {
@@ -277,7 +306,15 @@ export function ErrorBoundary({ nodes, fallback, onError, resetKeys }: ErrorBoun
const handleError = (e: unknown): Error => {
const errorObj = e instanceof Error ? e : new Error(String(e));
setError(errorObj);
- onError?.(errorObj);
+ if (onError) {
+ try {
+ onError(errorObj);
+ } catch (cbErr) {
+ if (typeof console !== "undefined") {
+ console.error("[SibuJS ErrorBoundary] onError callback threw:", cbErr);
+ }
+ }
+ }
return errorObj;
};
@@ -297,6 +334,9 @@ export function ErrorBoundary({ nodes, fallback, onError, resetKeys }: ErrorBoun
// Defer dispatch so the container is connected to the DOM tree first
const propagateError = fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError));
queueMicrotask(() => {
+ // CustomEvent bubbling traverses parentNode chains even on detached
+ // subtrees; require parentNode (not isConnected) so nested boundaries
+ // work in tests and pre-mount setup.
if (container.parentNode) {
container.dispatchEvent(
new CustomEvent("sibu:error-propagate", {
@@ -347,8 +387,10 @@ export function ErrorBoundary({ nodes, fallback, onError, resetKeys }: ErrorBoun
},
}) as Element;
- // Listen for error propagation from nested ErrorBoundaries
- container.addEventListener("sibu:error-propagate", (e: Event) => {
+ // Listen for error propagation from nested ErrorBoundaries.
+ // Store the handler so it can be removed via registerDisposer to avoid
+ // leaking the listener when the boundary itself is disposed.
+ const propagateListener = (e: Event) => {
// If this boundary is already in error state, let the event bubble to parent
if (error()) return;
e.stopPropagation();
@@ -357,6 +399,40 @@ export function ErrorBoundary({ nodes, fallback, onError, resetKeys }: ErrorBoun
if (propagatedError) {
handleError(propagatedError);
}
+ };
+ container.addEventListener("sibu:error-propagate", propagateListener);
+
+ // After mount, scan descendants for errors that were stashed by lazy()/etc.
+ // when their dispatch fired before any parent existed (silent-loss path).
+ // Collect every pending error so siblings aren't dropped.
+ onMount(() => {
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
+ const collected: Error[] = [];
+ // walker.currentNode starts at the container root — include it.
+ let node: Node | null = walker.currentNode;
+ while (node) {
+ const pending = takePendingError(node as Element);
+ if (pending) collected.push(pending);
+ node = walker.nextNode();
+ }
+ if (collected.length === 1) {
+ handleError(collected[0]);
+ } else if (collected.length > 1) {
+ const Agg = (globalThis as { AggregateError?: typeof AggregateError }).AggregateError;
+ handleError(
+ Agg
+ ? new Agg(collected, `${collected.length} pre-mount errors caught by ErrorBoundary`)
+ : new Error(collected.map((e) => e.message).join("; ")),
+ );
+ }
+ return undefined;
+ }, container as HTMLElement);
+
+ // Tear down resetKeys effect + remove the propagation listener when the
+ // boundary root is disposed (via when/match/each/dispose).
+ registerDisposer(container, () => {
+ if (resetKeysTeardown) resetKeysTeardown();
+ container.removeEventListener("sibu:error-propagate", propagateListener);
});
return container;
diff --git a/src/components/ErrorDisplay.ts b/src/components/ErrorDisplay.ts
index 56bc833..abfeef9 100644
--- a/src/components/ErrorDisplay.ts
+++ b/src/components/ErrorDisplay.ts
@@ -147,20 +147,21 @@ const STYLES = `
font-weight: 600;
}
.sibu-error-display .sibu-err-copy-btn {
- background: transparent;
- border: 1px solid #3a3a4e;
+ background: rgba(0, 0, 0, 0.22);
+ border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
- color: #a0a3b8;
+ color: rgba(255, 255, 255, 0.85);
cursor: pointer;
padding: 2px 10px;
- font-size: 0.95em;
+ font-size: 0.78em;
font-family: inherit;
transition: all 0.12s ease;
+ flex-shrink: 0;
}
.sibu-error-display .sibu-err-copy-btn:hover {
- background: #2a2a3e;
- color: #e5e7eb;
- border-color: #4a4a5e;
+ background: rgba(0, 0, 0, 0.35);
+ color: white;
+ border-color: rgba(255, 255, 255, 0.3);
}
.sibu-error-display .sibu-err-stack {
@@ -308,24 +309,28 @@ function normalizeError(err: unknown): NormalizedError {
};
}
-function buildCopyText(err: NormalizedError, meta: Record | undefined): string {
+function buildCopyText(err: NormalizedError, meta: Record | undefined, headline: string): string {
const lines: string[] = [];
+ lines.push(headline);
lines.push(`[${err.code}] ${err.message}`);
if (err.stack) {
lines.push("");
+ lines.push("Stack Trace:");
lines.push(err.stack);
}
- if (err.cause) {
+ let cause: NormalizedError | null = err.cause;
+ while (cause) {
lines.push("");
lines.push("Caused by:");
- lines.push(` [${err.cause.code}] ${err.cause.message}`);
- if (err.cause.stack) {
- const indented = err.cause.stack
+ lines.push(` [${cause.code}] ${cause.message}`);
+ if (cause.stack) {
+ const indented = cause.stack
.split("\n")
.map((l) => ` ${l}`)
.join("\n");
lines.push(indented);
}
+ cause = cause.cause;
}
if (meta && Object.keys(meta).length > 0) {
lines.push("");
@@ -335,9 +340,13 @@ function buildCopyText(err: NormalizedError, meta: Record | und
}
}
lines.push("");
- lines.push(`At: ${new Date().toISOString()}`);
+ lines.push("Environment:");
+ lines.push(` Timestamp: ${new Date().toISOString()}`);
+ if (typeof location !== "undefined") {
+ lines.push(` URL: ${location.href}`);
+ }
if (typeof navigator !== "undefined" && navigator.userAgent) {
- lines.push(`UA: ${navigator.userAgent}`);
+ lines.push(` User Agent: ${navigator.userAgent}`);
}
return lines.join("\n");
}
@@ -429,7 +438,7 @@ export function ErrorDisplay(props: ErrorDisplayProps): Element {
nodes: () => copyLabel(),
on: {
click: () => {
- const text = buildCopyText(normalized, props.metadata);
+ const text = buildCopyText(normalized, props.metadata, headline);
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(text).then(
() => {
@@ -451,6 +460,7 @@ export function ErrorDisplay(props: ErrorDisplayProps): Element {
nodes: [
code({ class: "sibu-err-icon", nodes: normalized.code }) as Element,
h3({ class: "sibu-err-title", nodes: headline }) as Element,
+ copyBtn,
span({ class: "sibu-err-timestamp", nodes: timestamp }) as Element,
],
}) as Element;
@@ -464,26 +474,12 @@ export function ErrorDisplay(props: ErrorDisplayProps): Element {
nodes: [
div({
class: "sibu-err-section-head",
- nodes: [span({ nodes: "Stack Trace" }) as Element, copyBtn],
+ nodes: [span({ nodes: "Stack Trace" }) as Element],
}) as Element,
renderFrames(normalized.frames),
],
}) as Element,
);
- } else if (showDetails) {
- // Even with no stack, still offer a copy button for the message + metadata
- bodyChildren.push(
- div({
- class: "sibu-err-section",
- nodes: [
- div({
- class: "sibu-err-section-head",
- nodes: [span({ nodes: "Details" }) as Element, copyBtn],
- }) as Element,
- div({ class: "sibu-err-stack", nodes: "(no stack available)" }) as Element,
- ],
- }) as Element,
- );
}
if (showDetails) {
@@ -502,38 +498,6 @@ export function ErrorDisplay(props: ErrorDisplayProps): Element {
);
}
- if (showDetails && typeof navigator !== "undefined" && navigator.userAgent) {
- bodyChildren.push(
- div({
- class: "sibu-err-section",
- nodes: [
- div({ class: "sibu-err-section-head", nodes: [span({ nodes: "Environment" }) as Element] }) as Element,
- div({
- class: "sibu-err-meta",
- nodes: (() => {
- const dl = document.createElement("dl");
- dl.className = "sibu-err-meta";
- const entries: [string, string][] = [
- ["User Agent", navigator.userAgent],
- ["URL", typeof location !== "undefined" ? location.href : "(n/a)"],
- ["Timestamp", new Date().toISOString()],
- ];
- for (const [k, v] of entries) {
- const dt = document.createElement("dt");
- dt.textContent = k;
- const dd = document.createElement("dd");
- dd.textContent = v;
- dl.appendChild(dt);
- dl.appendChild(dd);
- }
- return dl as unknown as Node;
- })(),
- }) as Element,
- ],
- }) as Element,
- );
- }
-
// Actions
const actionButtons: Element[] = [];
if (props.onRetry) {
diff --git a/src/core/dev.ts b/src/core/dev.ts
index dc42ed3..ba2fc0f 100644
--- a/src/core/dev.ts
+++ b/src/core/dev.ts
@@ -30,7 +30,7 @@ const _isDev = isDev();
*/
export function devAssert(condition: boolean, message: string): void {
if (_isDev && !condition) {
- throw new Error(`[Sibu] ${message}`);
+ throw new Error(`[SibuJS] ${message}`);
}
}
@@ -39,6 +39,6 @@ export function devAssert(condition: boolean, message: string): void {
*/
export function devWarn(message: string): void {
if (_isDev) {
- console.warn(`[Sibu] ${message}`);
+ console.warn(`[SibuJS] ${message}`);
}
}
diff --git a/src/core/rendering/context.ts b/src/core/rendering/context.ts
index e21b67b..22efa93 100644
--- a/src/core/rendering/context.ts
+++ b/src/core/rendering/context.ts
@@ -26,14 +26,32 @@ import { signal } from "../signals/signal";
*/
export interface Context {
- /** Set the context value globally. Affects all consumers. */
- provide(value: T): void;
+ /**
+ * Set the context value globally. Affects all consumers.
+ *
+ * Returns a `restore` function that re-sets the context to the value it
+ * had *before* this `provide` call. Useful for scoped overrides:
+ *
+ * ```ts
+ * const restore = Theme.provide("dark");
+ * try { renderChild(); } finally { restore(); }
+ * ```
+ *
+ * Callers that don't need scoping can ignore the return value — existing
+ * semantics are preserved.
+ */
+ provide(value: T): () => void;
/** Get a reactive getter for the current context value. */
use(): () => T;
/** Get the current value directly (non-reactive). */
get(): T;
/** Update the provided value reactively. */
set(value: T): void;
+ /**
+ * Run `fn` with the context temporarily set to `value`, then restore the
+ * previous value (even if `fn` throws). Returns the result of `fn`.
+ */
+ withContext(value: T, fn: () => R): R;
}
/**
@@ -45,9 +63,11 @@ export interface Context {
export function context(defaultValue: T): Context {
const [getValue, setValue] = signal(defaultValue);
- return {
- provide(value: T): void {
+ const ctx: Context = {
+ provide(value: T): () => void {
+ const previous = getValue();
setValue(value);
+ return () => setValue(previous);
},
use(): () => T {
@@ -61,5 +81,17 @@ export function context(defaultValue: T): Context {
set(value: T): void {
setValue(value);
},
+
+ withContext(value: T, fn: () => R): R {
+ const previous = getValue();
+ setValue(value);
+ try {
+ return fn();
+ } finally {
+ setValue(previous);
+ }
+ },
};
+
+ return ctx;
}
diff --git a/src/core/rendering/dispose.ts b/src/core/rendering/dispose.ts
index 8977f30..921e58a 100644
--- a/src/core/rendering/dispose.ts
+++ b/src/core/rendering/dispose.ts
@@ -38,7 +38,10 @@ export function dispose(node: Node): void {
while (stack.length > 0) {
const current = stack.pop()!;
order.push(current);
- const children = current.childNodes;
+ // Snapshot childNodes — it's a live NodeList. If a disposer mutates the
+ // tree mid-traversal (removeChild/replaceChild), reading it lazily can
+ // skip or duplicate children.
+ const children = Array.from(current.childNodes);
for (let i = 0; i < children.length; i++) {
stack.push(children[i]);
}
@@ -48,8 +51,14 @@ export function dispose(node: Node): void {
const current = order[i];
const disposers = elementDisposers.get(current);
if (disposers) {
- if (_isDev) activeBindingCount -= disposers.length;
- for (const d of disposers) {
+ // Snapshot + delete BEFORE running so re-entrant dispose() on the
+ // same node (e.g. parent disposer triggering child cleanup) doesn't
+ // re-run these or land in an infinite cycle. Disposers may also push
+ // new entries during execution; drain those after the snapshot.
+ const snapshot = disposers.slice();
+ elementDisposers.delete(current);
+ if (_isDev) activeBindingCount -= snapshot.length;
+ for (const d of snapshot) {
try {
d();
} catch (err) {
@@ -58,7 +67,25 @@ export function dispose(node: Node): void {
}
}
}
- elementDisposers.delete(current);
+ // Drain any disposers added during execution above. Bounded by a
+ // pass cap to prevent runaway re-entry.
+ let extraPasses = 0;
+ while (extraPasses++ < 8) {
+ const added = elementDisposers.get(current);
+ if (!added || added.length === 0) break;
+ const moreSnapshot = added.slice();
+ elementDisposers.delete(current);
+ if (_isDev) activeBindingCount -= moreSnapshot.length;
+ for (const d of moreSnapshot) {
+ try {
+ d();
+ } catch (err) {
+ if (_isDev && typeof console !== "undefined") {
+ console.warn("[SibuJS] Disposer threw during cleanup:", err);
+ }
+ }
+ }
+ }
}
}
}
diff --git a/src/core/rendering/dynamic.ts b/src/core/rendering/dynamic.ts
index fa7762b..8867430 100644
--- a/src/core/rendering/dynamic.ts
+++ b/src/core/rendering/dynamic.ts
@@ -1,5 +1,5 @@
import { track } from "../../reactivity/track";
-import { dispose } from "./dispose";
+import { dispose, registerDisposer } from "./dispose";
import { div } from "./html";
type Component = () => HTMLElement;
@@ -89,8 +89,10 @@ export function DynamicComponent(is: () => string | Component): HTMLElement {
container.replaceChildren(el);
}
- // Track reactive dependencies so render re-runs when `is()` changes
- track(render);
+ // Track reactive dependencies so render re-runs when `is()` changes.
+ // Capture the teardown so disposing the container unsubscribes the effect.
+ const untrack = track(render);
+ registerDisposer(container, untrack);
return container;
}
diff --git a/src/core/rendering/each.ts b/src/core/rendering/each.ts
index 1248cab..cc727fb 100644
--- a/src/core/rendering/each.ts
+++ b/src/core/rendering/each.ts
@@ -1,6 +1,6 @@
-import { track } from "../../reactivity/track";
+import { track, untracked } from "../../reactivity/track";
import { devAssert, devWarn, isDev } from "../dev";
-import { dispose } from "./dispose";
+import { dispose, registerDisposer } from "./dispose";
import type { NodeChild } from "./types";
const _isDev = isDev();
@@ -184,7 +184,10 @@ export function each(
// Create stable getters that close over the key and always read
// from the latest array via keyIndexMap, making them reactive.
const itemKey = key;
- const itemGetter = () => getArray()[keyIndexMap.get(itemKey)!];
+ // Read getArray() inside untracked() so consumers reading item()
+ // inside derived/effect do NOT subscribe to the whole-array signal —
+ // re-runs would otherwise fire on any unrelated row mutation.
+ const itemGetter = () => untracked(() => getArray()[keyIndexMap.get(itemKey)!]);
const indexGetter = () => keyIndexMap.get(itemKey)!;
try {
node = resolveNodeChild(render(itemGetter, indexGetter));
@@ -195,6 +198,24 @@ export function each(
);
}
node = document.createComment(`each:error:${i}`);
+ // Comment nodes don't bubble events to ancestor Elements. Defer
+ // and dispatch on the anchor's Element parent so an enclosing
+ // ErrorBoundary can catch it. Skip if anchor is still detached.
+ const errorObj = err instanceof Error ? err : new Error(String(err));
+ queueMicrotask(() => {
+ try {
+ const target = anchor.parentNode as Element | null;
+ if (target?.dispatchEvent) {
+ target.dispatchEvent(
+ new CustomEvent("sibu:error-propagate", { bubbles: true, detail: { error: errorObj } }),
+ );
+ } else if (_isDev) {
+ devWarn(`each: error not surfaced — anchor detached: ${errorObj.message}`);
+ }
+ } catch {
+ /* ignore */
+ }
+ });
}
}
workMap.set(key, node);
@@ -288,7 +309,9 @@ export function each(
// Track synchronously — dependencies are registered even if anchor
// has no parent yet (getArray() runs before the parent check).
- track(update);
+ // Capture teardown so disposing the anchor unsubscribes the effect.
+ const untrack = track(update);
+ registerDisposer(anchor, untrack);
// Fallback: if the anchor wasn't in the DOM during the initial track
// (common when each() is called inside tagFactory nodes), schedule
diff --git a/src/core/rendering/htm.ts b/src/core/rendering/htm.ts
index dc3479b..412a0b9 100644
--- a/src/core/rendering/htm.ts
+++ b/src/core/rendering/htm.ts
@@ -1,10 +1,17 @@
+import { devWarn, isDev } from "../../core/dev";
import { bindAttribute } from "../../reactivity/bindAttribute";
import { bindChildNode } from "../../reactivity/bindChildNode";
-import { isUrlAttribute, sanitizeUrl } from "../../utils/sanitize";
+import { isUrlAttribute, sanitizeSrcset, sanitizeUrl } from "../../utils/sanitize";
import { registerDisposer } from "./dispose";
import { SVG_NS } from "./tagFactory";
import type { NodeChild } from "./types";
+const _isDev = isDev();
+
+// Tags whose children are treated as raw text by the HTML parser and thus
+// cannot safely embed dynamic expressions.
+const RAW_TEXT_TAGS = new Set(["script", "style"]);
+
// Void elements that cannot have children (self-closing by spec)
const VOID_ELEMENTS = new Set([
"area",
@@ -277,6 +284,19 @@ function parseTemplate(strings: TemplateStringsArray): TmplChild[] {
} else {
const inner = parseChildren();
+ // Raw-text contexts (`;
}
@@ -862,7 +926,13 @@ export function serializeState(state: Record, nonce?: string):
*/
export function deserializeState>(validate?: (data: unknown) => data is T): T | undefined {
if (typeof window === "undefined") return undefined;
- const raw = (window as unknown as Record)[SSR_DATA_ATTR];
+ if (_isDev && !validate) {
+ console.warn(
+ "[SibuJS SSR] deserializeState() called without a validate guard — tampered SSR payloads will not be detected.",
+ );
+ }
+ const w = window as unknown as Record;
+ const raw = w[SSR_DATA_ATTR];
if (raw === undefined) return undefined;
if (validate && !validate(raw)) return undefined;
return raw as T;
diff --git a/src/platform/wasm.ts b/src/platform/wasm.ts
index 78e1fb8..da4688f 100644
--- a/src/platform/wasm.ts
+++ b/src/platform/wasm.ts
@@ -96,12 +96,52 @@ export function wasm = Record
* Supports loading from URL, ArrayBuffer, or Uint8Array.
* Caches compiled modules for reuse.
*/
+export interface LoadWasmOptions {
+ imports?: WebAssembly.Imports;
+ cacheKey?: string;
+ allowedOrigins?: string[];
+ /** Required when source is a URL and allowedOrigins is empty. WASM is
+ * compiled code with imports into JS memory — fetching from any URL is
+ * a supply-chain risk equivalent to remote module import (CWE-829). */
+ unsafelyAllowAnyOrigin?: boolean;
+}
+
export async function loadWasmModule(
source: string | ArrayBuffer | Uint8Array,
- imports?: WebAssembly.Imports,
+ imports?: WebAssembly.Imports | LoadWasmOptions,
cacheKey?: string,
): Promise {
- const key = cacheKey || (typeof source === "string" ? source : undefined);
+ // Back-compat: `imports` may be either WebAssembly.Imports (a record of
+ // module-name -> imports map) or a LoadWasmOptions bag. Disambiguate via
+ // the unique option keys ONLY — never `imports`/`cacheKey`, which a user
+ // could legally name a WASM module namespace.
+ const isOptionsBag = !!(imports && ("allowedOrigins" in imports || "unsafelyAllowAnyOrigin" in imports));
+ const opts: LoadWasmOptions = isOptionsBag
+ ? (imports as LoadWasmOptions)
+ : { imports: imports as WebAssembly.Imports | undefined, cacheKey };
+ const wasmImports = opts.imports;
+ const key = opts.cacheKey || (typeof source === "string" ? source : undefined);
+
+ if (typeof source === "string") {
+ const allowed = opts.allowedOrigins ?? [];
+ if (allowed.length > 0) {
+ let parsed: URL;
+ try {
+ parsed = new URL(source, typeof location !== "undefined" ? location.href : undefined);
+ } catch {
+ throw new Error(`loadWasmModule: invalid URL "${source}"`);
+ }
+ if (!allowed.includes(parsed.origin)) {
+ throw new Error(`loadWasmModule: origin "${parsed.origin}" is not in the allowlist`);
+ }
+ } else if (!opts.unsafelyAllowAnyOrigin) {
+ throw new Error(
+ `loadWasmModule: refused to fetch "${source}" with no allowedOrigins. ` +
+ "Pass { allowedOrigins: [...] } to restrict the origin, or " +
+ "{ unsafelyAllowAnyOrigin: true } to opt in (CWE-829).",
+ );
+ }
+ }
// Check instance cache
if (key) {
@@ -124,7 +164,7 @@ export async function loadWasmModule(
// URL - use streaming compilation if available
if (typeof WebAssembly.instantiateStreaming === "function") {
const response = fetch(source);
- const result = await WebAssembly.instantiateStreaming(response, imports || {});
+ const result = await WebAssembly.instantiateStreaming(response, wasmImports || {});
if (key) {
moduleCache.set(key, result.module);
instanceCache.set(key, result.instance);
@@ -144,7 +184,7 @@ export async function loadWasmModule(
}
// Instantiate
- const instance = await WebAssembly.instantiate(module, imports || {});
+ const instance = await WebAssembly.instantiate(module, wasmImports || {});
if (key) instanceCache.set(key, instance);
return instance;
}
@@ -155,8 +195,28 @@ export async function loadWasmModule(
* Preload and compile a WASM module without instantiating it.
* The compiled module is cached for instant instantiation later.
*/
-export async function preloadWasm(url: string): Promise {
+export async function preloadWasm(
+ url: string,
+ options: { allowedOrigins?: string[]; unsafelyAllowAnyOrigin?: boolean } = {},
+): Promise {
if (moduleCache.has(url)) return;
+ const allowed = options.allowedOrigins ?? [];
+ if (allowed.length > 0) {
+ let parsed: URL;
+ try {
+ parsed = new URL(url, typeof location !== "undefined" ? location.href : undefined);
+ } catch {
+ throw new Error(`preloadWasm: invalid URL "${url}"`);
+ }
+ if (!allowed.includes(parsed.origin)) {
+ throw new Error(`preloadWasm: origin "${parsed.origin}" is not in the allowlist`);
+ }
+ } else if (!options.unsafelyAllowAnyOrigin) {
+ throw new Error(
+ `preloadWasm: refused to fetch "${url}" with no allowedOrigins. ` +
+ "Pass { allowedOrigins: [...] } or { unsafelyAllowAnyOrigin: true } (CWE-829).",
+ );
+ }
let module: WebAssembly.Module;
if (typeof WebAssembly.compileStreaming === "function") {
diff --git a/src/platform/worker.ts b/src/platform/worker.ts
index f304caa..96b6e24 100644
--- a/src/platform/worker.ts
+++ b/src/platform/worker.ts
@@ -41,6 +41,14 @@ export function worker(
const [loading, setLoading] = signal(false);
let worker: Worker | null = null;
+ let blobUrl: string | null = null;
+
+ const revokeBlobUrl = () => {
+ if (blobUrl) {
+ URL.revokeObjectURL(blobUrl);
+ blobUrl = null;
+ }
+ };
try {
if (typeof Worker === "undefined") {
@@ -49,20 +57,28 @@ export function worker(
const fnBody = workerFn.toString();
const blob = new Blob([`self.onmessage = ${fnBody};`], { type: "application/javascript" });
- const url = URL.createObjectURL(blob);
- worker = new Worker(url);
- URL.revokeObjectURL(url);
+ blobUrl = URL.createObjectURL(blob);
+ worker = new Worker(blobUrl);
- worker.onmessage = (e: MessageEvent) => {
+ worker.addEventListener("message", (e: MessageEvent) => {
+ revokeBlobUrl();
setResult(e.data);
setLoading(false);
- };
+ });
- worker.onerror = (e: ErrorEvent) => {
+ worker.addEventListener("error", (e: ErrorEvent) => {
+ revokeBlobUrl();
setError(new Error(e.message || "Worker error"));
setLoading(false);
- };
+ // Mirror workerFn behavior: terminate on uncaught error so subsequent
+ // post() calls fail fast rather than silently target a broken worker.
+ if (worker) {
+ worker.terminate();
+ worker = null;
+ }
+ });
} catch (err) {
+ revokeBlobUrl();
setError(err instanceof Error ? err : new Error(String(err)));
}
@@ -78,6 +94,7 @@ export function worker(
if (!worker) return;
worker.terminate();
worker = null;
+ revokeBlobUrl();
setLoading(false);
}
@@ -109,6 +126,18 @@ export function workerFn(
const [loading, setLoading] = signal(false);
let worker: Worker | null = null;
+ let blobUrl: string | null = null;
+
+ const revokeBlobUrl = () => {
+ if (blobUrl) {
+ URL.revokeObjectURL(blobUrl);
+ blobUrl = null;
+ }
+ };
+
+ // FIFO queue of pending run() promises. The worker processes postMessage
+ // in order, so the head of the queue corresponds to the next reply.
+ const queue: { resolve: (v: TResult) => void; reject: (e: Error) => void }[] = [];
try {
if (typeof Worker === "undefined") {
@@ -126,11 +155,29 @@ export function workerFn(
],
{ type: "application/javascript" },
);
- const url = URL.createObjectURL(blob);
- worker = new Worker(url);
- URL.revokeObjectURL(url);
+ blobUrl = URL.createObjectURL(blob);
+ worker = new Worker(blobUrl);
+ worker.addEventListener("message", (e: MessageEvent) => {
+ revokeBlobUrl();
+ const head = queue.shift();
+ if (queue.length === 0) setLoading(false);
+ if (head) head.resolve(e.data);
+ });
+ worker.addEventListener("error", (e: ErrorEvent) => {
+ // Worker error events do not carry a request id, so we cannot know
+ // which pending run() failed. Reject ALL pending and terminate so
+ // future run() calls fail fast rather than silently mis-routing.
+ revokeBlobUrl();
+ const err = new Error(e.message || "Worker error");
+ while (queue.length > 0) queue.shift()!.reject(err);
+ setLoading(false);
+ if (worker) {
+ worker.terminate();
+ worker = null;
+ }
+ });
} catch {
- // Worker creation failed; run will reject
+ revokeBlobUrl();
}
function run(...args: TArgs): Promise {
@@ -139,19 +186,8 @@ export function workerFn(
reject(new Error("Worker is not available"));
return;
}
-
setLoading(true);
-
- worker.onmessage = (e: MessageEvent) => {
- setLoading(false);
- resolve(e.data);
- };
-
- worker.onerror = (e: ErrorEvent) => {
- setLoading(false);
- reject(new Error(e.message || "Worker error"));
- };
-
+ queue.push({ resolve, reject });
worker.postMessage(args);
});
}
@@ -160,6 +196,9 @@ export function workerFn(
if (!worker) return;
worker.terminate();
worker = null;
+ const err = new Error("Worker terminated");
+ while (queue.length > 0) queue.shift()!.reject(err);
+ revokeBlobUrl();
setLoading(false);
}
@@ -191,26 +230,69 @@ export function createWorkerPool(
): WorkerPool {
const size = poolSize || (typeof navigator !== "undefined" && navigator.hardwareConcurrency) || 4;
+ type Slot = { data: TInput; resolve: (v: TOutput) => void; reject: (e: Error) => void };
+ type Slot2 = Slot & { onMsg: (e: MessageEvent) => void; onErr: (e: ErrorEvent) => void };
const workers: Worker[] = [];
+ const queues: Slot[][] = [];
+ const inflight: (Slot2 | null)[] = [];
let currentIndex = 0;
let alive = true;
+ let blobUrl: string | null = null;
+ let firedOnce = false;
+
+ const revokeBlobUrl = () => {
+ if (blobUrl) {
+ URL.revokeObjectURL(blobUrl);
+ blobUrl = null;
+ }
+ };
+
+ function dispatchNext(idx: number) {
+ if (!alive || inflight[idx] || queues[idx].length === 0) return;
+ const w = workers[idx];
+ const slot = queues[idx].shift() as Slot;
+ const onMsg = (e: MessageEvent) => {
+ if (!firedOnce) {
+ firedOnce = true;
+ revokeBlobUrl();
+ }
+ w.removeEventListener("message", onMsg);
+ w.removeEventListener("error", onErr);
+ inflight[idx] = null;
+ slot.resolve(e.data);
+ dispatchNext(idx);
+ };
+ const onErr = (e: ErrorEvent) => {
+ if (!firedOnce) {
+ firedOnce = true;
+ revokeBlobUrl();
+ }
+ w.removeEventListener("message", onMsg);
+ w.removeEventListener("error", onErr);
+ inflight[idx] = null;
+ slot.reject(new Error(e.message || "Worker error"));
+ dispatchNext(idx);
+ };
+ inflight[idx] = { ...slot, onMsg, onErr };
+ w.addEventListener("message", onMsg);
+ w.addEventListener("error", onErr);
+ w.postMessage(slot.data);
+ }
try {
if (typeof Worker === "undefined") {
throw new Error("Web Workers are not supported in this environment");
}
-
const fnBody = workerFn.toString();
const blob = new Blob([`self.onmessage = ${fnBody};`], { type: "application/javascript" });
- const url = URL.createObjectURL(blob);
-
+ blobUrl = URL.createObjectURL(blob);
for (let i = 0; i < size; i++) {
- workers.push(new Worker(url));
+ workers.push(new Worker(blobUrl));
+ queues.push([]);
+ inflight.push(null);
}
-
- URL.revokeObjectURL(url);
} catch {
- // Pool creation failed; execute will reject
+ revokeBlobUrl();
}
function execute(data: TInput): Promise {
@@ -219,28 +301,26 @@ export function createWorkerPool(
reject(new Error("Worker pool is not available"));
return;
}
-
- const worker = workers[currentIndex % workers.length];
+ const idx = currentIndex % workers.length;
currentIndex++;
-
- worker.onmessage = (e: MessageEvent) => {
- resolve(e.data);
- };
-
- worker.onerror = (e: ErrorEvent) => {
- reject(new Error(e.message || "Worker error"));
- };
-
- worker.postMessage(data);
+ queues[idx].push({ data, resolve, reject });
+ dispatchNext(idx);
});
}
function terminate(): void {
alive = false;
- for (const w of workers) {
- w.terminate();
+ for (const w of workers) w.terminate();
+ const err = new Error("Worker pool terminated");
+ for (let i = 0; i < queues.length; i++) {
+ const inf = inflight[i];
+ if (inf) inf.reject(err);
+ for (const s of queues[i]) s.reject(err);
+ queues[i] = [];
+ inflight[i] = null;
}
workers.length = 0;
+ revokeBlobUrl();
}
return { execute, terminate };
diff --git a/src/plugins/plugin.ts b/src/plugins/plugin.ts
index f0c7326..4314a61 100644
--- a/src/plugins/plugin.ts
+++ b/src/plugins/plugin.ts
@@ -24,14 +24,107 @@ interface PluginHooks {
error: Array<(error: Error) => void>;
}
-const installedPlugins = new Set();
-const hooks: PluginHooks = {
- init: [],
- mount: [],
- unmount: [],
- error: [],
-};
-const provided = new Map();
+export interface PluginRegistry {
+ readonly installedPlugins: Set;
+ readonly hooks: PluginHooks;
+ readonly provided: Map;
+ plugin: (p: SibuPlugin, options?: unknown) => void;
+ inject: (key: string, defaultValue?: T) => T;
+ triggerMount: (element: HTMLElement) => void;
+ triggerUnmount: (element: HTMLElement) => void;
+ triggerError: (error: Error) => void;
+ reset: () => void;
+}
+
+/**
+ * Create an isolated plugin registry. Useful for tests, SSR per-request
+ * isolation, or embedding multiple independent SibuJS apps on one page.
+ */
+export function createPluginRegistry(): PluginRegistry {
+ const installedPlugins = new Set();
+ const hooks: PluginHooks = { init: [], mount: [], unmount: [], error: [] };
+ const provided = new Map();
+
+ const registry: PluginRegistry = {
+ installedPlugins,
+ hooks,
+ provided,
+ plugin(p, options) {
+ if (installedPlugins.has(p.name)) {
+ console.warn(`[Plugin] "${p.name}" is already installed.`);
+ return;
+ }
+ const ctx: PluginContext = {
+ onInit: (cb) => hooks.init.push(cb),
+ onMount: (cb) => hooks.mount.push(cb),
+ onUnmount: (cb) => hooks.unmount.push(cb),
+ onError: (cb) => hooks.error.push(cb),
+ provide: (key, value) => provided.set(key, value),
+ };
+ const initHooksBefore = hooks.init.length;
+ p.install(ctx, options);
+ installedPlugins.add(p.name);
+ // Snapshot only the init hooks added by this plugin, then iterate the copy
+ const justAdded = hooks.init.slice(initHooksBefore);
+ for (const cb of justAdded) {
+ try {
+ cb();
+ } catch (e) {
+ console.error(`[Plugin] "${p.name}" init error:`, e);
+ }
+ }
+ },
+ inject(key: string, defaultValue?: T): T {
+ if (provided.has(key)) return provided.get(key) as T;
+ if (defaultValue !== undefined) return defaultValue;
+ throw new Error(`[Plugin] No provider found for key "${key}"`);
+ },
+ triggerMount(element) {
+ // Snapshot before iterating — hooks may register/unregister re-entrantly
+ const snapshot = hooks.mount.slice();
+ for (const hook of snapshot) {
+ try {
+ hook(element);
+ } catch (e) {
+ console.error("[Plugin] Mount hook error:", e);
+ }
+ }
+ },
+ triggerUnmount(element) {
+ const snapshot = hooks.unmount.slice();
+ for (const hook of snapshot) {
+ try {
+ hook(element);
+ } catch (e) {
+ console.error("[Plugin] Unmount hook error:", e);
+ }
+ }
+ },
+ triggerError(error) {
+ const snapshot = hooks.error.slice();
+ for (const hook of snapshot) {
+ try {
+ hook(error);
+ } catch (e) {
+ console.error("[Plugin] Error hook error:", e);
+ }
+ }
+ },
+ reset() {
+ installedPlugins.clear();
+ hooks.init.length = 0;
+ hooks.mount.length = 0;
+ hooks.unmount.length = 0;
+ hooks.error.length = 0;
+ provided.clear();
+ },
+ };
+ return registry;
+}
+
+// Default singleton registry (kept for back-compat with existing public API).
+let defaultRegistry: PluginRegistry = createPluginRegistry();
+let defaultRegistryTouched = false;
/**
* Creates a plugin definition.
@@ -41,96 +134,61 @@ export function createPlugin(name: string, install: (ctx: PluginContext, options
}
/**
- * Installs a plugin into the application.
+ * Installs a plugin into the default (singleton) registry.
*/
export function plugin(plugin: SibuPlugin, options?: unknown): void {
- if (installedPlugins.has(plugin.name)) {
- console.warn(`[Plugin] "${plugin.name}" is already installed.`);
- return;
- }
-
- const ctx: PluginContext = {
- onInit: (cb) => hooks.init.push(cb),
- onMount: (cb) => hooks.mount.push(cb),
- onUnmount: (cb) => hooks.unmount.push(cb),
- onError: (cb) => hooks.error.push(cb),
- provide: (key, value) => provided.set(key, value),
- };
-
- const initHooksBefore = hooks.init.length;
- plugin.install(ctx, options);
- installedPlugins.add(plugin.name);
-
- // Run only the init hooks added by this plugin (not all hooks)
- for (let i = initHooksBefore; i < hooks.init.length; i++) {
- try {
- hooks.init[i]();
- } catch (e) {
- console.error(`[Plugin] "${plugin.name}" init error:`, e);
- }
- }
+ defaultRegistryTouched = true;
+ defaultRegistry.plugin(plugin, options);
}
/**
- * Retrieve a value provided by a plugin.
+ * Retrieve a value provided by a plugin (from the default registry).
*/
export function inject(key: string, defaultValue?: T): T {
- if (provided.has(key)) {
- return provided.get(key) as T;
- }
- if (defaultValue !== undefined) {
- return defaultValue;
- }
- throw new Error(`[Plugin] No provider found for key "${key}"`);
+ return defaultRegistry.inject(key, defaultValue);
}
/**
- * Trigger mount hooks for an element.
+ * Trigger mount hooks for an element (default registry).
*/
export function triggerPluginMount(element: HTMLElement): void {
- for (const hook of hooks.mount) {
- try {
- hook(element);
- } catch (e) {
- console.error("[Plugin] Mount hook error:", e);
- }
- }
+ defaultRegistry.triggerMount(element);
}
/**
- * Trigger unmount hooks for an element.
+ * Trigger unmount hooks for an element (default registry).
*/
export function triggerPluginUnmount(element: HTMLElement): void {
- for (const hook of hooks.unmount) {
- try {
- hook(element);
- } catch (e) {
- console.error("[Plugin] Unmount hook error:", e);
- }
- }
+ defaultRegistry.triggerUnmount(element);
}
/**
- * Trigger error hooks.
+ * Trigger error hooks (default registry).
*/
export function triggerPluginError(error: Error): void {
- for (const hook of hooks.error) {
- try {
- hook(error);
- } catch (e) {
- console.error("[Plugin] Error hook error:", e);
- }
- }
+ defaultRegistry.triggerError(error);
}
/**
- * Reset all plugins (useful for testing).
+ * Reset the default plugin registry (useful for testing).
*/
export function resetPlugins(): void {
- installedPlugins.clear();
- hooks.init.length = 0;
- hooks.mount.length = 0;
- hooks.unmount.length = 0;
- hooks.error.length = 0;
- provided.clear();
+ defaultRegistry.reset();
+ defaultRegistryTouched = false;
+}
+
+/**
+ * Replace the default registry with an isolated one. Emits a dev warning
+ * if the default singleton already had plugins installed (to surface
+ * accidental interleaving of singleton + registry use).
+ */
+export function setDefaultPluginRegistry(registry: PluginRegistry): void {
+ if (defaultRegistryTouched && defaultRegistry.installedPlugins.size > 0) {
+ console.warn(
+ "[Plugin] Replacing default plugin registry while plugins are already installed on the singleton. " +
+ "This may indicate mixed singleton/registry usage.",
+ );
+ }
+ defaultRegistry = registry;
+ defaultRegistryTouched = true;
}
diff --git a/src/plugins/router.ts b/src/plugins/router.ts
index 657e610..879825e 100644
--- a/src/plugins/router.ts
+++ b/src/plugins/router.ts
@@ -620,9 +620,11 @@ class ComponentLoader {
return component;
} catch (error) {
- throw new Error(
+ const wrapped = new Error(
`Failed to load component for route "${routePath}": ${error instanceof Error ? error.message : String(error)}`,
);
+ wrapped.cause = error;
+ throw wrapped;
}
}
@@ -894,13 +896,17 @@ export class SibuRouter {
// Handle redirects
if ("redirect" in route) {
const redirectPath = typeof route.redirect === "function" ? route.redirect(to) : route.redirect;
- // Warn about absolute URL redirects (potential open redirect vulnerability)
- if (typeof redirectPath === "string" && /^https?:\/\/|^\/\//i.test(redirectPath)) {
- console.warn(
- `[SibuJS Router] Redirect to absolute URL "${redirectPath}" detected. Use relative paths for safer redirects.`,
- );
+ // Refuse cross-origin / protocol-relative redirects by default —
+ // these are open-redirect vectors (CWE-601) when redirect targets
+ // are derived from untrusted route params.
+ if (typeof redirectPath === "string" && /^(https?:)?\/\//i.test(redirectPath)) {
+ if (typeof console !== "undefined") {
+ console.error(
+ `[SibuJS Router] Refusing absolute/protocol-relative redirect "${redirectPath}" — open-redirect risk.`,
+ );
+ }
+ throw new NavigationFailureError("aborted", from, to);
}
- // Security: refuse redirect targets with dangerous protocols.
if (typeof redirectPath === "string" && !isSafeNavigationTarget(redirectPath)) {
throw new NavigationFailureError("aborted", from, to);
}
@@ -1186,6 +1192,7 @@ export function createRouter(routesOrOptions: RouteDef[] | RouterOptions, option
}
globalRouter = new SibuRouter(routes, options);
+ ensureRouterPagehide();
return globalRouter;
}
@@ -1290,7 +1297,12 @@ export function Route(): Node {
const cleanupNodes = () => {
[currentNode, loadingNode, errorNode].forEach((node) => {
- if (node?.parentNode) {
+ if (!node) return;
+ // Run reactive disposers attached during route render BEFORE detaching.
+ // Without dispose(), every effect/binding/listener inside the route
+ // subtree leaks across navigations.
+ dispose(node);
+ if (node.parentNode) {
node.parentNode.removeChild(node);
}
});
@@ -1328,7 +1340,7 @@ export function Route(): Node {
}
};
- const showError = (error: Error) => {
+ const showError = (error: Error, routeDef?: RouteDef) => {
if (!anchor.parentNode) return;
cleanupNodes();
@@ -1338,6 +1350,23 @@ export function Route(): Node {
(errorNode as HTMLElement).setAttribute("role", "alert");
(errorNode as HTMLElement).setAttribute("aria-live", "assertive");
+ // Attach component source info so the app layer can display it.
+ // Extract the import path from the lazy function's source (e.g. import("./pages/Features.ts")).
+ if (routeDef && "component" in routeDef) {
+ const src = routeDef.component.toString();
+ const importMatch = src.match(/import\(["']([^"']+)["']\)/);
+ if (importMatch) {
+ (errorNode as HTMLElement).setAttribute("data-component-source", importMatch[1]);
+ }
+ if (routeDef.component.name) {
+ (errorNode as HTMLElement).setAttribute("data-component-name", routeDef.component.name);
+ }
+ }
+
+ // Stash the original error so the app layer can access the real
+ // stack trace and cause chain (DOM only carries text otherwise).
+ (errorNode as HTMLElement & { __routeError?: Error }).__routeError = error;
+
// SECURITY FIX: Use textContent instead of innerHTML to prevent XSS
const title = document.createElement("h3");
title.textContent = "Route Error";
@@ -1351,12 +1380,17 @@ export function Route(): Node {
retryButton.textContent = "Retry";
retryButton.className = "route-error-retry";
retryButton.type = "button";
- retryButton.addEventListener("click", () => {
+ const onRetryClick = () => {
if (globalRouter) {
globalRouter.clearErrorCache();
- update(); // Trigger retry
+ update();
}
- });
+ };
+ retryButton.addEventListener("click", onRetryClick);
+ // Pair the listener with a disposer so replacing the error node via
+ // cleanupNodes() -> dispose() actually releases the closure capturing
+ // globalRouter/update.
+ registerDisposer(retryButton, () => retryButton.removeEventListener("click", onRetryClick));
(errorNode as HTMLElement).appendChild(title);
(errorNode as HTMLElement).appendChild(message);
@@ -1404,7 +1438,11 @@ export function Route(): Node {
if ("redirect" in routeDef) {
const redirectPath = typeof routeDef.redirect === "function" ? routeDef.redirect(route) : routeDef.redirect;
- queueMicrotask(() => globalRouter?.navigate(redirectPath));
+ queueMicrotask(() => {
+ globalRouter?.navigate(redirectPath).catch((err) => {
+ if (typeof console !== "undefined") console.error("[router] redirect failed:", err);
+ });
+ });
return;
}
@@ -1431,7 +1469,7 @@ export function Route(): Node {
} catch (error) {
hideLoading();
console.error("[Route] Component error:", error);
- showError(error instanceof Error ? error : new Error(String(error)));
+ showError(error instanceof Error ? error : new Error(String(error)), routeDef);
}
}
} catch (error) {
@@ -1454,15 +1492,17 @@ export function Route(): Node {
await originalUpdate();
routeInitialized = true;
};
- track(wrappedUpdate);
+ const routeTeardown = track(wrappedUpdate);
if (!routeInitialized) {
queueMicrotask(() => {
if (!routeInitialized && anchor.parentNode) wrappedUpdate();
});
}
- // Register cleanup for destroyRouter
- routeCleanups.push(cleanupNodes);
+ routeCleanups.push(() => {
+ routeTeardown();
+ cleanupNodes();
+ });
return anchor;
}
@@ -1528,7 +1568,11 @@ export function KeepAliveRoute(options?: { max?: number; include?: string[] }):
const { route: routeDef } = match;
if ("redirect" in routeDef) {
const redirectPath = typeof routeDef.redirect === "function" ? routeDef.redirect(route) : routeDef.redirect;
- queueMicrotask(() => globalRouter?.navigate(redirectPath));
+ queueMicrotask(() => {
+ globalRouter?.navigate(redirectPath).catch((err) => {
+ if (typeof console !== "undefined") console.error("[router] redirect failed:", err);
+ });
+ });
return;
}
@@ -1618,7 +1662,7 @@ export function KeepAliveRoute(options?: { max?: number; include?: string[] }):
await update();
initialized = true;
};
- track(wrappedUpdate);
+ const kaTeardown = track(wrappedUpdate);
if (!initialized) {
queueMicrotask(() => {
if (!initialized && anchor.parentNode) wrappedUpdate();
@@ -1626,6 +1670,7 @@ export function KeepAliveRoute(options?: { max?: number; include?: string[] }):
}
routeCleanups.push(() => {
+ kaTeardown();
for (const node of cache.values()) {
dispose(node);
if (node.parentNode) node.parentNode.removeChild(node);
@@ -1722,14 +1767,18 @@ export function RouterLink(props: {
}
// Handle click for internal navigation
- link.addEventListener("click", (e) => {
- // Let browser handle external links, modified clicks, or when target is set
+ const onLinkClick = (e: MouseEvent) => {
if (target || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
return;
}
-
e.preventDefault();
- globalRouter?.navigate(to, { replace });
+ globalRouter?.navigate(to, { replace }).catch((err) => {
+ if (typeof console !== "undefined") console.error("[router] link navigate failed:", err);
+ });
+ };
+ link.addEventListener("click", onLinkClick);
+ registerDisposer(link, () => {
+ link.removeEventListener("click", onLinkClick);
});
return link;
@@ -1900,11 +1949,39 @@ export function destroyRouter(): void {
}
}
-// Cleanup on page unload
-if (typeof window !== "undefined") {
- window.addEventListener("beforeunload", () => {
- destroyRouter();
- });
+// Cleanup on page unload.
+// Use `pagehide` instead of `beforeunload` so the browser's back/forward
+// cache (bfcache) is not disabled. Only actually destroy when the page is
+// being discarded (persisted === false); if it may be restored from bfcache,
+// keep the router intact so a user returning via Back sees a working app.
+//
+// Previously registered at module top-level — that contradicts the package's
+// `sideEffects: false` claim and fires per-import in test/HMR loops. Now
+// installed lazily on first `createRouter()` call and de-duplicated via
+// the `_routerPagehideHandler` module variable.
+let _routerPagehideHandler: ((event: PageTransitionEvent) => void) | null = null;
+
+function ensureRouterPagehide(): void {
+ if (_routerPagehideHandler || typeof window === "undefined") return;
+ _routerPagehideHandler = (event: PageTransitionEvent) => {
+ if (event.persisted === false) {
+ destroyRouter();
+ }
+ };
+ window.addEventListener("pagehide", _routerPagehideHandler);
+}
+
+/**
+ * Remove the module-level `pagehide` listener. Intended for HMR and tests —
+ * normal apps never need to call this (the listener is page-lifetime).
+ *
+ * @internal
+ */
+export function __removeRouterPagehideHandler(): void {
+ if (_routerPagehideHandler && typeof window !== "undefined") {
+ window.removeEventListener("pagehide", _routerPagehideHandler);
+ _routerPagehideHandler = null;
+ }
}
// ============================================================================
@@ -1946,12 +2023,20 @@ export function Outlet(): Node {
}
};
- track(update);
+ const outletTeardown = track(update);
if (!anchor.parentNode) {
queueMicrotask(() => {
if (anchor.parentNode) update();
});
}
+ routeCleanups.push(() => {
+ outletTeardown();
+ if (currentNode) {
+ dispose(currentNode);
+ if (currentNode.parentNode) currentNode.parentNode.removeChild(currentNode);
+ currentNode = null;
+ }
+ });
return anchor;
}
diff --git a/src/plugins/routerSSR.ts b/src/plugins/routerSSR.ts
index c55bc19..610ca0d 100644
--- a/src/plugins/routerSSR.ts
+++ b/src/plugins/routerSSR.ts
@@ -527,11 +527,17 @@ export function hydrateRouter(routes: SSRRouteDef[], options?: { container?: HTM
const resolved = resolveServerRoute(serverState.path, routes);
if (resolved.component) {
// Import hydrate from ssr and attach bindings to existing DOM
- import("../platform/ssr").then(({ hydrate }) => {
- if (resolved.component) {
- hydrate(resolved.component, container);
- }
- });
+ import("../platform/ssr")
+ .then(({ hydrate }) => {
+ if (resolved.component) {
+ hydrate(resolved.component, container);
+ }
+ })
+ .catch((err) => {
+ if (typeof console !== "undefined") {
+ console.error("[SibuJS routerSSR] failed to load hydrate:", err);
+ }
+ });
}
}
}
diff --git a/src/plugins/startup.ts b/src/plugins/startup.ts
index 9d1b559..403145c 100644
--- a/src/plugins/startup.ts
+++ b/src/plugins/startup.ts
@@ -27,8 +27,14 @@ export function preloadCritical(
if (typeof document === "undefined") return;
for (const resource of resources) {
- // Skip if a preload link for this href already exists
- const existing = document.querySelector(`link[rel="preload"][href="${resource.href}"]`);
+ // Skip if a preload link for this href already exists.
+ // Use CSS.escape to safely embed arbitrary URLs in the attribute selector
+ // (hrefs may contain quotes, brackets, or other special characters).
+ const safeHref =
+ typeof CSS !== "undefined" && typeof CSS.escape === "function"
+ ? CSS.escape(resource.href)
+ : resource.href.replace(/["\\]/g, "\\$&");
+ const existing = document.querySelector(`link[rel="preload"][href="${safeHref}"]`);
if (existing) continue;
const link = document.createElement("link");
diff --git a/src/reactivity/batch.ts b/src/reactivity/batch.ts
index 4ec0218..9f29685 100644
--- a/src/reactivity/batch.ts
+++ b/src/reactivity/batch.ts
@@ -63,9 +63,14 @@ export function isBatching(): boolean {
* regardless of how many signals changed.
*/
function flushBatch(): void {
- for (const signal of pendingSignals) {
- queueSignalNotification(signal);
+ // Clear-before-drain + try/finally so a throwing subscriber during
+ // notification can't strand pendingSignals for the next batch.
+ try {
+ for (const signal of pendingSignals) {
+ queueSignalNotification(signal);
+ }
+ } finally {
+ pendingSignals.clear();
}
- pendingSignals.clear();
drainNotificationQueue();
}
diff --git a/src/reactivity/bindAttribute.ts b/src/reactivity/bindAttribute.ts
index e4bb24f..0f46a1e 100644
--- a/src/reactivity/bindAttribute.ts
+++ b/src/reactivity/bindAttribute.ts
@@ -4,6 +4,14 @@ import { track } from "./track";
const _isDev = isDev();
+/**
+ * Typed property setter — local helper to avoid `@ts-expect-error` at call
+ * sites when assigning to dynamic IDL properties (checked, value, etc.).
+ */
+function setProp(el: Element, key: string, val: unknown): void {
+ (el as unknown as Record)[key] = val;
+}
+
/**
* Is this attribute an `on*` event handler? Event-handler attributes are
* always a XSS vector when set via `setAttribute` (they evaluate the
@@ -52,8 +60,7 @@ export function bindAttribute(el: HTMLElement, attr: string, getter: () => unkno
// For IDL properties like checked/disabled/selected, set the DOM property
// directly — setAttribute only changes the default, not the current state.
if (attr in el && (attr === "checked" || attr === "disabled" || attr === "selected")) {
- // @ts-expect-error — dynamic property assignment
- el[attr] = value;
+ setProp(el, attr, value);
} else if (value) {
el.setAttribute(attr, "");
} else {
@@ -66,8 +73,7 @@ export function bindAttribute(el: HTMLElement, attr: string, getter: () => unkno
// If binding an input value or checked state, update the property
if ((attr === "value" || attr === "checked") && attr in el) {
- // @ts-expect-error
- el[attr] = attr === "checked" ? Boolean(value) : str;
+ setProp(el, attr, attr === "checked" ? Boolean(value) : str);
} else {
// URL attributes need protocol sanitization; others are safe via setAttribute
el.setAttribute(attr, isUrlAttribute(attr) ? sanitizeUrl(str) : str);
@@ -127,8 +133,7 @@ export function bindDynamic(
// If binding an input value or checked state, update the property
if ((name === "value" || name === "checked") && name in el) {
- // @ts-expect-error
- el[name] = name === "checked" ? Boolean(value) : str;
+ setProp(el, name, name === "checked" ? Boolean(value) : str);
} else {
el.setAttribute(name, isUrlAttribute(name) ? sanitizeUrl(str) : str);
}
diff --git a/src/reactivity/bindChildNode.ts b/src/reactivity/bindChildNode.ts
index b4b45d1..a0c9a3e 100644
--- a/src/reactivity/bindChildNode.ts
+++ b/src/reactivity/bindChildNode.ts
@@ -40,30 +40,38 @@ export function bindChildNode(placeholder: Comment, getter: () => NodeChild | No
return;
}
- // Build the new node list
+ // Build the new node list. Dedupe by reference so a getter returning
+ // `[sharedEl, sharedEl]` doesn't desync DOM (insertBefore moves the
+ // node, leaving only one in place but our list recording two).
let newNodes: Node[];
if (Array.isArray(result)) {
newNodes = [];
+ const seen = new Set();
for (let i = 0; i < result.length; i++) {
const item = result[i];
if (item == null || typeof item === "boolean") continue;
- newNodes.push(item instanceof Node ? item : document.createTextNode(String(item)));
+ const node = item instanceof Node ? item : document.createTextNode(String(item));
+ if (seen.has(node)) {
+ if (_isDev)
+ devWarn("bindChildNode: duplicate node reference in array — only the first occurrence is rendered.");
+ continue;
+ }
+ seen.add(node);
+ newNodes.push(node);
}
} else {
const node = result instanceof Node ? result : document.createTextNode(String(result));
newNodes = [node];
}
- // Build a set of nodes that will be reused (present in both old and new lists)
- const reused: Set | undefined = lastNodes.length > 0 && newNodes.length > 0 ? new Set() : undefined;
- if (reused) {
+ // Build a set of nodes that will be reused (present in both old and new lists).
+ // Use Set membership for O(n+m) instead of the previous O(n*m) nested scan.
+ let reused: Set | undefined;
+ if (lastNodes.length > 0 && newNodes.length > 0) {
+ const lastSet = new Set(lastNodes);
+ reused = new Set();
for (let i = 0; i < newNodes.length; i++) {
- for (let j = 0; j < lastNodes.length; j++) {
- if (newNodes[i] === lastNodes[j]) {
- reused.add(newNodes[i]);
- break;
- }
- }
+ if (lastSet.has(newNodes[i])) reused.add(newNodes[i]);
}
}
diff --git a/src/reactivity/bindTextNode.ts b/src/reactivity/bindTextNode.ts
index d61970d..e3f1345 100644
--- a/src/reactivity/bindTextNode.ts
+++ b/src/reactivity/bindTextNode.ts
@@ -1,3 +1,4 @@
+import { devWarn, isDev } from "../core/dev";
import { track } from "./track";
/**
@@ -15,7 +16,10 @@ export function bindTextNode(textNode: Text, getter: () => string | number): ()
let value: string | number;
try {
value = getter();
- } catch {
+ } catch (err) {
+ if (isDev()) {
+ devWarn(`[SibuJS] bindTextNode getter threw: ${err instanceof Error ? err.message : String(err)}`);
+ }
return;
}
textNode.textContent = String(value);
diff --git a/src/reactivity/concurrent.ts b/src/reactivity/concurrent.ts
index c2ee969..228d4fb 100644
--- a/src/reactivity/concurrent.ts
+++ b/src/reactivity/concurrent.ts
@@ -46,13 +46,15 @@ import { track } from "./track";
* each(() => heavyFilter(items, deferredQuery()), row => li(row.name));
* ```
*/
-export function defer(getter: () => T): () => T {
+export function defer(getter: () => T): (() => T) & { dispose: () => void } {
const [value, setValue] = signal(getter());
let pending = false;
+ let disposed = false;
let latest: T = value();
const flush = () => {
pending = false;
+ if (disposed) return;
setValue(latest);
};
@@ -68,12 +70,18 @@ export function defer(getter: () => T): () => T {
});
};
- track(() => {
+ const teardown = track(() => {
latest = getter();
schedule();
});
- return value;
+ const accessor = (() => value()) as (() => T) & { dispose: () => void };
+ accessor.dispose = () => {
+ if (disposed) return;
+ disposed = true;
+ teardown();
+ };
+ return accessor;
}
// ─── transition() ──────────────────────────────────────────────────────────
diff --git a/src/reactivity/track.ts b/src/reactivity/track.ts
index 64acea3..346a461 100644
--- a/src/reactivity/track.ts
+++ b/src/reactivity/track.ts
@@ -14,7 +14,6 @@ let currentSubscriber: Subscriber | null = null;
// Subscriber deps stored directly on subscriber as _deps property (avoids WeakMap).
// Signal subscribers stored in Set cached on signal as __s (avoids WeakMap in hot path).
-const signalSubscribers = new WeakMap>();
// Fast notification cache: store the Set reference directly on the signal
// for O(1) property access during notification (avoids WeakMap hash lookup).
@@ -27,6 +26,11 @@ let notifyDepth = 0;
const pendingQueue: Subscriber[] = [];
const pendingSet = new Set();
+// Reusable worklist for iterative propagateDirty — avoids recursion on
+// wide diamonds where a single signal fans out to many computeds each
+// with their own downstream chains.
+const propagateStack: ReactiveSignal[] = [];
+
/**
* Safely invoke a subscriber, catching errors to prevent one failing
* subscriber from killing remaining subscribers in the notification queue.
@@ -43,6 +47,27 @@ function safeInvoke(sub: Subscriber): void {
let suspendDepth = 0;
export let trackingSuspended = false;
+/**
+ * Re-run a subscriber body WITHOUT cleanup. New deps naturally subscribe via
+ * recordDependency; previously-subscribed deps stay subscribed (accepting
+ * mild over-subscription on conditional getters — same model as Vue/MobX/
+ * Preact Signals).
+ *
+ * Used by `derived` on every pull. Skips the O(N) Set.delete + Set.add
+ * cycle per dep that `track()`'s cleanup phase incurs, AND uses a simple
+ * save/restore of `currentSubscriber` instead of the stackTop push/pop —
+ * measurably faster on deep chains where this function runs per-level.
+ */
+export function retrack(effectFn: () => void, subscriber: Subscriber): void {
+ const prev = currentSubscriber;
+ currentSubscriber = subscriber;
+ try {
+ effectFn();
+ } finally {
+ currentSubscriber = prev;
+ }
+}
+
/**
* Track dependencies of an effect or computed subscriber.
* Returns a teardown function to remove all subscriptions.
@@ -150,7 +175,6 @@ export function recordDependency(signal: ReactiveSignal) {
let subs = (signal as SignalWithCache)[SUBS];
if (!subs) {
subs = new Set();
- signalSubscribers.set(signal, subs);
(signal as SignalWithCache)[SUBS] = subs;
}
subs.add(currentSubscriber);
@@ -181,8 +205,19 @@ export function queueSignalNotification(signal: ReactiveSignal): void {
/**
* Process all pending subscriber notifications.
+ *
+ * The drain cap prevents infinite cycles (effect A writes to signal that
+ * triggers effect A again forever). Apps with very large legitimate fan-out
+ * (e.g. >100k effects in a single batch) can raise it via `setMaxDrainIterations`.
*/
-const MAX_DRAIN_ITERATIONS = 1000;
+let maxDrainIterations = 100000;
+
+/** Raise/lower the per-batch drain iteration cap. Returns previous value. */
+export function setMaxDrainIterations(n: number): number {
+ const prev = maxDrainIterations;
+ if (Number.isFinite(n) && n > 0) maxDrainIterations = Math.floor(n);
+ return prev;
+}
export function drainNotificationQueue(): void {
if (notifyDepth > 0) return;
@@ -190,10 +225,10 @@ export function drainNotificationQueue(): void {
try {
let i = 0;
while (i < pendingQueue.length) {
- if (i >= MAX_DRAIN_ITERATIONS) {
+ if (i >= maxDrainIterations) {
if (typeof console !== "undefined") {
console.error(
- `[SibuJS] Notification queue exceeded ${MAX_DRAIN_ITERATIONS} iterations — ` +
+ `[SibuJS] Notification queue exceeded ${maxDrainIterations} iterations — ` +
"likely an effect that writes to a signal it reads. Breaking to prevent infinite loop.",
);
}
@@ -203,77 +238,79 @@ export function drainNotificationQueue(): void {
i++;
}
} finally {
- pendingQueue.length = 0;
- pendingSet.clear();
notifyDepth--;
+ if (notifyDepth === 0) {
+ pendingQueue.length = 0;
+ pendingSet.clear();
+ }
}
}
/**
* Iteratively propagate dirty flags through a computed chain.
- * markDirty (tagged _c) sets the dirty flag. _sig exposes the computed's
- * signal for walking downstream subscribers without recursive calls.
*
- * In the __f fast path (single-subscriber chains), sets _d directly on
- * the signal and evaluates _g inline — avoids megamorphic function calls
- * to markDirty and _re. Combined with suspendTracking, this eliminates
- * the pull-phase recursive call stack entirely.
+ * Marks each computed dirty and walks downstream subscribers via an explicit
+ * worklist (no recursion). markDirty (tagged _c) sets the dirty flag; _sig
+ * exposes the computed's signal for walking downstream. Does NOT eagerly
+ * evaluate — computedGetter uses track() on re-evaluation to re-register
+ * dependencies, which is essential for derived-of-derived chains (e.g.
+ * formula cells referencing other formula cells).
*
- * Eager evaluation is only applied to single-dep computeds (_deps.size === 1).
- * Multi-dep computeds (e.g. aggregators in wide diamonds) are marked dirty
- * and pulled lazily — avoids O(n²) re-evaluation when many deps update.
- */
-/**
- * Iteratively propagate dirty flags through a computed chain.
- *
- * Marks each computed dirty and walks downstream subscribers.
- * Does NOT eagerly evaluate — computedGetter uses track() on re-evaluation
- * to re-register dependencies, which is essential for derived-of-derived chains
- * (e.g. formula cells referencing other formula cells).
+ * In the __f fast path (single-subscriber chains), sets _d directly on the
+ * signal — avoids megamorphic function calls to markDirty. Multi-dep
+ * computeds are marked dirty and pulled lazily to avoid O(n²) re-evaluation
+ * when many deps update.
*/
function propagateDirty(sub: () => void): void {
sub(); // markDirty: sets dirty flag
- let sig: ReactiveSignal | undefined = (sub as any)._sig;
+ const rootSig: ReactiveSignal | undefined = (sub as any)._sig;
+ if (!rootSig) return;
+
+ // Iterative worklist using a reusable module-level stack.
+ // Each entry is a signal whose subscribers still need walking.
+ const stack = propagateStack;
+ const baseLen = stack.length;
+ stack.push(rootSig);
- while (sig) {
- // Fast path: single subscriber cached in __f (common in computed chains)
+ while (stack.length > baseLen) {
+ const sig = stack.pop() as ReactiveSignal;
+
+ // Fast path: single subscriber cached in __f
const first: any = (sig as any).__f;
if (first) {
if (first._c) {
const nSig: any = first._sig;
- // Mark dirty (no eager evaluation — let lazy pull + track() handle it)
- nSig._d = true;
- sig = nSig;
- continue;
- }
- // Single effect subscriber — queue it
- if (!pendingSet.has(first)) {
+ // Skip if already dirty — avoids redundant downstream walks on
+ // deep chains where the same signal is reached multiple times.
+ if (!nSig._d) {
+ nSig._d = true;
+ stack.push(nSig);
+ }
+ } else if (!pendingSet.has(first)) {
pendingSet.add(first);
pendingQueue.push(first);
}
- break;
+ continue;
}
// Multi-subscriber path (Set iteration)
const subs = (sig as SignalWithCache)[SUBS];
- if (!subs) break;
+ if (!subs) continue;
- let nextSig: ReactiveSignal | undefined;
for (const s of subs) {
if ((s as any)._c) {
- s(); // markDirty
- const nSig = (s as any)._sig;
- if (nSig && !nextSig) {
- nextSig = nSig;
- } else if (nSig) {
- propagateDirty(s);
+ const nSig: any = (s as any)._sig;
+ if (nSig && !nSig._d) {
+ nSig._d = true; // markDirty inline; skip self-call when already dirty
+ stack.push(nSig);
+ } else if (!nSig) {
+ s(); // computed without _sig — fall back to function call
}
} else if (!pendingSet.has(s)) {
pendingSet.add(s);
pendingQueue.push(s);
}
}
- sig = nextSig;
}
}
@@ -313,13 +350,24 @@ export function notifySubscribers(signal: ReactiveSignal) {
// Drain cascading effects
let i = 0;
while (i < pendingQueue.length) {
+ if (i >= maxDrainIterations) {
+ if (typeof console !== "undefined") {
+ console.error(
+ `[SibuJS] Notification queue exceeded ${maxDrainIterations} iterations — ` +
+ "likely an effect that writes to a signal it reads. Breaking to prevent infinite loop.",
+ );
+ }
+ break;
+ }
safeInvoke(pendingQueue[i]);
i++;
}
} finally {
- pendingQueue.length = 0;
- pendingSet.clear();
notifyDepth--;
+ if (notifyDepth === 0) {
+ pendingQueue.length = 0;
+ pendingSet.clear();
+ }
}
return;
}
@@ -343,25 +391,37 @@ export function notifySubscribers(signal: ReactiveSignal) {
// Outermost notification
notifyDepth++;
try {
- // Snapshot direct subscribers
+ // Snapshot direct subscribers, noting whether any are computed.
+ // If none are computed, skip Pass 1/2 machinery and invoke directly.
let directCount = 0;
+ let hasComputedSub = false;
for (const sub of subs) {
+ if ((sub as any)._c) hasComputedSub = true;
pendingQueue[directCount++] = sub;
}
- // Pass 1: Run computed subscribers for dirty propagation (iterative)
- for (let i = 0; i < directCount; i++) {
- if ((pendingQueue[i] as any)._c) {
- propagateDirty(pendingQueue[i]);
+ if (!hasComputedSub) {
+ // Fast path: pure effect fan-out — invoke directly, no Pass 2 bookkeeping.
+ for (let i = 0; i < directCount; i++) {
+ safeInvoke(pendingQueue[i]);
+ }
+ } else {
+ // Pass 1: Run computed subscribers for dirty propagation (iterative)
+ for (let i = 0; i < directCount; i++) {
+ if ((pendingQueue[i] as any)._c) {
+ propagateDirty(pendingQueue[i]);
+ }
}
- }
- // Pass 2: Run direct effect subscribers, skip those already queued
- // by cascading during Pass 1 (prevents diamond double-execution)
- for (let i = 0; i < directCount; i++) {
- if (!(pendingQueue[i] as any)._c) {
- if (!pendingSet.has(pendingQueue[i])) {
- safeInvoke(pendingQueue[i]);
+ // Pass 2: Run direct effect subscribers, skip those already queued
+ // by cascading during Pass 1 (prevents diamond double-execution).
+ // Add sub to pendingSet BEFORE invoking so any re-entrant cascade
+ // cannot double-execute the same effect.
+ for (let i = 0; i < directCount; i++) {
+ const sub = pendingQueue[i];
+ if (!(sub as any)._c && !pendingSet.has(sub)) {
+ pendingSet.add(sub);
+ safeInvoke(sub);
}
}
}
@@ -369,13 +429,24 @@ export function notifySubscribers(signal: ReactiveSignal) {
// Pass 3: Drain cascading effects queued during propagation
let i = directCount;
while (i < pendingQueue.length) {
+ if (i - directCount >= maxDrainIterations) {
+ if (typeof console !== "undefined") {
+ console.error(
+ `[SibuJS] Notification queue exceeded ${maxDrainIterations} iterations — ` +
+ "likely an effect that writes to a signal it reads. Breaking to prevent infinite loop.",
+ );
+ }
+ break;
+ }
safeInvoke(pendingQueue[i]);
i++;
}
} finally {
- pendingQueue.length = 0;
- pendingSet.clear();
notifyDepth--;
+ if (notifyDepth === 0) {
+ pendingQueue.length = 0;
+ pendingSet.clear();
+ }
}
}
@@ -392,7 +463,9 @@ function cleanup(subscriber: Subscriber) {
if (subs) {
subs.delete(subscriber);
if ((singleDep as any).__f === subscriber) {
- (singleDep as any).__f = undefined;
+ (singleDep as any).__f = subs.size === 1 ? subs.values().next().value : undefined;
+ } else if (subs.size === 1 && (singleDep as any).__f === undefined) {
+ (singleDep as any).__f = subs.values().next().value;
}
}
sub._dep = undefined;
@@ -408,7 +481,9 @@ function cleanup(subscriber: Subscriber) {
if (subs) {
subs.delete(subscriber);
if ((signal as any).__f === subscriber) {
- (signal as any).__f = undefined;
+ (signal as any).__f = subs.size === 1 ? subs.values().next().value : undefined;
+ } else if (subs.size === 1 && (signal as any).__f === undefined) {
+ (signal as any).__f = subs.values().next().value;
}
}
}
diff --git a/src/testing/e2e.ts b/src/testing/e2e.ts
index 76f4f8f..a867fbe 100644
--- a/src/testing/e2e.ts
+++ b/src/testing/e2e.ts
@@ -26,8 +26,9 @@ export interface MockRoute {
* Create an HTTP mock server that intercepts fetch calls.
* Useful for testing components that make API calls.
*/
-export function createHttpMock(routes: MockRoute[] = []) {
+export function createHttpMock(routes: MockRoute[] = [], options: { afterEach?: (cleanup: () => void) => void } = {}) {
const originalFetch = globalThis.fetch;
+ const hadOriginalFetch = Object.hasOwn(globalThis, "fetch");
const requestLog: Array<{ url: string; method: string; body: unknown; timestamp: number }> = [];
const mockRoutes = [...routes];
@@ -52,33 +53,50 @@ export function createHttpMock(routes: MockRoute[] = []) {
return new Response(JSON.stringify({ error: "Not mocked" }), { status: 404 });
}
+ // Wrap response resolution in try/finally so a user-supplied response
+ // callback throwing does not leave the mock in a partially-applied state.
let mockResponse: MockResponse;
- if (typeof route.response === "function") {
- mockResponse = await route.response({ url, method, body, headers: new Headers(init?.headers) });
- } else {
- mockResponse = route.response;
- }
+ try {
+ if (typeof route.response === "function") {
+ mockResponse = await route.response({ url, method, body, headers: new Headers(init?.headers) });
+ } else {
+ mockResponse = route.response;
+ }
- if (mockResponse.delay) {
- await new Promise((r) => setTimeout(r, mockResponse.delay));
+ if (mockResponse.delay) {
+ await new Promise((r) => setTimeout(r, mockResponse.delay));
+ }
+
+ return new Response(
+ typeof mockResponse.body === "string" ? mockResponse.body : JSON.stringify(mockResponse.body),
+ {
+ status: mockResponse.status || 200,
+ statusText: mockResponse.statusText || "OK",
+ headers: mockResponse.headers,
+ },
+ );
+ } catch (err) {
+ // Surface response-handler errors as a synthetic 500 so tests see them
+ // instead of an unhandled rejection leaking past the mock boundary.
+ return new Response(JSON.stringify({ error: String(err) }), { status: 500 });
}
+ };
- return new Response(typeof mockResponse.body === "string" ? mockResponse.body : JSON.stringify(mockResponse.body), {
- status: mockResponse.status || 200,
- statusText: mockResponse.statusText || "OK",
- headers: mockResponse.headers,
- });
+ const restore = (): void => {
+ if (hadOriginalFetch) {
+ globalThis.fetch = originalFetch;
+ } else {
+ delete (globalThis as unknown as Record).fetch;
+ }
};
- return {
+ const api = {
/** Install the mock (replace global fetch) */
install(): void {
globalThis.fetch = mockFetch as typeof fetch;
},
/** Restore original fetch */
- restore(): void {
- globalThis.fetch = originalFetch;
- },
+ restore,
/** Add a mock route */
addRoute(route: MockRoute): void {
mockRoutes.push(route);
@@ -110,6 +128,13 @@ export function createHttpMock(routes: MockRoute[] = []) {
return requestLog.filter((r) => r.url.includes(url) && r.method.toUpperCase() === method.toUpperCase()).length;
},
};
+
+ // Optional auto-restore via a caller-supplied afterEach hook (e.g. vitest's).
+ if (typeof options.afterEach === "function") {
+ options.afterEach(() => api.restore());
+ }
+
+ return api;
}
// ─── Timer Mock ─────────────────────────────────────────────────────────────
@@ -118,19 +143,30 @@ export function createHttpMock(routes: MockRoute[] = []) {
* Create a fake timer system for testing time-dependent code.
* Mocks setTimeout, setInterval, requestAnimationFrame.
*/
-export function createTimerMock() {
- const originalSetTimeout = globalThis.setTimeout;
- const originalSetInterval = globalThis.setInterval;
- const originalClearTimeout = globalThis.clearTimeout;
- const originalClearInterval = globalThis.clearInterval;
- const originalRAF = typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : undefined;
- const originalCAF = typeof cancelAnimationFrame !== "undefined" ? cancelAnimationFrame : undefined;
+export function createTimerMock(options: { afterEach?: (cleanup: () => void) => void } = {}) {
+ const g = globalThis as unknown as Record;
+
+ // Capture originals alongside a "was it defined?" flag so restore() can
+ // properly `delete` keys that were never present in the first place,
+ // rather than leaving `undefined` stubs behind.
+ const snapshot = (key: string) => ({
+ had: Object.hasOwn(globalThis, key),
+ value: g[key],
+ });
+ const saved = {
+ setTimeout: snapshot("setTimeout"),
+ setInterval: snapshot("setInterval"),
+ clearTimeout: snapshot("clearTimeout"),
+ clearInterval: snapshot("clearInterval"),
+ requestAnimationFrame: snapshot("requestAnimationFrame"),
+ cancelAnimationFrame: snapshot("cancelAnimationFrame"),
+ };
let currentTime = 0;
let nextId = 1;
const timers: Array<{ id: number; callback: () => void; time: number; interval?: number }> = [];
- return {
+ const api = {
install(): void {
currentTime = 0;
(globalThis as unknown as Record).setTimeout = (cb: () => void, delay = 0) => {
@@ -162,12 +198,16 @@ export function createTimerMock() {
};
},
restore(): void {
- globalThis.setTimeout = originalSetTimeout;
- globalThis.setInterval = originalSetInterval;
- globalThis.clearTimeout = originalClearTimeout;
- globalThis.clearInterval = originalClearInterval;
- if (originalRAF) (globalThis as unknown as Record).requestAnimationFrame = originalRAF;
- if (originalCAF) (globalThis as unknown as Record).cancelAnimationFrame = originalCAF;
+ for (const [key, snap] of Object.entries(saved)) {
+ if (snap.had) {
+ g[key] = snap.value;
+ } else {
+ // When an original was never defined (e.g. rAF in non-browser envs),
+ // `delete` the key rather than leaving an `undefined` stub — callers
+ // typically guard via `typeof requestAnimationFrame !== "undefined"`.
+ delete g[key];
+ }
+ }
timers.length = 0;
},
/** Advance time by a given number of ms, running any timers that fire */
@@ -214,6 +254,12 @@ export function createTimerMock() {
return timers.length;
},
};
+
+ if (typeof options.afterEach === "function") {
+ options.afterEach(() => api.restore());
+ }
+
+ return api;
}
// ─── DOM Snapshot Testing ───────────────────────────────────────────────────
diff --git a/src/testing/index.ts b/src/testing/index.ts
index ba01bda..7b2911c 100644
--- a/src/testing/index.ts
+++ b/src/testing/index.ts
@@ -2,8 +2,44 @@
// TESTING UTILITIES
// ============================================================================
+import { dispose } from "../core/rendering/dispose";
+
+/**
+ * Escape a value for safe embedding in a CSS attribute selector.
+ * Uses the native `CSS.escape` when available (jsdom/browsers) and
+ * falls back to a conservative hex-escape otherwise.
+ */
+function escapeSelector(value: string): string {
+ const g = globalThis as unknown as { CSS?: { escape?: (v: string) => string } };
+ if (g.CSS && typeof g.CSS.escape === "function") return g.CSS.escape(value);
+ return value.replace(/[^\w-]/g, (m) => `\\${m.charCodeAt(0).toString(16)} `);
+}
+
+// Tracks containers produced by `render()` so tests can bulk-clean via
+// `unmountAll()` when individual `unmount()` calls were missed.
+const _renderedContainers = new Set();
+
+/**
+ * Unmount every container still alive from prior `render()` calls.
+ * Safe to call from an `afterEach` hook to guarantee teardown.
+ */
+export function unmountAll(): void {
+ for (const container of _renderedContainers) {
+ // Run reactive disposers before clearing markup so effects/listeners
+ // registered during render don't leak across tests.
+ for (const child of Array.from(container.childNodes)) dispose(child);
+ container.replaceChildren();
+ if (container.parentNode) container.parentNode.removeChild(container);
+ }
+ _renderedContainers.clear();
+}
+
/**
* render mounts a component into a test container and returns helpers.
+ *
+ * The caller is responsible for calling `unmount()` (typically from an
+ * `afterEach` hook). For bulk teardown across many renders, call
+ * `unmountAll()` instead — every live container is tracked internally.
*/
export function render(component: () => HTMLElement): {
container: HTMLElement;
@@ -16,6 +52,7 @@ export function render(component: () => HTMLElement): {
} {
const container = document.createElement("div");
document.body.appendChild(container);
+ _renderedContainers.add(container);
const element = component();
container.appendChild(element);
@@ -35,7 +72,7 @@ export function render(component: () => HTMLElement): {
}
function getByTestId(testId: string): HTMLElement | null {
- return container.querySelector(`[data-testid="${testId}"]`);
+ return container.querySelector(`[data-testid="${escapeSelector(testId)}"]`);
}
function getByRole(role: string): HTMLElement | null {
@@ -47,8 +84,10 @@ export function render(component: () => HTMLElement): {
}
function unmount(): void {
- container.innerHTML = "";
+ for (const child of Array.from(container.childNodes)) dispose(child);
+ container.replaceChildren();
if (container.parentNode) container.parentNode.removeChild(container);
+ _renderedContainers.delete(container);
}
return { container, element, getByText, getByTestId, getByRole, queryAll, unmount };
diff --git a/src/testing/queries.ts b/src/testing/queries.ts
index 8ad5515..f58f8a4 100644
--- a/src/testing/queries.ts
+++ b/src/testing/queries.ts
@@ -147,23 +147,33 @@ export function waitForSignal(
const timeoutMs = options.timeout ?? 1000;
return new Promise((resolve, reject) => {
let resolved = false;
- const timer = setTimeout(() => {
- if (!resolved) {
- resolved = true;
+ let timer: ReturnType | undefined;
+
+ const finish = (fn: () => void) => {
+ if (resolved) return;
+ resolved = true;
+ // Always clear the timer — cheap no-op if it already fired, and
+ // guarantees we never leak a pending handle on any resolve path.
+ if (timer !== undefined) clearTimeout(timer);
+ fn();
+ };
+
+ timer = setTimeout(() => {
+ finish(() => {
teardown();
reject(new Error(`waitForSignal: predicate did not match within ${timeoutMs}ms`));
- }
+ });
}, timeoutMs);
const teardown = effect(() => {
if (resolved) return;
const value = getter();
if (predicate(value)) {
- resolved = true;
- clearTimeout(timer);
- // Defer teardown so the current effect pass completes cleanly
- queueMicrotask(() => teardown());
- resolve(value);
+ finish(() => {
+ // Defer teardown so the current effect pass completes cleanly
+ queueMicrotask(() => teardown());
+ resolve(value);
+ });
}
});
});
diff --git a/src/ui/a11y.ts b/src/ui/a11y.ts
index ac1c455..e92f64e 100644
--- a/src/ui/a11y.ts
+++ b/src/ui/a11y.ts
@@ -31,15 +31,33 @@ export function focus(): {
isFocused: () => boolean;
focus: () => void;
blur: () => void;
- bind: (element: HTMLElement) => void;
+ bind: (element: HTMLElement) => () => void;
} {
const [isFocused, setIsFocused] = signal(false);
let currentElement: HTMLElement | null = null;
- function bind(element: HTMLElement): void {
+ /**
+ * Attach focus/blur listeners to the given element. Returns a `dispose()`
+ * function that removes the listeners. The returned disposer is also
+ * registered with the element so SPA unmounts clean it up automatically.
+ */
+ function bind(element: HTMLElement): () => void {
currentElement = element;
- element.addEventListener("focus", () => setIsFocused(true));
- element.addEventListener("blur", () => setIsFocused(false));
+ const onFocus = () => setIsFocused(true);
+ const onBlur = () => setIsFocused(false);
+ element.addEventListener("focus", onFocus);
+ element.addEventListener("blur", onBlur);
+
+ let disposed = false;
+ const dispose = () => {
+ if (disposed) return;
+ disposed = true;
+ element.removeEventListener("focus", onFocus);
+ element.removeEventListener("blur", onBlur);
+ if (currentElement === element) currentElement = null;
+ };
+ registerDisposer(element, dispose);
+ return dispose;
}
function focus(): void {
@@ -67,20 +85,50 @@ export function FocusTrap(
const previouslyFocused = document.activeElement as HTMLElement;
- container.addEventListener("keydown", (e: KeyboardEvent) => {
- if (e.key !== "Tab") return;
+ // Base selector — narrowed further by tree-walker filters below.
+ const FOCUSABLE_SELECTOR =
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), [contenteditable]';
- const focusable = container.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
- );
+ function isEffectivelyVisible(el: HTMLElement): boolean {
+ // Walk ancestors to detect hidden/inert/aria-hidden/display:none.
+ let node: HTMLElement | null = el;
+ while (node) {
+ if (node.hasAttribute("inert")) return false;
+ if (node.getAttribute("aria-hidden") === "true") return false;
+ if (node.hidden) return false;
+ // `offsetParent` is null for display:none (except on / fixed).
+ // Use getClientRects as a secondary check.
+ node = node.parentElement;
+ }
+ if (el.offsetParent === null && el.getClientRects().length === 0) return false;
+ return true;
+ }
+
+ function getFocusable(): HTMLElement[] {
+ const raw = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
+ const out: HTMLElement[] = [];
+ for (const el of raw) {
+ if (el.hasAttribute("disabled")) continue;
+ if (el.getAttribute("aria-hidden") === "true") continue;
+ if (el.hasAttribute("inert")) continue;
+ // `contenteditable="false"` should not be focusable via this path.
+ const ce = el.getAttribute("contenteditable");
+ if (ce !== null && ce === "false") continue;
+ if (!isEffectivelyVisible(el)) continue;
+ out.push(el);
+ }
+ return out;
+ }
+
+ const onTrapKeydown = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+ const focusable = getFocusable();
if (focusable.length === 0) {
e.preventDefault();
return;
}
-
const first = focusable[0];
const last = focusable[focusable.length - 1];
-
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
@@ -92,23 +140,24 @@ export function FocusTrap(
first.focus();
}
}
- });
+ };
+ container.addEventListener("keydown", onTrapKeydown);
if (options.autoFocus !== false) {
queueMicrotask(() => {
- const first = container.querySelector(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
- );
+ const first = getFocusable()[0];
first?.focus();
});
}
- // Restore focus on removal — observe document.body with subtree so ancestor
- // removal (not just direct parent removal) is detected.
+ // Restore focus on removal. The observer is scoped to the trap container
+ // itself (subtree so nested mutations fire the callback) — the `isConnected`
+ // check below handles container-level removal via the disposer path.
let trapObserver: MutationObserver | null = null;
function restoreFocusAndCleanup(): void {
if (options.restoreFocus !== false) previouslyFocused?.focus();
+ container.removeEventListener("keydown", onTrapKeydown);
if (trapObserver) {
trapObserver.disconnect();
trapObserver = null;
@@ -124,7 +173,7 @@ export function FocusTrap(
queueMicrotask(() => {
if (container.isConnected) {
- trapObserver!.observe(document.body, { childList: true, subtree: true });
+ trapObserver!.observe(container, { childList: true, subtree: true });
}
});
}
@@ -138,7 +187,12 @@ export function FocusTrap(
/**
* hotkey registers a keyboard shortcut handler.
- * Returns a cleanup function.
+ *
+ * Returns a `dispose()` cleanup function — call it from the owning
+ * component's unmount path to remove the `keydown` listener from
+ * `document`. The returned function is idempotent only via the
+ * browser's default `removeEventListener` semantics, so callers
+ * should invoke it exactly once.
*
* Supports two calling styles:
* - String combo: hotkey("ctrl+shift+z", handler)
@@ -193,10 +247,23 @@ export function hotkey(
/**
* announce creates a screen reader announcement using ARIA live regions.
+ *
+ * Rapid successive calls are serialized through an internal per-priority
+ * queue so that each message has a chance to be read before the next one
+ * overwrites the live region.
*/
-export function announce(message: string, priority: "polite" | "assertive" = "polite"): void {
- let region = document.getElementById(`sibu-announce-${priority}`);
+const announceQueues: Record<"polite" | "assertive", string[]> = {
+ polite: [],
+ assertive: [],
+};
+const announceDraining: Record<"polite" | "assertive", boolean> = {
+ polite: false,
+ assertive: false,
+};
+const ANNOUNCE_INTERVAL_MS = 150;
+function ensureLiveRegion(priority: "polite" | "assertive"): HTMLElement {
+ let region = document.getElementById(`sibu-announce-${priority}`);
if (!region) {
region = document.createElement("div");
region.id = `sibu-announce-${priority}`;
@@ -207,10 +274,34 @@ export function announce(message: string, priority: "polite" | "assertive" = "po
"position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;";
document.body.appendChild(region);
}
+ return region;
+}
+
+function drainAnnounceQueue(priority: "polite" | "assertive"): void {
+ if (announceDraining[priority]) return;
+ const queue = announceQueues[priority];
+ if (queue.length === 0) return;
+ announceDraining[priority] = true;
+
+ const region = ensureLiveRegion(priority);
+ const next = queue.shift() as string;
- // Clear and set to trigger announcement
region.textContent = "";
requestAnimationFrame(() => {
- if (region) region.textContent = message;
+ if (!region.isConnected) {
+ announceDraining[priority] = false;
+ return;
+ }
+ region.textContent = next;
+ setTimeout(() => {
+ announceDraining[priority] = false;
+ drainAnnounceQueue(priority);
+ }, ANNOUNCE_INTERVAL_MS);
});
}
+
+export function announce(message: string, priority: "polite" | "assertive" = "polite"): void {
+ if (typeof document === "undefined") return;
+ announceQueues[priority].push(message);
+ drainAnnounceQueue(priority);
+}
diff --git a/src/ui/a11yPrimitives.ts b/src/ui/a11yPrimitives.ts
index 8863f1e..2d596d3 100644
--- a/src/ui/a11yPrimitives.ts
+++ b/src/ui/a11yPrimitives.ts
@@ -188,24 +188,27 @@ export function createListbox(container: HTMLElement, options: ListboxOptions =
}
function select(value: string): void {
+ // Snapshot the previous selection once — reading the signal a second
+ // time after `setSelectedValue()` would mix DOM reconciliation into the
+ // signal read path and can race if any subscribers mutate state.
+ const previous = selectedValue();
+ let nextSelectedSet: Set;
if (multiple) {
- const current = selectedValue();
- const set = new Set((current ?? "").split(",").filter(Boolean));
- if (set.has(value)) set.delete(value);
- else set.add(value);
- setSelectedValue(Array.from(set).join(","));
+ nextSelectedSet = new Set((previous ?? "").split(",").filter(Boolean));
+ if (nextSelectedSet.has(value)) nextSelectedSet.delete(value);
+ else nextSelectedSet.add(value);
+ setSelectedValue(Array.from(nextSelectedSet).join(","));
} else {
+ nextSelectedSet = new Set([value]);
setSelectedValue(value);
}
options.onSelect?.(value);
- // Reflect `aria-selected` on each option
+ // Reflect `aria-selected` on each option using the computed next set.
const opts = getOptions();
- const current = selectedValue();
- const selected = new Set((current ?? "").split(",").filter(Boolean));
for (const opt of opts) {
const ov = opt.dataset.value ?? "";
- opt.setAttribute("aria-selected", selected.has(ov) ? "true" : "false");
+ opt.setAttribute("aria-selected", nextSelectedSet.has(ov) ? "true" : "false");
}
}
diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts
index 0090a81..bea0b7e 100644
--- a/src/ui/dialog.ts
+++ b/src/ui/dialog.ts
@@ -3,9 +3,57 @@ 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.
+ * A module-level stack tracks open dialogs across the app so that pressing
+ * Escape only closes the top (most recently opened) dialog — nested dialog
+ * stacks (e.g. a confirm modal opened on top of a settings sheet) behave
+ * intuitively without every owner having to wire its own listener.
+ *
+ * Call `dispose()` when the owning component unmounts to ensure the dialog
+ * is removed from the stack even if it is still open.
+ */
+
+type DialogEntry = {
+ close: () => void;
+};
+
+const dialogStack: DialogEntry[] = [];
+let globalListenerAttached = false;
+
+/**
+ * Test-only helper to reset the module-level stack between specs. Client-only:
+ * in SSR dialog() is never meaningfully invoked. In production the stack is
+ * bounded by open dialog count and cleaned via removeFromStack/dispose.
+ *
+ * @internal
*/
+export function __resetDialogStack(): void {
+ while (dialogStack.length > 0) dialogStack.pop();
+ if (typeof window !== "undefined" && globalListenerAttached) {
+ window.removeEventListener("keydown", handleGlobalKeydown);
+ globalListenerAttached = false;
+ }
+}
+
+function handleGlobalKeydown(event: KeyboardEvent): void {
+ if (event.key !== "Escape") return;
+ const top = dialogStack[dialogStack.length - 1];
+ if (top) top.close();
+}
+
+function ensureGlobalListener(): void {
+ if (typeof window === "undefined" || globalListenerAttached) return;
+ window.addEventListener("keydown", handleGlobalKeydown);
+ globalListenerAttached = true;
+}
+
+function removeGlobalListenerIfIdle(): void {
+ if (typeof window === "undefined") return;
+ if (!globalListenerAttached) return;
+ if (dialogStack.length > 0) return;
+ window.removeEventListener("keydown", handleGlobalKeydown);
+ globalListenerAttached = false;
+}
+
export function dialog(): {
open: () => void;
close: () => void;
@@ -14,36 +62,35 @@ export function dialog(): {
dispose: () => void;
} {
const [isOpen, setIsOpen] = signal(false);
- let listenerAttached = false;
+ const entry: DialogEntry = { close: () => close() };
- function handleKeydown(event: KeyboardEvent): void {
- if (event.key === "Escape") {
- close();
- }
+ function pushOnStack(): void {
+ // Avoid duplicate pushes if open() is called twice.
+ if (dialogStack.indexOf(entry) !== -1) return;
+ dialogStack.push(entry);
+ ensureGlobalListener();
}
- function attachListener(): void {
- if (typeof window !== "undefined" && !listenerAttached) {
- window.addEventListener("keydown", handleKeydown);
- listenerAttached = true;
- }
- }
-
- function detachListener(): void {
- if (typeof window !== "undefined" && listenerAttached) {
- window.removeEventListener("keydown", handleKeydown);
- listenerAttached = false;
- }
+ function removeFromStack(): void {
+ const idx = dialogStack.indexOf(entry);
+ if (idx !== -1) dialogStack.splice(idx, 1);
+ removeGlobalListenerIfIdle();
}
function open(): void {
+ if (isOpen()) return;
setIsOpen(true);
- attachListener();
+ pushOnStack();
}
function close(): void {
+ if (!isOpen()) {
+ // Still make sure we're off the stack.
+ removeFromStack();
+ return;
+ }
setIsOpen(false);
- detachListener();
+ removeFromStack();
}
function toggle(): void {
@@ -52,7 +99,7 @@ export function dialog(): {
}
function dispose(): void {
- detachListener();
+ removeFromStack();
setIsOpen(false);
}
diff --git a/src/ui/form.ts b/src/ui/form.ts
index 7ae0777..c40f642 100644
--- a/src/ui/form.ts
+++ b/src/ui/form.ts
@@ -203,9 +203,21 @@ export function form>(config: FormConfig):
return null;
});
+ // Wrap the setter so editing the field clears any prior manual error
+ // (e.g. server-side "email already taken" must not stick after edit).
+ const wrappedSet = (next: T[keyof T]) => {
+ setValue(next);
+ setManualErrors((prev) => {
+ if (!((name as string) in prev) || prev[name as string] == null) return prev;
+ const copy = { ...prev };
+ copy[name as string] = null;
+ return copy;
+ });
+ };
+
fieldMap[name] = {
value,
- set: setValue,
+ set: wrappedSet,
error,
touched: isTouched,
touch: () => setTouched(true),
diff --git a/src/ui/inputMask.ts b/src/ui/inputMask.ts
index 3d040f9..8b4cab8 100644
--- a/src/ui/inputMask.ts
+++ b/src/ui/inputMask.ts
@@ -18,7 +18,7 @@ export interface MaskOptions {
export function inputMask(options: MaskOptions): {
value: () => string;
rawValue: () => string;
- bind: (input: HTMLInputElement) => void;
+ bind: (input: HTMLInputElement) => () => void;
} {
const placeholder = options.placeholder || "_";
const [value, setValue] = signal("");
@@ -95,8 +95,8 @@ export function inputMask(options: MaskOptions): {
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", () => {
+ function bind(input: HTMLInputElement): () => void {
+ const onInput = () => {
const cursorBefore = input.selectionStart ?? input.value.length;
const oldValue = input.value;
const raw = oldValue.replace(stripRegex, "");
@@ -105,8 +105,6 @@ export function inputMask(options: MaskOptions): {
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++;
@@ -123,9 +121,9 @@ export function inputMask(options: MaskOptions): {
}
}
input.setSelectionRange(newCursor, newCursor);
- });
+ };
- input.addEventListener("focus", () => {
+ const onFocus = () => {
if (!input.value) {
const display = options.pattern
.replace(/9/g, placeholder)
@@ -133,7 +131,15 @@ export function inputMask(options: MaskOptions): {
.replace(/\*/g, placeholder);
input.placeholder = display;
}
- });
+ };
+
+ input.addEventListener("input", onInput);
+ input.addEventListener("focus", onFocus);
+
+ return () => {
+ input.removeEventListener("input", onInput);
+ input.removeEventListener("focus", onFocus);
+ };
}
return { value, rawValue, bind };
diff --git a/src/ui/intersection.ts b/src/ui/intersection.ts
index becce6d..9240627 100644
--- a/src/ui/intersection.ts
+++ b/src/ui/intersection.ts
@@ -21,6 +21,7 @@ export function intersection(options?: IntersectionObserverInit): IntersectionRe
let currentElement: HTMLElement | null = null;
function observe(element: HTMLElement): void {
+ if (typeof IntersectionObserver === "undefined") return;
unobserve();
currentElement = element;
@@ -57,6 +58,10 @@ export function intersection(options?: IntersectionObserverInit): IntersectionRe
* Calls the loader function when element becomes visible.
*/
export function lazyLoad(element: HTMLElement, loader: () => void, options?: IntersectionObserverInit): () => void {
+ if (typeof IntersectionObserver === "undefined") {
+ loader();
+ return () => {};
+ }
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
diff --git a/src/ui/scrollLock.ts b/src/ui/scrollLock.ts
index 3fab9e0..1602a9b 100644
--- a/src/ui/scrollLock.ts
+++ b/src/ui/scrollLock.ts
@@ -25,6 +25,15 @@ export interface ScrollLockHandle {
unlock: () => void;
}
+// Module-level counter + snapshot. The snapshot is taken EXACTLY ONCE on the
+// 0 → 1 transition and restored on the N → 0 transition; nested locks never
+// re-snapshot. Concurrent lock()/unlock() from multiple handles is safe as
+// long as each handle obeys its own `owned` flag (enforced below).
+//
+// Note: we do NOT observe external mutations to `document.body.style` while
+// the lock is active — if application code assigns `body.style.overflow`
+// during a lock, that value will be clobbered on unlock. Keep modal state
+// in scrollLock handles, not direct style writes.
let lockCount = 0;
let savedOverflow: string | null = null;
let savedPaddingRight: string | null = null;
@@ -36,6 +45,8 @@ export function scrollLock(): ScrollLockHandle {
if (owned) return;
owned = true;
lockCount++;
+ // Only the 0 → 1 transition snapshots and mutates the body; nested locks
+ // increment the counter and otherwise no-op.
if (lockCount !== 1 || typeof document === "undefined") return;
const body = document.body;
@@ -52,6 +63,7 @@ export function scrollLock(): ScrollLockHandle {
if (!owned) return;
owned = false;
lockCount = Math.max(0, lockCount - 1);
+ // Only the N → 0 transition restores the snapshot.
if (lockCount !== 0 || typeof document === "undefined") return;
const body = document.body;
diff --git a/src/ui/socket.ts b/src/ui/socket.ts
index c814394..d95b835 100644
--- a/src/ui/socket.ts
+++ b/src/ui/socket.ts
@@ -44,7 +44,9 @@ export function socket(
} {
const autoReconnect = options?.autoReconnect ?? false;
const reconnectDelay = options?.reconnectDelay ?? 1000;
- const maxReconnects = options?.maxReconnects ?? Infinity;
+ // Bound default to 10 attempts so a permanently broken URL doesn't hammer
+ // the server forever. Callers can pass Infinity if that behavior is wanted.
+ const maxReconnects = options?.maxReconnects ?? 10;
const heartbeat = options?.heartbeat;
const protocols = options?.protocols;
@@ -88,13 +90,22 @@ export function socket(
ws.onclose = () => {
setStatus("closed");
stopHeartbeat();
- if (autoReconnect && !disposed && !manuallyClosed && reconnectCount < maxReconnects) {
+ const wasManual = manuallyClosed;
+ // Reset BEFORE scheduling so close() during the timer window correctly
+ // re-sets manuallyClosed and the scheduled reconnect short-circuits.
+ manuallyClosed = false;
+ if (autoReconnect && !disposed && !wasManual && reconnectCount < maxReconnects) {
+ // Exponential backoff with jitter, capped at 30s.
+ const cap = 30_000;
+ const delay = Math.min(cap, reconnectDelay * 2 ** reconnectCount);
+ const jittered = delay * (0.5 + Math.random() * 0.5);
reconnectCount++;
reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ if (disposed || manuallyClosed) return;
connect();
- }, reconnectDelay);
+ }, jittered);
}
- manuallyClosed = false;
};
ws.onerror = () => {
diff --git a/src/ui/springSignal.ts b/src/ui/springSignal.ts
index 386b30d..867fefe 100644
--- a/src/ui/springSignal.ts
+++ b/src/ui/springSignal.ts
@@ -58,18 +58,32 @@ export function springSignal(
let velocity = 0;
let target = initial;
let rafId: number | null = null;
+ let lastTime = 0;
+ // Reference timestep (60 Hz) — coefficients are tuned at this rate so
+ // the same `stiffness`/`damping` produce the same feel regardless of
+ // monitor refresh rate. Clamped per-frame to avoid blow-ups after a
+ // tab is throttled / backgrounded.
+ const REF_DT_MS = 1000 / 60;
+ const MAX_STEP_RATIO = 4; // never integrate more than 4 reference steps
+
+ function tick(now: number): void {
+ if (lastTime === 0) lastTime = now;
+ const rawDt = now - lastTime;
+ lastTime = now;
+ // Guard against NaN/Infinity from broken rAF shims and clock skew.
+ const dt = Number.isFinite(rawDt) && rawDt > 0 ? rawDt : REF_DT_MS;
+ const ratio = Math.min(MAX_STEP_RATIO, Math.max(0.1, dt / REF_DT_MS));
- function tick(): void {
const force = -stiffness * (current - target);
const dampingForce = -damping * velocity;
- velocity += force + dampingForce;
- current += velocity;
+ velocity += (force + dampingForce) * ratio;
+ current += velocity * ratio;
- // Check if settled
if (Math.abs(current - target) < precision && Math.abs(velocity) < precision) {
current = target;
velocity = 0;
rafId = null;
+ lastTime = 0;
setValue(current);
return;
}
@@ -89,12 +103,14 @@ export function springSignal(
cancelAnimationFrame(rafId);
rafId = null;
}
+ lastTime = 0;
setValue(current);
return;
}
// Start animation loop if not already running
if (rafId === null) {
+ lastTime = 0;
rafId = requestAnimationFrame(tick);
}
}
@@ -104,6 +120,7 @@ export function springSignal(
cancelAnimationFrame(rafId);
rafId = null;
}
+ lastTime = 0;
}
return [value, set, dispose];
diff --git a/src/ui/stream.ts b/src/ui/stream.ts
index 9c320da..42744a7 100644
--- a/src/ui/stream.ts
+++ b/src/ui/stream.ts
@@ -25,6 +25,9 @@ export function stream(
options?: {
withCredentials?: boolean;
autoReconnect?: boolean;
+ maxReconnects?: number;
+ reconnectBaseMs?: number;
+ reconnectMaxMs?: number;
},
): {
data: () => string | null;
@@ -34,6 +37,9 @@ export function stream(
dispose: () => void;
} {
const autoReconnect = options?.autoReconnect ?? false;
+ const maxReconnects = options?.maxReconnects ?? 10;
+ const baseMs = options?.reconnectBaseMs ?? 1000;
+ const maxMs = options?.reconnectMaxMs ?? 30_000;
const [data, setData] = signal(null);
const [event, setEvent] = signal(null);
@@ -42,6 +48,7 @@ export function stream(
let source: EventSource | null = null;
let disposed = false;
let reconnectTimer: ReturnType | null = null;
+ let attempts = 0;
function connect(): void {
if (disposed) return;
@@ -59,6 +66,7 @@ export function stream(
source.onopen = () => {
setStatus("open");
+ attempts = 0; // successful connection resets backoff
};
source.onmessage = (evt: MessageEvent) => {
@@ -70,11 +78,15 @@ export function stream(
if (source && source.readyState === EventSource.CLOSED) {
setStatus("closed");
source = null;
- if (autoReconnect && !disposed) {
+ if (autoReconnect && !disposed && attempts < maxReconnects) {
+ // Exponential backoff with jitter, capped at reconnectMaxMs.
+ const delay = Math.min(maxMs, baseMs * 2 ** attempts);
+ const jittered = delay * (0.5 + Math.random() * 0.5);
+ attempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
- }, 1000);
+ }, jittered);
}
}
};
diff --git a/src/ui/toast.ts b/src/ui/toast.ts
index aa6eda9..b527ca5 100644
--- a/src/ui/toast.ts
+++ b/src/ui/toast.ts
@@ -10,8 +10,6 @@ export interface Toast {
type?: "info" | "success" | "error" | "warning";
}
-let toastCounter = 0;
-
export interface ToastInstance {
toasts: () => Toast[];
show: (message: string, type?: Toast["type"]) => string;
@@ -32,24 +30,33 @@ export function toast(options?: { duration?: number; maxToasts?: number }): Toas
const maxToasts = options?.maxToasts ?? Infinity;
const [toasts, setToasts] = signal([]);
const timers = new Map>();
+ // Per-instance counter — avoids cross-instance id collisions and leakage
+ // of a module-global counter across tests / SSR runs.
+ let toastCounter = 0;
function show(message: string, type?: Toast["type"]): string {
const id = `toast-${++toastCounter}`;
const toast: Toast = { id, message, type };
+ // Compute which toasts (if any) will be trimmed BEFORE the set call so
+ // we can (a) skip scheduling a timer for a toast that will be trimmed
+ // immediately and (b) perform timer cleanup OUTSIDE the signal updater
+ // (updaters must be pure).
+ const trimmedIds: string[] = [];
setToasts((prev) => {
const next = [...prev, toast];
- // Trim oldest toasts if over max
if (next.length > maxToasts) {
const removed = next.splice(0, next.length - maxToasts);
- for (const r of removed) {
- clearTimerForToast(r.id);
- }
+ for (const r of removed) trimmedIds.push(r.id);
}
return next;
});
- if (duration > 0) {
+ // Apply timer side effects AFTER the set call.
+ for (const tid of trimmedIds) clearTimerForToast(tid);
+
+ const wasTrimmed = trimmedIds.indexOf(id) !== -1;
+ if (duration > 0 && !wasTrimmed) {
const timer = setTimeout(() => {
dismiss(id);
}, duration);
diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts
index e579b51..cee96f7 100644
--- a/src/utils/sanitize.ts
+++ b/src/utils/sanitize.ts
@@ -12,17 +12,18 @@ export function sanitize(value: unknown): string {
.replace(/'/g, "'");
}
+// Allowlist of safe URL protocols. Anything else (including javascript:,
+// data:, vbscript:, blob:, file:, etc.) is rejected.
+const SAFE_URL_PROTOCOLS = ["http:", "https:", "mailto:", "tel:", "ftp:"];
+
/**
- * Sanitizes a URL to prevent javascript: and data: protocol injection.
- * Allows http, https, mailto, tel, and relative URLs.
+ * Sanitizes a URL using a protocol allowlist. Accepts http:, https:,
+ * mailto:, tel:, ftp:, and relative URLs. All other protocols are rejected.
*
* @param url URL string to sanitize
* @returns The URL if safe, or empty string if dangerous
*/
export function sanitizeUrl(url: string): string {
- // Strip ASCII control characters (C0 controls, 0x00-0x1F) that browsers
- // may silently ignore, which could bypass protocol checks.
- // E.g. "\x01javascript:alert(1)" would skip startsWith("javascript:").
// Strip C0/C1 control characters and Unicode whitespace that browsers
// may silently ignore, which could bypass protocol checks.
// E.g. "\x01javascript:alert(1)" or "java\tscript:alert(1)"
@@ -30,20 +31,51 @@ export function sanitizeUrl(url: string): string {
const trimmed = url.replace(/[\x00-\x20\x7f-\x9f]+/g, "").trim();
if (!trimmed) return "";
- // Block dangerous protocols
+ // Detect an explicit scheme: the first ":" before any "/", "?", or "#".
+ // If there's no scheme, treat as relative URL (safe).
const lower = trimmed.toLowerCase();
- if (
- lower.startsWith("javascript:") ||
- lower.startsWith("data:") ||
- lower.startsWith("vbscript:") ||
- lower.startsWith("blob:")
- ) {
- return "";
+ let schemeEnd = -1;
+ for (let i = 0; i < lower.length; i++) {
+ const ch = lower.charCodeAt(i);
+ if (ch === 58 /* : */) {
+ schemeEnd = i;
+ break;
+ }
+ // Stop if we hit a path/query/fragment separator — it's a relative URL.
+ if (ch === 47 /* / */ || ch === 63 /* ? */ || ch === 35 /* # */) break;
}
+ if (schemeEnd === -1) return trimmed; // relative URL
+
+ const scheme = lower.slice(0, schemeEnd + 1);
+ // Only chars [a-z0-9+.-] are valid scheme characters; anything else means
+ // the ":" is part of a path/fragment, not a scheme.
+ if (!/^[a-z][a-z0-9+.-]*:$/.test(scheme)) return trimmed;
+
+ if (SAFE_URL_PROTOCOLS.indexOf(scheme) === -1) return "";
return trimmed;
}
+/**
+ * Sanitizes a srcset attribute value by splitting on commas, running each
+ * URL through sanitizeUrl, and re-joining. Invalid candidates are dropped.
+ */
+export function sanitizeSrcset(value: string): string {
+ const parts = value.split(",");
+ const out: string[] = [];
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i].trim();
+ if (!part) continue;
+ // Candidate = URL [descriptor]. Split on first whitespace run.
+ const m = part.match(/^(\S+)(\s+.+)?$/);
+ if (!m) continue;
+ const safe = sanitizeUrl(m[1]);
+ if (!safe) continue;
+ out.push(m[2] ? `${safe}${m[2]}` : safe);
+ }
+ return out.join(", ");
+}
+
/**
* Sanitizes a CSS value to prevent data exfiltration via url(), expression(),
* or other injection vectors. Strips url() and expression() calls entirely.
@@ -52,12 +84,28 @@ export function sanitizeUrl(url: string): string {
* @returns The sanitized value, or empty string if dangerous
*/
export function sanitizeCSSValue(value: string): string {
- const lower = value.toLowerCase().replace(/\s+/g, "");
+ // Decode CSS escapes (\xx hex and \uXXXX) so attackers can't bypass checks
+ // via e.g. "ex\\70 ression(...)" or "\\75 rl(...)".
+ const decoded = value.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_m, hex) => {
+ const code = Number.parseInt(hex, 16);
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return "";
+ try {
+ return String.fromCodePoint(code);
+ } catch {
+ return "";
+ }
+ });
+ const lower = decoded.toLowerCase().replace(/\s+/g, "");
if (
lower.includes("url(") ||
lower.includes("expression(") ||
lower.includes("javascript:") ||
- lower.includes("-moz-binding")
+ lower.includes("vbscript:") ||
+ lower.includes("-moz-binding") ||
+ lower.includes("behavior:") ||
+ lower.includes("@import") ||
+ lower.includes("image-set(") ||
+ lower.includes("filter:progid")
) {
return "";
}
@@ -112,8 +160,24 @@ const SAFE_ATTRIBUTES = new Set([
"data-*",
]);
-// Attributes that hold URLs and need URL sanitization
-const URL_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "cite", "poster", "background", "srcset"]);
+// Attributes that hold URLs and need URL sanitization.
+// `xlink:href` is a legacy SVG alias for `href` and has historically been a
+// javascript: vector on `` / `