From 95db6cebccc65caa4cacc43c0ad1647a55d7ffef Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 15:11:54 -0600 Subject: [PATCH 1/6] Updated CHANGELOG and package.json --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c767a55..64c424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.0.3] — 2026-03-28 + +### Added + +- **Wider `NodeChild` / `NodeChildren` types** — `NodeChild` now accepts `boolean`; `NodeChildren` accepts nested arrays and full reactive functions. Conditional patterns like `condition && element` work without `as any` casts. Boolean values are filtered out in `appendChildren`, `bindChildNode`, `Fragment()`, `htm.ts`, and `resolveChild`. +- **`onCleanup()` lifecycle hook** — `onCleanup(callback, element)` registers teardown logic (closing sockets, clearing timers, removing listeners) tied to an element's disposal. Integrates with the existing `dispose()` system so cleanup runs automatically when `when()`, `match()`, or `each()` swap content. +- **`query()` `select` option** — Optional `select` function that transforms cached data before returning it to consumers. Raw response stays in cache; `select` runs on read, enabling derived views without extra signals. +- **`formatNumber()` and `formatCurrency()`** — `Intl`-based formatting utilities exported from `sibujs/browser`. `formatNumber` wraps `Intl.NumberFormat`; `formatCurrency` is a convenience shorthand that sets `style: "currency"`. + +### Fixed + +- **Boolean values no longer render as text** — `false`, `true` are filtered in all rendering paths (`tagFactory`, `bindChildNode`, `Fragment`, `htm.ts`, `resolveChild`) preventing visible `"false"` text nodes. +- **Lint fixes** — Resolved unused variable in `router.basic.test.ts` and formatting issues flagged by Biome. + +--- + ## [1.0.2] — 2026-03-27 ### Fixed diff --git a/package.json b/package.json index 4a30d20..a3cd741 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.0.2", + "version": "1.0.3", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", From 9487727c338809848170d361ea8775a3fa149ad9 Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:30:29 -0600 Subject: [PATCH 2/6] ci: use npm install instead of npm ci --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aab4d99..e156d9e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Install dependencies - run: npm ci + run: npm install - name: Run tests run: npm test From 077718418208d14423f9aeddb63876ce57f6454c Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:51:26 -0600 Subject: [PATCH 3/6] trusted-publisher --- .github/workflows/publish.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cdf4e5b..f25d1f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,21 @@ on: release: types: [published] +permissions: + id-token: write + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - - name: Checkout código + - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" - name: Install dependencies @@ -28,5 +32,3 @@ jobs: - name: Publish to npm run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From ee7cf487a4e8438c2238b7ed54bc652e48b10b6d Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 11 Apr 2026 09:51:07 -0600 Subject: [PATCH 4/6] Updated main --- README.md | 54 +++++++++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 633c67a..61c59bc 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,10 @@ import { div, h1, button, signal, mount } from "sibujs"; function Counter() { const [count, setCount] = signal(0); - return div({ - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ - nodes: "Increment", - on: { click: () => setCount(c => c + 1) } - }) - ] - }); + return div({ class: "counter" }, [ + h1(() => `Count: ${count()}`), + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), + ]); } mount(Counter, document.getElementById("app")); @@ -43,32 +38,41 @@ mount(Counter, document.getElementById("app")); SibuJS gives you maximum flexibility with three interoperable styles: -#### 1. Tag Factory (Full Props) -Maximum control with an explicit properties object. Perfect for complex elements. +#### 1. Tag Factory +The canonical form: a props object followed by children as a second +positional argument. No `nodes:` key required at any level of the tree — +children can be a string, a number, a single node, an array, or a +reactive getter. ```javascript -import { div, h1, button } from "sibujs"; - -const [count, setCount] = signal(0); - -return div({ - class: "counter", - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) - ] -}); +import { div, h1, label, input, button } from "sibujs"; + +return div({ class: "counter" }, [ + h1({ class: "title" }, () => `Count: ${count()}`), + label({ for: "amount" }, "Step"), + input({ id: "amount", type: "number", value: 1 }), + button( + { class: "primary", on: { click: () => setCount(c => c + 1) } }, + "Increment", + ), +]); ``` -#### 2. Shorthand API -Concise and readable for common layouts. Class and children passed as positional arguments. +All legacy forms — `tag({ class, nodes })`, `tag("className", children)`, +`tag("text")`, `tag([children])`, `tag(node)`, `tag(() => reactive)` — +continue to work unchanged. When both `props.nodes` and the positional +second argument are present, the positional wins. + +#### 2. Positional Shorthand +The tersest form. Class and children as positional arguments, for +layouts with no event handlers or custom props. ```javascript import { div, h1, button } from "sibujs"; return div("counter", [ h1(() => `Count: ${count()}`), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), ]); ``` From 8540ede0ce094d92ec960845d7be6b3e1f250ed6 Mon Sep 17 00:00:00 2001 From: hexplus Date: Tue, 14 Apr 2026 17:32:13 -0600 Subject: [PATCH 5/6] chore: harden reactivity + rendering; fix SSR hydration, worker pool crosstalk, dispose re-entry; add retrack() for derived pulls; widget ARIA bind() (Tabs/Accordion/Tooltip/Popover/Combobox/Select/FileUpload/datePicker); effect() onCleanup; security (xlink:href, JSON reviver, loadRemoteModule/Wasm allowlist); lazy router pagehide; publish.mjs order + provenance; bench 2187/2187 tests, 0 lint --- bench.mjs | 35 +- index.ts | 8 +- package.json | 9 +- publish.mjs | 59 ++- release.sh | 14 +- src/browser/broadcast.ts | 5 + src/browser/dragDrop.ts | 6 +- src/browser/imageLoader.ts | 15 +- src/browser/speech.ts | 66 ++- src/browser/wakeLock.ts | 12 +- src/components/ErrorBoundary.ts | 100 ++++- src/components/ErrorDisplay.ts | 88 ++-- src/core/dev.ts | 4 +- src/core/rendering/context.ts | 40 +- src/core/rendering/dispose.ts | 35 +- src/core/rendering/dynamic.ts | 8 +- src/core/rendering/each.ts | 31 +- src/core/rendering/htm.ts | 64 ++- src/core/rendering/html.ts | 3 +- src/core/rendering/keepAlive.ts | 28 +- src/core/rendering/lazy.ts | 90 +++- src/core/rendering/lifecycle.ts | 268 ++++++++++-- src/core/rendering/mount.ts | 2 +- src/core/rendering/portal.ts | 19 + src/core/rendering/tagFactory.ts | 85 +++- src/core/rendering/types.ts | 9 + src/core/signals/array.ts | 3 +- src/core/signals/asyncDerived.ts | 25 +- src/core/signals/derived.ts | 50 ++- src/core/signals/effect.ts | 107 ++++- src/core/signals/ref.ts | 2 +- src/core/signals/store.ts | 21 +- src/core/ssr-context.ts | 82 +++- src/core/strict.ts | 4 +- src/data/infiniteQuery.ts | 15 +- src/data/mutation.ts | 9 +- src/data/offlineStore.ts | 122 +++++- src/data/query.ts | 91 ++++- src/data/retry.ts | 6 + src/data/routeLoader.ts | 8 + src/devtools/debug.ts | 6 +- src/devtools/devtools.ts | 521 +++++++++++++++--------- src/devtools/hmr.ts | 98 ++++- src/devtools/introspect.ts | 21 +- src/ecosystem/adapters/mobx.ts | 19 +- src/ecosystem/adapters/redux.ts | 8 +- src/ecosystem/adapters/zustand.ts | 8 +- src/patterns/globalStore.ts | 37 +- src/patterns/optimistic.ts | 60 ++- src/patterns/persist.ts | 25 +- src/patterns/timeTravel.ts | 29 +- src/performance/bundleOptimize.ts | 10 +- src/performance/chunkLoader.ts | 134 +++--- src/performance/compiled.ts | 19 +- src/performance/domRecycler.ts | 26 +- src/performance/scheduler.ts | 49 ++- src/platform/customElement.ts | 34 +- src/platform/incrementalRegeneration.ts | 35 +- src/platform/microfrontend.ts | 54 ++- src/platform/serviceWorker.ts | 43 +- src/platform/ssr.ts | 130 ++++-- src/platform/wasm.ts | 70 +++- src/platform/worker.ts | 168 ++++++-- src/plugins/plugin.ts | 204 ++++++---- src/plugins/router.ts | 143 +++++-- src/plugins/routerSSR.ts | 16 +- src/plugins/startup.ts | 10 +- src/reactivity/batch.ts | 11 +- src/reactivity/bindAttribute.ts | 17 +- src/reactivity/bindChildNode.ts | 30 +- src/reactivity/bindTextNode.ts | 6 +- src/reactivity/concurrent.ts | 14 +- src/reactivity/track.ts | 199 ++++++--- src/testing/e2e.ts | 108 +++-- src/testing/index.ts | 43 +- src/testing/queries.ts | 28 +- src/ui/a11y.ts | 137 +++++-- src/ui/a11yPrimitives.ts | 21 +- src/ui/dialog.ts | 91 ++++- src/ui/form.ts | 14 +- src/ui/inputMask.ts | 22 +- src/ui/intersection.ts | 5 + src/ui/scrollLock.ts | 12 + src/ui/socket.ts | 19 +- src/ui/springSignal.ts | 25 +- src/ui/stream.ts | 16 +- src/ui/toast.ts | 21 +- src/utils/sanitize.ts | 98 ++++- src/widgets/Accordion.ts | 113 +++++ src/widgets/Combobox.ts | 111 +++++ src/widgets/FileUpload.ts | 128 ++++++ src/widgets/Popover.ts | 92 ++++- src/widgets/Select.ts | 117 +++++- src/widgets/Tabs.ts | 138 +++++++ src/widgets/Tooltip.ts | 108 ++++- src/widgets/contentEditable.ts | 55 ++- src/widgets/datePicker.ts | 123 +++++- tests/consumption.test.ts | 4 +- tests/domRecycler.test.ts | 7 +- tests/hardening.test.ts | 2 +- tests/keepAlive.test.ts | 81 ++++ tests/optimistic.test.ts | 9 - tests/pluginRegistry.test.ts | 30 ++ tests/reduxAdapter.test.ts | 10 +- tests/widgetsAria.test.ts | 127 ++++++ tests/zustandAdapter.test.ts | 4 +- tsconfig.json | 3 +- 107 files changed, 4662 insertions(+), 1162 deletions(-) create mode 100644 tests/keepAlive.test.ts create mode 100644 tests/pluginRegistry.test.ts create mode 100644 tests/widgetsAria.test.ts 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..ba516c7 100644 --- a/package.json +++ b/package.json @@ -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 `` / ``. `formtarget` / `ping` / `data` +// (on ``) are additional URL sinks enumerated by the HTML spec. +const URL_ATTRIBUTES = new Set([ + "href", + "xlink:href", + "src", + "action", + "formaction", + "formtarget", + "cite", + "poster", + "background", + "srcset", + "ping", + "data", +]); /** * Checks if an attribute name is safe to set without sanitization. diff --git a/src/widgets/Accordion.ts b/src/widgets/Accordion.ts index 0cfcefc..dadaf38 100644 --- a/src/widgets/Accordion.ts +++ b/src/widgets/Accordion.ts @@ -1,12 +1,31 @@ import { derived } from "../core/signals/derived"; +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; +// First trigger of an accordion identifies the binding instance for +// idempotency — calling bind() twice on the same set returns the prior +// teardown rather than stacking listeners + effects. +const boundAccordions = new WeakMap void>(); + export interface AccordionOptions { items: Array<{ id: string; label: string }>; multiple?: boolean; defaultExpanded?: string[]; } +export interface AccordionAriaBinding { + /** WAI-ARIA Accordion pattern — wires `aria-expanded`/`aria-controls`, + * Enter/Space toggle, and panel `role=region`. Returns dispose. + * Pass `root` (any stable container element) to anchor the WeakMap + * idempotency key — without it, double-bind detection falls back to + * the first trigger and breaks if items re-render. */ + bind: (els: { + root?: HTMLElement; + triggers: Record; + panels: Record; + }) => () => void; +} + export function accordion(options: AccordionOptions): { items: () => Array<{ id: string; label: string; isExpanded: boolean }>; toggle: (id: string) => void; @@ -15,7 +34,9 @@ export function accordion(options: AccordionOptions): { expandAll: () => void; collapseAll: () => void; isExpanded: (id: string) => boolean; + bind: AccordionAriaBinding["bind"]; } { + // (no-op — kept structure) const { items: itemDefs, multiple = false, defaultExpanded = [] } = options; const [expandedIds, setExpandedIds] = signal>(new Set(defaultExpanded)); @@ -73,6 +94,97 @@ export function accordion(options: AccordionOptions): { return expandedIds().has(id); } + function bind(els: { + root?: HTMLElement; + triggers: Record; + panels: Record; + }): () => void { + // Prefer caller-supplied `root` for the idempotency key; fall back to + // first trigger only when no root was given (legacy callers). + const idempotencyKey: HTMLElement | undefined = + els.root ?? (itemDefs.length > 0 ? els.triggers[itemDefs[0].id] : undefined); + if (idempotencyKey) { + const existing = boundAccordions.get(idempotencyKey); + if (existing) return existing; + } + const restore: Array<() => void> = []; + for (const item of itemDefs) { + const trig = els.triggers[item.id]; + const panel = els.panels[item.id]; + if (!trig) continue; + const prevTrigId = trig.id; + const prevTrigControls = trig.getAttribute("aria-controls"); + trig.id = `sibu-accordion-trigger-${item.id}`; + let prevPanelRole: string | null = null; + let prevPanelId = ""; + let prevPanelLabelledBy: string | null = null; + if (panel) { + prevPanelRole = panel.getAttribute("role"); + prevPanelId = panel.id; + prevPanelLabelledBy = panel.getAttribute("aria-labelledby"); + panel.setAttribute("role", "region"); + panel.id = `sibu-accordion-panel-${item.id}`; + panel.setAttribute("aria-labelledby", trig.id); + trig.setAttribute("aria-controls", panel.id); + } + restore.push(() => { + if (prevTrigId === "") trig.removeAttribute("id"); + else trig.id = prevTrigId; + if (prevTrigControls === null) trig.removeAttribute("aria-controls"); + else trig.setAttribute("aria-controls", prevTrigControls); + trig.removeAttribute("aria-expanded"); + if (panel) { + if (prevPanelRole === null) panel.removeAttribute("role"); + else panel.setAttribute("role", prevPanelRole); + if (prevPanelId === "") panel.removeAttribute("id"); + else panel.id = prevPanelId; + if (prevPanelLabelledBy === null) panel.removeAttribute("aria-labelledby"); + else panel.setAttribute("aria-labelledby", prevPanelLabelledBy); + } + }); + } + + const fxTeardown = effect(() => { + const ids = expandedIds(); + for (const item of itemDefs) { + const trig = els.triggers[item.id]; + const panel = els.panels[item.id]; + if (!trig) continue; + const expanded = ids.has(item.id); + trig.setAttribute("aria-expanded", expanded ? "true" : "false"); + if (panel) panel.hidden = !expanded; + } + }); + + const handlers: Array<{ el: HTMLElement; click: (e: Event) => void; key: (e: KeyboardEvent) => void }> = []; + for (const item of itemDefs) { + const trig = els.triggers[item.id]; + if (!trig) continue; + const click = () => toggle(item.id); + const key = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(item.id); + } + }; + trig.addEventListener("click", click); + trig.addEventListener("keydown", key); + handlers.push({ el: trig, click, key }); + } + + const teardown = () => { + if (idempotencyKey) boundAccordions.delete(idempotencyKey); + fxTeardown(); + for (const { el, click, key } of handlers) { + el.removeEventListener("click", click); + el.removeEventListener("keydown", key); + } + for (const r of restore) r(); + }; + if (idempotencyKey) boundAccordions.set(idempotencyKey, teardown); + return teardown; + } + return { items, toggle, @@ -82,5 +194,6 @@ export function accordion(options: AccordionOptions): { collapseAll, /** Reactive check — use inside class/nodes bindings for per-item reactivity */ isExpanded, + bind, }; } diff --git a/src/widgets/Combobox.ts b/src/widgets/Combobox.ts index 578c446..1b2b212 100644 --- a/src/widgets/Combobox.ts +++ b/src/widgets/Combobox.ts @@ -1,8 +1,12 @@ import { derived } from "../core/signals/derived"; +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { watch } from "../core/signals/watch"; import { batch } from "../reactivity/batch"; +let comboboxIdCounter = 0; +const boundComboboxes = new WeakMap void>(); + export interface ComboboxOptions { items: T[]; filterFn?: (item: T, query: string) => boolean; @@ -22,6 +26,14 @@ export function combobox(options: ComboboxOptions): { isOpen: () => boolean; open: () => void; close: () => void; + /** WAI-ARIA Combobox 1.2 wiring: `role=combobox` on input, + * `aria-expanded`/`aria-controls`/`aria-activedescendant`, listbox option + * ids, Down/Up/Enter/Escape/Home/End. Returns dispose. */ + bind: (els: { + input: HTMLInputElement; + listbox: HTMLElement; + option: (item: T, index: number) => HTMLElement | null; + }) => () => void; } { const { items, filterFn, itemToString } = options; @@ -91,6 +103,104 @@ export function combobox(options: ComboboxOptions): { setIsOpen(false); } + function bind(els: { + input: HTMLInputElement; + listbox: HTMLElement; + option: (item: T, index: number) => HTMLElement | null; + }): () => void { + const existing = boundComboboxes.get(els.input); + if (existing) return existing; + + const listboxId = `sibu-combobox-listbox-${++comboboxIdCounter}`; + els.listbox.id = listboxId; + els.listbox.setAttribute("role", "listbox"); + els.input.setAttribute("role", "combobox"); + els.input.setAttribute("aria-autocomplete", "list"); + els.input.setAttribute("aria-controls", listboxId); + + const fxTeardown = effect(() => { + const open = isOpen(); + els.input.setAttribute("aria-expanded", open ? "true" : "false"); + els.listbox.hidden = !open; + + const idx = highlightedIndex(); + const filtered = filteredItems(); + let activeId = ""; + for (let i = 0; i < filtered.length; i++) { + const optEl = els.option(filtered[i], i); + if (!optEl) continue; + if (!optEl.id) optEl.id = `${listboxId}-opt-${i}`; + optEl.setAttribute("role", "option"); + const isHighlighted = i === idx; + optEl.setAttribute("aria-selected", isHighlighted ? "true" : "false"); + if (isHighlighted) activeId = optEl.id; + } + if (activeId) els.input.setAttribute("aria-activedescendant", activeId); + else els.input.removeAttribute("aria-activedescendant"); + }); + + const onInput = () => { + setQuery(els.input.value); + open(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (!isOpen()) open(); + highlightNext(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (!isOpen()) open(); + highlightPrev(); + } else if (e.key === "Enter") { + if (highlightedIndex() >= 0) { + e.preventDefault(); + selectHighlighted(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + close(); + } else if (e.key === "Home") { + e.preventDefault(); + if (filteredItems().length > 0) setHighlightedIndex(0); + } else if (e.key === "End") { + e.preventDefault(); + const len = filteredItems().length; + if (len > 0) setHighlightedIndex(len - 1); + } + }; + let blurTimer: ReturnType | null = null; + const onFocus = () => open(); + const onBlur = () => { + // Slight delay so click on listbox option lands before close. + if (blurTimer !== null) clearTimeout(blurTimer); + blurTimer = setTimeout(() => { + blurTimer = null; + if (document.activeElement !== els.input) close(); + }, 100); + }; + + els.input.addEventListener("input", onInput); + els.input.addEventListener("keydown", onKey); + els.input.addEventListener("focus", onFocus); + els.input.addEventListener("blur", onBlur); + + const teardown = () => { + boundComboboxes.delete(els.input); + fxTeardown(); + els.input.removeEventListener("input", onInput); + els.input.removeEventListener("keydown", onKey); + els.input.removeEventListener("focus", onFocus); + els.input.removeEventListener("blur", onBlur); + if (blurTimer !== null) { + clearTimeout(blurTimer); + blurTimer = null; + } + }; + boundComboboxes.set(els.input, teardown); + return teardown; + } + return { query, setQuery, @@ -104,5 +214,6 @@ export function combobox(options: ComboboxOptions): { isOpen, open, close, + bind, }; } diff --git a/src/widgets/FileUpload.ts b/src/widgets/FileUpload.ts index cd3d850..bdb7861 100644 --- a/src/widgets/FileUpload.ts +++ b/src/widgets/FileUpload.ts @@ -1,6 +1,10 @@ +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { batch } from "../reactivity/batch"; +let fileUploadIdCounter = 0; +const boundFileUploads = new WeakMap void>(); + export interface FileUploadOptions { accept?: string; multiple?: boolean; @@ -16,6 +20,14 @@ export function fileUpload(options?: FileUploadOptions): { errors: () => string[]; isDragOver: () => boolean; setDragOver: (v: boolean) => void; + /** Wires native file input + drop zone with proper labeling, hint + * description, and keyboard activation. Returns dispose. */ + bind: (els: { + input: HTMLInputElement; + dropZone?: HTMLElement; + hint?: HTMLElement; + errorRegion?: HTMLElement; + }) => () => void; } { const accept = options?.accept; const multiple = options?.multiple ?? false; @@ -101,6 +113,121 @@ export function fileUpload(options?: FileUploadOptions): { }); } + function bind(els: { + input: HTMLInputElement; + dropZone?: HTMLElement; + hint?: HTMLElement; + errorRegion?: HTMLElement; + }): () => void { + const existing = boundFileUploads.get(els.input); + if (existing) return existing; + + const id = `sibu-fileupload-${++fileUploadIdCounter}`; + const restore: Array<() => void> = []; + if (accept) els.input.accept = accept; + els.input.multiple = multiple; + let hintId: string | null = null; + if (els.hint) { + const assignedHintId = !els.hint.id; + if (assignedHintId) els.hint.id = `${id}-hint`; + hintId = els.hint.id; + const prev = els.input.getAttribute("aria-describedby"); + els.input.setAttribute("aria-describedby", prev ? `${prev} ${hintId}` : hintId); + restore.push(() => { + // Splice our id out, preserving any other ids that may have been added. + const cur = els.input.getAttribute("aria-describedby"); + if (cur) { + const parts = cur.split(/\s+/).filter((p) => p && p !== hintId); + if (parts.length > 0) els.input.setAttribute("aria-describedby", parts.join(" ")); + else els.input.removeAttribute("aria-describedby"); + } + if (assignedHintId && els.hint && els.hint.id === hintId) els.hint.removeAttribute("id"); + }); + } + if (els.errorRegion) { + // role=alert implies aria-live=assertive; setting both is redundant. + // Use status+polite for non-blocking validation errors per APG. + const prevRole = els.errorRegion.getAttribute("role"); + const prevLive = els.errorRegion.getAttribute("aria-live"); + els.errorRegion.setAttribute("role", "status"); + els.errorRegion.setAttribute("aria-live", "polite"); + restore.push(() => { + if (prevRole === null) els.errorRegion!.removeAttribute("role"); + else els.errorRegion!.setAttribute("role", prevRole); + if (prevLive === null) els.errorRegion!.removeAttribute("aria-live"); + else els.errorRegion!.setAttribute("aria-live", prevLive); + }); + } + if (els.dropZone) { + const prevDzRole = els.dropZone.getAttribute("role"); + const prevDzLabel = els.dropZone.getAttribute("aria-label"); + const prevDzTabindex = els.dropZone.hasAttribute("tabindex") ? els.dropZone.getAttribute("tabindex") : null; + els.dropZone.setAttribute("role", "button"); + els.dropZone.setAttribute("aria-label", "File drop zone — click or press Enter to browse"); + if (els.dropZone.tabIndex < 0) els.dropZone.tabIndex = 0; + restore.push(() => { + if (prevDzRole === null) els.dropZone!.removeAttribute("role"); + else els.dropZone!.setAttribute("role", prevDzRole); + if (prevDzLabel === null) els.dropZone!.removeAttribute("aria-label"); + else els.dropZone!.setAttribute("aria-label", prevDzLabel); + if (prevDzTabindex === null) els.dropZone!.removeAttribute("tabindex"); + else els.dropZone!.setAttribute("tabindex", prevDzTabindex); + }); + } + + const fxTeardown = effect(() => { + const errs = errors(); + if (els.errorRegion) els.errorRegion.textContent = errs.join(". "); + if (els.dropZone) els.dropZone.setAttribute("data-drag-over", isDragOver() ? "true" : "false"); + }); + + const onChange = () => { + if (els.input.files) addFiles(els.input.files); + }; + const onDropClick = () => els.input.click(); + const onDropKey = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + els.input.click(); + } + }; + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + setDragOver(true); + }; + const onDragLeave = () => setDragOver(false); + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer?.files) addFiles(e.dataTransfer.files); + }; + + els.input.addEventListener("change", onChange); + if (els.dropZone) { + els.dropZone.addEventListener("click", onDropClick); + els.dropZone.addEventListener("keydown", onDropKey); + els.dropZone.addEventListener("dragover", onDragOver); + els.dropZone.addEventListener("dragleave", onDragLeave); + els.dropZone.addEventListener("drop", onDrop); + } + + const teardown = () => { + boundFileUploads.delete(els.input); + fxTeardown(); + els.input.removeEventListener("change", onChange); + if (els.dropZone) { + els.dropZone.removeEventListener("click", onDropClick); + els.dropZone.removeEventListener("keydown", onDropKey); + els.dropZone.removeEventListener("dragover", onDragOver); + els.dropZone.removeEventListener("dragleave", onDragLeave); + els.dropZone.removeEventListener("drop", onDrop); + } + for (const r of restore) r(); + }; + boundFileUploads.set(els.input, teardown); + return teardown; + } + return { files, addFiles, @@ -109,5 +236,6 @@ export function fileUpload(options?: FileUploadOptions): { errors, isDragOver, setDragOver, + bind, }; } diff --git a/src/widgets/Popover.ts b/src/widgets/Popover.ts index e6826b4..9fce02f 100644 --- a/src/widgets/Popover.ts +++ b/src/widgets/Popover.ts @@ -1,5 +1,9 @@ +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; +let popoverIdCounter = 0; +const boundPopovers = new WeakMap void>(); + /** * popover provides simple state management for positioned floating content. * Manages open/close/toggle without any DOM coupling. @@ -9,6 +13,9 @@ export function popover(): { open: () => void; close: () => void; toggle: () => void; + /** WAI-ARIA non-modal dialog wiring: `role=dialog`, `aria-expanded` on + * trigger, Escape closes, click-outside closes. Returns dispose. */ + bind: (els: { trigger: HTMLElement; popover: HTMLElement; labelledBy?: HTMLElement }) => () => void; } { const [isOpen, setIsOpen] = signal(false); @@ -24,5 +31,88 @@ export function popover(): { setIsOpen((prev) => !prev); } - return { isOpen, open, close, toggle }; + function bind(els: { trigger: HTMLElement; popover: HTMLElement; labelledBy?: HTMLElement }): () => void { + const existing = boundPopovers.get(els.trigger); + if (existing) return existing; + + const id = `sibu-popover-${++popoverIdCounter}`; + // Capture prior attribute state so teardown can restore (or remove) + // every attribute we touch — bind() should be reversible. + const prevPopoverRole = els.popover.getAttribute("role"); + const prevPopoverId = els.popover.id; + const prevLabelledBy = els.popover.getAttribute("aria-labelledby"); + const prevTriggerHaspopup = els.trigger.getAttribute("aria-haspopup"); + const prevTriggerControls = els.trigger.getAttribute("aria-controls"); + + els.popover.setAttribute("role", "dialog"); + els.popover.id = id; + els.trigger.setAttribute("aria-haspopup", "dialog"); + els.trigger.setAttribute("aria-controls", id); + let assignedLabelId: string | null = null; + if (els.labelledBy) { + if (!els.labelledBy.id) { + els.labelledBy.id = `${id}-label`; + assignedLabelId = els.labelledBy.id; + } + els.popover.setAttribute("aria-labelledby", els.labelledBy.id); + } + + const fxTeardown = effect(() => { + const open = isOpen(); + els.trigger.setAttribute("aria-expanded", open ? "true" : "false"); + els.popover.hidden = !open; + }); + + const onTriggerClick = (e: Event) => { + e.preventDefault(); + toggle(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen()) { + e.stopPropagation(); + close(); + els.trigger.focus(); + } + }; + const onDocPointer = (e: PointerEvent) => { + if (!isOpen()) return; + const t = e.target as Node | null; + if (!t) return; + if (els.trigger.contains(t) || els.popover.contains(t)) return; + close(); + }; + + els.trigger.addEventListener("click", onTriggerClick); + els.popover.addEventListener("keydown", onKey); + els.trigger.addEventListener("keydown", onKey); + document.addEventListener("pointerdown", onDocPointer); + + const teardown = () => { + boundPopovers.delete(els.trigger); + fxTeardown(); + els.trigger.removeEventListener("click", onTriggerClick); + els.popover.removeEventListener("keydown", onKey); + els.trigger.removeEventListener("keydown", onKey); + document.removeEventListener("pointerdown", onDocPointer); + // Reverse every attribute mutation to leave the DOM as we found it. + if (prevPopoverRole === null) els.popover.removeAttribute("role"); + else els.popover.setAttribute("role", prevPopoverRole); + if (prevPopoverId === "") els.popover.removeAttribute("id"); + else els.popover.id = prevPopoverId; + if (prevLabelledBy === null) els.popover.removeAttribute("aria-labelledby"); + else els.popover.setAttribute("aria-labelledby", prevLabelledBy); + if (assignedLabelId && els.labelledBy?.id === assignedLabelId) { + els.labelledBy.removeAttribute("id"); + } + if (prevTriggerHaspopup === null) els.trigger.removeAttribute("aria-haspopup"); + else els.trigger.setAttribute("aria-haspopup", prevTriggerHaspopup); + if (prevTriggerControls === null) els.trigger.removeAttribute("aria-controls"); + else els.trigger.setAttribute("aria-controls", prevTriggerControls); + els.trigger.removeAttribute("aria-expanded"); + }; + boundPopovers.set(els.trigger, teardown); + return teardown; + } + + return { isOpen, open, close, toggle, bind }; } diff --git a/src/widgets/Select.ts b/src/widgets/Select.ts index 3dc0379..cc94bdc 100644 --- a/src/widgets/Select.ts +++ b/src/widgets/Select.ts @@ -1,11 +1,18 @@ import { derived } from "../core/signals/derived"; +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { batch } from "../reactivity/batch"; +let selectIdCounter = 0; +const boundSelects = new WeakMap void>(); + export interface SelectOptions { items: T[]; multiple?: boolean; itemToString?: (item: T) => string; + /** Optional predicate marking items as disabled — such items are skipped + * by `highlightNext`/`highlightPrev`/typeahead and rejected by `select`. */ + isDisabled?: (item: T) => boolean; } export function select(options: SelectOptions): { @@ -23,8 +30,17 @@ export function select(options: SelectOptions): { highlightPrev: () => void; selectHighlighted: () => void; clear: () => void; + /** WAI-ARIA Listbox wiring: `role=listbox`, `aria-multiselectable`, + * `aria-selected`/`aria-activedescendant`, arrow + Home/End/Enter/Space + * + typeahead. Returns dispose. */ + bind: (els: { + listbox: HTMLElement; + option: (item: T, index: number) => HTMLElement | null; + itemToString?: (item: T) => string; + }) => () => void; } { - const { items, multiple = false } = options; + const { items, multiple = false, itemToString, isDisabled } = options; + const isItemDisabled = isDisabled ?? (() => false); const [selectedItems, setSelectedItems] = signal([]); const [isOpen, setIsOpen] = signal(false); @@ -36,6 +52,7 @@ export function select(options: SelectOptions): { }); function select(item: T): void { + if (isItemDisabled(item)) return; if (multiple) { setSelectedItems((prev) => { if (prev.includes(item)) return prev; @@ -73,19 +90,30 @@ export function select(options: SelectOptions): { setIsOpen(false); } + function nextEnabled(from: number, dir: 1 | -1): number { + const len = items.length; + if (len === 0) return -1; + let i = from; + for (let n = 0; n < len; n++) { + i = (i + dir + len) % len; + if (!isItemDisabled(items[i])) return i; + } + return -1; + } + function highlightNext(): void { if (items.length === 0) return; setHighlightedIndex((prev) => { - const next = prev + 1; - return next >= items.length ? 0 : next; + const n = nextEnabled(prev < 0 ? -1 : prev, 1); + return n === -1 ? prev : n; }); } function highlightPrev(): void { if (items.length === 0) return; setHighlightedIndex((prev) => { - const next = prev - 1; - return next < 0 ? items.length - 1 : next; + const n = nextEnabled(prev < 0 ? items.length : prev, -1); + return n === -1 ? prev : n; }); } @@ -100,6 +128,84 @@ export function select(options: SelectOptions): { setSelectedItems([]); } + function bind(els: { + listbox: HTMLElement; + option: (item: T, index: number) => HTMLElement | null; + itemToString?: (item: T) => string; + }): () => void { + const existing = boundSelects.get(els.listbox); + if (existing) return existing; + + const listboxId = `sibu-select-${++selectIdCounter}`; + els.listbox.id = listboxId; + els.listbox.setAttribute("role", "listbox"); + els.listbox.setAttribute("aria-multiselectable", multiple ? "true" : "false"); + if (els.listbox.tabIndex < 0) els.listbox.tabIndex = 0; + + const toStr = els.itemToString ?? itemToString ?? ((it: T) => String(it)); + + const fxTeardown = effect(() => { + const idx = highlightedIndex(); + const sel = selectedItems(); + let activeId = ""; + for (let i = 0; i < items.length; i++) { + const optEl = els.option(items[i], i); + if (!optEl) continue; + if (!optEl.id) optEl.id = `${listboxId}-opt-${i}`; + optEl.setAttribute("role", "option"); + optEl.setAttribute("aria-selected", sel.includes(items[i]) ? "true" : "false"); + if (isItemDisabled(items[i])) optEl.setAttribute("aria-disabled", "true"); + else optEl.removeAttribute("aria-disabled"); + if (i === idx) activeId = optEl.id; + } + if (activeId) els.listbox.setAttribute("aria-activedescendant", activeId); + else els.listbox.removeAttribute("aria-activedescendant"); + }); + + // Typeahead — printable chars accumulate within a 500ms window. + let typeBuffer = ""; + let typeTimer: ReturnType | null = null; + const onKey = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + highlightNext(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + highlightPrev(); + } else if (e.key === "Home") { + e.preventDefault(); + if (items.length > 0) setHighlightedIndex(0); + } else if (e.key === "End") { + e.preventDefault(); + if (items.length > 0) setHighlightedIndex(items.length - 1); + } else if (e.key === "Enter" || e.key === " ") { + if (highlightedIndex() >= 0) { + e.preventDefault(); + selectHighlighted(); + } + } else if (e.key.length === 1 && /\S/.test(e.key)) { + typeBuffer += e.key.toLowerCase(); + if (typeTimer !== null) clearTimeout(typeTimer); + typeTimer = setTimeout(() => { + typeBuffer = ""; + typeTimer = null; + }, 500); + const found = items.findIndex((it) => !isItemDisabled(it) && toStr(it).toLowerCase().startsWith(typeBuffer)); + if (found !== -1) setHighlightedIndex(found); + } + }; + els.listbox.addEventListener("keydown", onKey); + + const teardown = () => { + boundSelects.delete(els.listbox); + fxTeardown(); + els.listbox.removeEventListener("keydown", onKey); + if (typeTimer !== null) clearTimeout(typeTimer); + }; + boundSelects.set(els.listbox, teardown); + return teardown; + } + return { selectedItems, selectedItem, @@ -115,5 +221,6 @@ export function select(options: SelectOptions): { highlightPrev, selectHighlighted, clear, + bind, }; } diff --git a/src/widgets/Tabs.ts b/src/widgets/Tabs.ts index 95f462e..c5eaf91 100644 --- a/src/widgets/Tabs.ts +++ b/src/widgets/Tabs.ts @@ -1,11 +1,24 @@ import { derived } from "../core/signals/derived"; +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; +const boundTablists = new WeakMap void>(); + export interface TabsOptions { tabs: Array<{ id: string; label: string; disabled?: boolean }>; defaultTab?: string; } +export interface TabsAriaBinding { + /** WAI-ARIA Tabs pattern — wires `role`/`aria-*` and arrow/Home/End keys + * to the provided tablist + per-tab elements. Returns dispose. */ + bind: (els: { + tablist: HTMLElement; + tabs: Record; + panels?: Record; + }) => () => void; +} + export function tabs(options: TabsOptions): { activeTab: () => string; setActiveTab: (id: string) => void; @@ -13,6 +26,7 @@ export function tabs(options: TabsOptions): { nextTab: () => void; prevTab: () => void; isActive: (id: string) => boolean; + bind: TabsAriaBinding["bind"]; } { const { tabs: tabDefs, defaultTab } = options; @@ -74,6 +88,129 @@ export function tabs(options: TabsOptions): { return activeTab() === id; } + function bind(els: { + tablist: HTMLElement; + tabs: Record; + panels?: Record; + }): () => void { + const existing = boundTablists.get(els.tablist); + if (existing) return existing; + // Snapshot prior attribute state so teardown can restore. + const restore: Array<() => void> = []; + const prevTablistRole = els.tablist.getAttribute("role"); + els.tablist.setAttribute("role", "tablist"); + restore.push(() => { + if (prevTablistRole === null) els.tablist.removeAttribute("role"); + else els.tablist.setAttribute("role", prevTablistRole); + }); + for (const def of tabDefs) { + const tabEl = els.tabs[def.id]; + if (!tabEl) continue; + const prevRole = tabEl.getAttribute("role"); + const prevId = tabEl.id; + const prevDisabled = tabEl.getAttribute("aria-disabled"); + const prevControls = tabEl.getAttribute("aria-controls"); + tabEl.setAttribute("role", "tab"); + tabEl.setAttribute("id", `sibu-tab-${def.id}`); + if (def.disabled) tabEl.setAttribute("aria-disabled", "true"); + const panelEl = els.panels?.[def.id]; + let prevPanelRole: string | null = null; + let prevPanelId = ""; + let prevPanelLabelledBy: string | null = null; + if (panelEl) { + prevPanelRole = panelEl.getAttribute("role"); + prevPanelId = panelEl.id; + prevPanelLabelledBy = panelEl.getAttribute("aria-labelledby"); + panelEl.setAttribute("role", "tabpanel"); + panelEl.setAttribute("id", `sibu-tabpanel-${def.id}`); + panelEl.setAttribute("aria-labelledby", `sibu-tab-${def.id}`); + tabEl.setAttribute("aria-controls", `sibu-tabpanel-${def.id}`); + } + restore.push(() => { + if (prevRole === null) tabEl.removeAttribute("role"); + else tabEl.setAttribute("role", prevRole); + if (prevId === "") tabEl.removeAttribute("id"); + else tabEl.id = prevId; + if (prevDisabled === null) tabEl.removeAttribute("aria-disabled"); + else tabEl.setAttribute("aria-disabled", prevDisabled); + if (prevControls === null) tabEl.removeAttribute("aria-controls"); + else tabEl.setAttribute("aria-controls", prevControls); + tabEl.removeAttribute("aria-selected"); + tabEl.removeAttribute("tabindex"); + if (panelEl) { + if (prevPanelRole === null) panelEl.removeAttribute("role"); + else panelEl.setAttribute("role", prevPanelRole); + if (prevPanelId === "") panelEl.removeAttribute("id"); + else panelEl.id = prevPanelId; + if (prevPanelLabelledBy === null) panelEl.removeAttribute("aria-labelledby"); + else panelEl.setAttribute("aria-labelledby", prevPanelLabelledBy); + } + }); + } + + // Roving tabindex + aria-selected reflect the active tab reactively. + const fxTeardown = effect(() => { + const active = activeTab(); + for (const def of tabDefs) { + const tabEl = els.tabs[def.id]; + if (!tabEl) continue; + const isAct = def.id === active; + tabEl.setAttribute("aria-selected", isAct ? "true" : "false"); + tabEl.tabIndex = isAct ? 0 : -1; + const panelEl = els.panels?.[def.id]; + if (panelEl) panelEl.hidden = !isAct; + } + }); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + nextTab(); + els.tabs[activeTab()]?.focus(); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + prevTab(); + els.tabs[activeTab()]?.focus(); + } else if (e.key === "Home") { + e.preventDefault(); + const first = tabDefs.find((t) => !t.disabled); + if (first) { + setActiveTabState(first.id); + els.tabs[first.id]?.focus(); + } + } else if (e.key === "End") { + e.preventDefault(); + for (let i = tabDefs.length - 1; i >= 0; i--) { + if (!tabDefs[i].disabled) { + setActiveTabState(tabDefs[i].id); + els.tabs[tabDefs[i].id]?.focus(); + break; + } + } + } + }; + els.tablist.addEventListener("keydown", onKey); + + const clickHandlers: Array<{ el: HTMLElement; fn: (e: Event) => void }> = []; + for (const def of tabDefs) { + const tabEl = els.tabs[def.id]; + if (!tabEl) continue; + const fn = () => setActiveTab(def.id); + tabEl.addEventListener("click", fn); + clickHandlers.push({ el: tabEl, fn }); + } + + const teardown = () => { + boundTablists.delete(els.tablist); + fxTeardown(); + els.tablist.removeEventListener("keydown", onKey); + for (const { el, fn } of clickHandlers) el.removeEventListener("click", fn); + for (const r of restore) r(); + }; + boundTablists.set(els.tablist, teardown); + return teardown; + } + return { activeTab, setActiveTab, @@ -82,5 +219,6 @@ export function tabs(options: TabsOptions): { prevTab, /** Reactive check — use inside class/nodes bindings for per-tab reactivity */ isActive, + bind, }; } diff --git a/src/widgets/Tooltip.ts b/src/widgets/Tooltip.ts index 7cb84ae..8d9da56 100644 --- a/src/widgets/Tooltip.ts +++ b/src/widgets/Tooltip.ts @@ -1,29 +1,42 @@ +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; +let tooltipIdCounter = 0; + +// Track which trigger elements already have a bind() active so a second +// call short-circuits rather than corrupting aria-describedby restore. +const boundTriggers = new WeakMap void>(); + /** * tooltip manages tooltip visibility with optional show delay. * Timer cleanup is handled via closure variables per the framework convention. */ -export function tooltip(options?: { delay?: number }): { +export function tooltip(options?: { delay?: number; hideDelay?: number }): { isVisible: () => boolean; show: () => void; hide: () => void; content: () => string; setContent: (text: string) => void; + /** WAI-ARIA Tooltip pattern — wires `role=tooltip`, `aria-describedby`, + * Escape-to-dismiss, and pointer hover-grace per WCAG 1.4.13. */ + bind: (els: { trigger: HTMLElement; tooltip: HTMLElement }) => () => void; } { const delay = options?.delay ?? 0; + const hideDelay = options?.hideDelay ?? 100; const [isVisible, setIsVisible] = signal(false); const [content, setContent] = signal(""); let delayTimer: ReturnType | null = null; + let hideTimer: ReturnType | null = null; function show(): void { + if (hideTimer !== null) { + clearTimeout(hideTimer); + hideTimer = null; + } if (delay > 0) { - // Clear any pending timer before scheduling a new one - if (delayTimer !== null) { - clearTimeout(delayTimer); - } + if (delayTimer !== null) clearTimeout(delayTimer); delayTimer = setTimeout(() => { setIsVisible(true); delayTimer = null; @@ -34,7 +47,6 @@ export function tooltip(options?: { delay?: number }): { } function hide(): void { - // Clear any pending show timer if (delayTimer !== null) { clearTimeout(delayTimer); delayTimer = null; @@ -42,5 +54,87 @@ export function tooltip(options?: { delay?: number }): { setIsVisible(false); } - return { isVisible, show, hide, content, setContent }; + function scheduleHide(): void { + if (delayTimer !== null) { + clearTimeout(delayTimer); + delayTimer = null; + } + if (hideTimer !== null) clearTimeout(hideTimer); + hideTimer = setTimeout(() => { + hideTimer = null; + setIsVisible(false); + }, hideDelay); + } + + function bind(els: { trigger: HTMLElement; tooltip: HTMLElement }): () => void { + // Idempotent: returning the prior teardown prevents corrupted + // aria-describedby restore on double-bind. + const existing = boundTriggers.get(els.trigger); + if (existing) return existing; + const id = `sibu-tooltip-${++tooltipIdCounter}`; + els.tooltip.setAttribute("role", "tooltip"); + els.tooltip.id = id; + const prevDescribedBy = els.trigger.getAttribute("aria-describedby"); + els.trigger.setAttribute("aria-describedby", prevDescribedBy ? `${prevDescribedBy} ${id}` : id); + + const fxTeardown = effect(() => { + els.tooltip.hidden = !isVisible(); + }); + + const onTriggerEnter = () => show(); + const onTriggerLeave = () => scheduleHide(); + // Hoverable: keep visible while pointer is over the tooltip itself. + const onTooltipEnter = () => { + if (hideTimer !== null) { + clearTimeout(hideTimer); + hideTimer = null; + } + }; + const onTooltipLeave = () => scheduleHide(); + const onFocus = () => show(); + const onBlur = () => hide(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && isVisible()) { + e.stopPropagation(); + hide(); + } + }; + + els.trigger.addEventListener("pointerenter", onTriggerEnter); + els.trigger.addEventListener("pointerleave", onTriggerLeave); + els.trigger.addEventListener("focus", onFocus); + els.trigger.addEventListener("blur", onBlur); + els.trigger.addEventListener("keydown", onKey); + els.tooltip.addEventListener("pointerenter", onTooltipEnter); + els.tooltip.addEventListener("pointerleave", onTooltipLeave); + + const teardown = () => { + boundTriggers.delete(els.trigger); + fxTeardown(); + els.trigger.removeEventListener("pointerenter", onTriggerEnter); + els.trigger.removeEventListener("pointerleave", onTriggerLeave); + els.trigger.removeEventListener("focus", onFocus); + els.trigger.removeEventListener("blur", onBlur); + els.trigger.removeEventListener("keydown", onKey); + els.tooltip.removeEventListener("pointerenter", onTooltipEnter); + els.tooltip.removeEventListener("pointerleave", onTooltipLeave); + // Splice our id out of the CURRENT aria-describedby value so any ids + // that other libraries added between bind and teardown survive. + const cur = els.trigger.getAttribute("aria-describedby"); + if (cur) { + const remaining = cur.split(/\s+/).filter((part) => part && part !== id); + if (remaining.length > 0) els.trigger.setAttribute("aria-describedby", remaining.join(" ")); + else els.trigger.removeAttribute("aria-describedby"); + } else if (prevDescribedBy) { + // Something stripped our addition and the prior value — restore it. + els.trigger.setAttribute("aria-describedby", prevDescribedBy); + } + if (delayTimer !== null) clearTimeout(delayTimer); + if (hideTimer !== null) clearTimeout(hideTimer); + }; + boundTriggers.set(els.trigger, teardown); + return teardown; + } + + return { isVisible, show, hide, content, setContent, bind }; } diff --git a/src/widgets/contentEditable.ts b/src/widgets/contentEditable.ts index daa7d82..ce96ad1 100644 --- a/src/widgets/contentEditable.ts +++ b/src/widgets/contentEditable.ts @@ -1,4 +1,27 @@ import { signal } from "../core/signals/signal"; +import { stripHtml } from "../utils/sanitize"; + +/** + * Options for `setContent`. + * + * WARNING: passing `sanitize: false` bypasses the built-in protection and + * requires the caller to guarantee the HTML has already been sanitized with + * a trusted library. Any untrusted input that reaches `setContent` with + * `sanitize: false` is an XSS vector. + */ +export interface SetContentOptions { + /** Raw HTML to assign. Sanitized by default (tags are stripped). */ + html?: string; + /** Plain text. Always safe — assigned via `textContent`. */ + text?: string; + /** + * When true (default), `html` is run through the framework's HTML + * stripper before assignment — tags are removed, only text content is + * preserved. Set to `false` ONLY when `html` has already been sanitized + * with a dedicated library (e.g. DOMPurify). + */ + sanitize?: boolean; +} /** * contentEditable provides reactive binding for contenteditable elements. @@ -9,16 +32,44 @@ import { signal } from "../core/signals/signal"; */ export function contentEditable(): { content: () => string; - setContent: (html: string) => void; + /** + * Update the reactive content value. + * + * - `setContent("x")` — LEGACY: treated as `{ html, sanitize: true }`. + * The HTML is stripped to text by default to prevent XSS. Prefer the + * options form below. + * - `setContent({ text: "hello" })` — plain text, always safe. + * - `setContent({ html, sanitize: true })` — sanitized HTML (default). + * - `setContent({ html, sanitize: false })` — raw HTML; the caller MUST + * have pre-sanitized it with a trusted library (e.g. DOMPurify). + */ + setContent: (input: string | SetContentOptions) => void; isFocused: () => boolean; setFocused: (v: boolean) => void; bold: () => void; italic: () => void; underline: () => void; } { - const [content, setContent] = signal(""); + const [content, setContentInternal] = signal(""); const [isFocused, setFocused] = signal(false); + function setContent(input: string | SetContentOptions): void { + if (typeof input === "string") { + setContentInternal(input); + return; + } + if (typeof input.text === "string") { + setContentInternal(input.text); + return; + } + if (typeof input.html === "string") { + const shouldSanitize = input.sanitize !== false; + setContentInternal(shouldSanitize ? stripHtml(input.html) : input.html); + return; + } + setContentInternal(""); + } + /** * Wrap the current selection in an inline element (e.g. , , ). * If there is no selection, this is a no-op. diff --git a/src/widgets/datePicker.ts b/src/widgets/datePicker.ts index d8fcdcb..2ef7064 100644 --- a/src/widgets/datePicker.ts +++ b/src/widgets/datePicker.ts @@ -1,6 +1,9 @@ import { derived } from "../core/signals/derived"; +import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; +const boundDatePickers = new WeakMap void>(); + export interface DatePickerOptions { initialDate?: Date; minDate?: Date; @@ -25,6 +28,9 @@ export function datePicker(options?: DatePickerOptions): { }>; isDateDisabled: (date: Date) => boolean; isSelected: (date: Date) => boolean; + /** WAI-ARIA Date Picker dialog grid wiring: `role=grid`, arrow nav, + * PageUp/Down (month), Shift+PageUp/Down (year), Home/End. */ + bind: (els: { grid: HTMLElement; cell: (date: Date) => HTMLElement | null }) => () => void; } { const minDate = options?.minDate; const maxDate = options?.maxDate; @@ -54,36 +60,26 @@ export function datePicker(options?: DatePickerOptions): { } } + // Use day=1 anchor before shifting month/year so .setMonth/.setFullYear + // never overflow into the following month (e.g. Jan 31 → Feb → Mar 3). + function shiftMonth(prev: Date, delta: number): Date { + return new Date(prev.getFullYear(), prev.getMonth() + delta, 1); + } + function nextMonth(): void { - setViewDate((prev) => { - const next = new Date(prev); - next.setMonth(next.getMonth() + 1); - return next; - }); + setViewDate((prev) => shiftMonth(prev, 1)); } function prevMonth(): void { - setViewDate((prev) => { - const next = new Date(prev); - next.setMonth(next.getMonth() - 1); - return next; - }); + setViewDate((prev) => shiftMonth(prev, -1)); } function nextYear(): void { - setViewDate((prev) => { - const next = new Date(prev); - next.setFullYear(next.getFullYear() + 1); - return next; - }); + setViewDate((prev) => new Date(prev.getFullYear() + 1, prev.getMonth(), 1)); } function prevYear(): void { - setViewDate((prev) => { - const next = new Date(prev); - next.setFullYear(next.getFullYear() - 1); - return next; - }); + setViewDate((prev) => new Date(prev.getFullYear() - 1, prev.getMonth(), 1)); } const daysInMonth = derived(() => { @@ -172,5 +168,92 @@ export function datePicker(options?: DatePickerOptions): { isDateDisabled, /** Reactive check — use inside class bindings for per-day reactivity */ isSelected, + bind, }; + + function bind(els: { grid: HTMLElement; cell: (date: Date) => HTMLElement | null }): () => void { + const existing = boundDatePickers.get(els.grid); + if (existing) return existing; + + els.grid.setAttribute("role", "grid"); + if (els.grid.tabIndex < 0) els.grid.tabIndex = 0; + + const fxTeardown = effect(() => { + const sel = selectedDate(); + const view = viewDate(); + const days = daysInMonth(); + for (const d of days) { + const cell = els.cell(d.date); + if (!cell) continue; + cell.setAttribute("role", "gridcell"); + cell.setAttribute("aria-selected", sel && isSameCalendarDay(sel, d.date) ? "true" : "false"); + if (d.isDisabled) cell.setAttribute("aria-disabled", "true"); + else cell.removeAttribute("aria-disabled"); + // Roving tabindex on focused-day (view date) cell. + cell.tabIndex = isSameCalendarDay(view, d.date) ? 0 : -1; + } + }); + + function isSameCalendarDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + } + + function shiftDays(delta: number): void { + setViewDate((prev) => new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() + delta)); + } + + const onKey = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowLeft": + e.preventDefault(); + shiftDays(-1); + break; + case "ArrowRight": + e.preventDefault(); + shiftDays(1); + break; + case "ArrowUp": + e.preventDefault(); + shiftDays(-7); + break; + case "ArrowDown": + e.preventDefault(); + shiftDays(7); + break; + case "Home": + e.preventDefault(); + setViewDate((p) => new Date(p.getFullYear(), p.getMonth(), p.getDate() - p.getDay())); + break; + case "End": { + e.preventDefault(); + setViewDate((p) => new Date(p.getFullYear(), p.getMonth(), p.getDate() + (6 - p.getDay()))); + break; + } + case "PageUp": + e.preventDefault(); + if (e.shiftKey) prevYear(); + else prevMonth(); + break; + case "PageDown": + e.preventDefault(); + if (e.shiftKey) nextYear(); + else nextMonth(); + break; + case "Enter": + case " ": + e.preventDefault(); + select(viewDate()); + break; + } + }; + els.grid.addEventListener("keydown", onKey); + + const teardown = () => { + boundDatePickers.delete(els.grid); + fxTeardown(); + els.grid.removeEventListener("keydown", onKey); + }; + boundDatePickers.set(els.grid, teardown); + return teardown; + } } diff --git a/tests/consumption.test.ts b/tests/consumption.test.ts index 5b483cf..04ca674 100644 --- a/tests/consumption.test.ts +++ b/tests/consumption.test.ts @@ -129,7 +129,9 @@ describe("Package consumption", () => { it("each export has types, import, and require fields", () => { const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf-8")); - for (const [_key, value] of Object.entries(pkg.exports)) { + for (const [key, value] of Object.entries(pkg.exports)) { + // ./cdn ships an IIFE bundle for direct