From 81313296e668466b7d0efbdb96ef1f149859feff Mon Sep 17 00:00:00 2001 From: ochairo <19258508+ochairo@users.noreply.github.com> Date: Wed, 13 May 2026 00:28:45 +0900 Subject: [PATCH 1/5] docs: standardize OSS templates and contributor workflow across library repos --- .github/ISSUE_TEMPLATE/bug_report.yml | 109 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/documentation.yml | 51 ++++++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 78 +++++++++++++++ .github/ISSUE_TEMPLATE/support_request.yml | 60 ++++++++++++ .github/pull_request_template.md | 27 +++++ .gitignore | 6 -- CONTRIBUTING.md | 18 +++- SECURITY.md | 27 +++++ SUPPORT.md | 21 ++++ 10 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/support_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9fb3f90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,109 @@ +name: Bug report +description: Report a reproducible bug with enough detail to investigate it quickly +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for the report. + + Please include a minimal reproduction whenever possible. + For security vulnerabilities, do not open a public issue. Use the repository security policy instead. + - type: input + id: package_name + attributes: + label: Affected package + description: Which package or module is affected? + placeholder: "@ochairo/beat" + validations: + required: true + - type: input + id: package_version + attributes: + label: Package version + description: Include the exact published version or commit when possible + placeholder: 1.6.6 + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Regression? + description: Did this work in an earlier version? + options: + - "Yes" + - "No" + - "Not sure" + validations: + required: true + - type: dropdown + id: impact + attributes: + label: Impact + description: How severe is this for your workflow? + options: + - Blocks usage or release + - Major incorrect behavior + - Limited workaround exists + - Minor issue or polish problem + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Runtime, OS, browser, framework, bundler, package manager, and anything else that matters + placeholder: | + Node 20.19.1 + pnpm 10.11.0 + macOS 15.4 + Chrome 136 + Vite 6 + validations: + required: true + - type: input + id: reproduction_link + attributes: + label: Reproduction link + description: Link to a repo, StackBlitz, CodeSandbox, or gist if available + placeholder: https://github.com/... + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Provide the shortest sequence that reproduces the bug + render: shell + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Stack traces, screenshots, failing assertions, bisect notes, or workarounds + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I searched the existing issues for duplicates + required: true + - label: I reproduced this on a current release or current `develop` + required: true + - label: I included a minimal reproduction or explained why I could not + required: true + - label: This is not a security vulnerability or private disclosure issue + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0c3e72f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability report + url: https://github.com/ochairo/beat/security/policy + about: Report vulnerabilities privately instead of opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..872ac10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,51 @@ +name: Documentation improvement +description: Report missing, confusing, or incorrect documentation +title: "[Docs]: " +labels: + - documentation +body: + - type: markdown + attributes: + value: | + Use this form for documentation gaps, unclear explanations, incorrect examples, or missing migration notes. + - type: input + id: package_name + attributes: + label: Package + placeholder: "@ochairo/beat" + validations: + required: true + - type: input + id: page + attributes: + label: Page, file, or section + description: Link or path to the docs page, README section, or source file + placeholder: README.md#usage + - type: textarea + id: problem + attributes: + label: What is wrong or missing? + description: Describe the confusing, outdated, or missing content + validations: + required: true + - type: textarea + id: improvement + attributes: + label: Proposed improvement + description: What should the docs say or show instead? + validations: + required: true + - type: textarea + id: examples + attributes: + label: Example or reference material + description: Sample wording, code, screenshots, or links to similar docs + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I searched existing issues for related documentation requests + required: true + - label: I included the page, file, or section when known + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..cf9e8fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,78 @@ +name: Feature request +description: Propose a new capability or improvement with clear user value and scope +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion. + + Start with the problem you are solving, then describe the proposed API or behavior. + Strong requests usually explain why the change belongs in this package instead of app-level code. + - type: input + id: package_name + attributes: + label: Target package + placeholder: "@ochairo/beat" + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem statement + description: What limitation, friction, or missing capability are you hitting? + validations: + required: true + - type: textarea + id: use_case + attributes: + label: Use case + description: What are you trying to build or enable? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the API, behavior, ergonomics, or workflow you want + validations: + required: true + - type: textarea + id: api_sketch + attributes: + label: API sketch or example + description: Optional example showing the desired usage + render: ts + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Existing workarounds, competing designs, or reasons not to do this + - type: dropdown + id: breaking_change + attributes: + label: Breaking change risk + options: + - No + - Possibly + - Yes + validations: + required: true + - type: textarea + id: impact + attributes: + label: Ecosystem impact + description: Effects on users, docs, examples, integrations, or migration paths + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I searched the existing issues for related requests + required: true + - label: I described the problem before proposing a solution + required: true + - label: I explained why this should live in the package rather than app code + required: true diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.github/ISSUE_TEMPLATE/support_request.yml new file mode 100644 index 0000000..e763426 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support_request.yml @@ -0,0 +1,60 @@ +name: Support request +description: Ask for usage guidance or troubleshooting help +title: "[Support]: " +labels: + - question +body: + - type: markdown + attributes: + value: | + Use this form for troubleshooting, API usage questions, or help choosing an approach. + + For security vulnerabilities, do not open a public issue. Use the repository security policy instead. + - type: input + id: package_name + attributes: + label: Package + placeholder: "@ochairo/beat" + validations: + required: true + - type: input + id: package_version + attributes: + label: Package version + placeholder: 1.6.6 + - type: textarea + id: goal + attributes: + label: What are you trying to do? + description: Describe the outcome you want, not only the API you tried first + validations: + required: true + - type: textarea + id: tried + attributes: + label: What have you tried already? + description: Include relevant code, docs you followed, and where the result diverged + render: ts + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Runtime, OS, browser, framework, bundler, package manager, and related package versions + - type: textarea + id: context + attributes: + label: Additional context + description: Error output, screenshots, repository links, or constraints + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I read the README and relevant docs first + required: true + - label: I searched existing issues for related questions + required: true + - label: This is not a security vulnerability or private disclosure issue + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..859f6a8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## Summary + +- + +## Linked Issue + +- Closes # + +## Validation + +List the checks you ran and the result. If a command was skipped, explain why. + +```sh +pnpm validate +``` + +- [ ] `pnpm validate` +- [ ] `pnpm test` +- [ ] `pnpm typecheck` +- [ ] `pnpm build` +- [ ] Manual verification +- [ ] Not applicable + +## Release Notes + +- User-facing summary: +- Migration or breaking-change notes: diff --git a/.gitignore b/.gitignore index 7489407..04c7b9f 100644 --- a/.gitignore +++ b/.gitignore @@ -309,9 +309,3 @@ tsconfig.local.json # Turborepo cache .turbo/ - -# ============================================ -# OTHER PROJECT-SPECIFIC IGNORES -# ============================================ -.github/* -!.github/workflows/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c1c8d0..6dc52b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,11 +42,19 @@ Entry points are exported from `src/index.ts`. The JSX runtime subpaths (`jsx-ru ## Pull Request Guidelines -1. Fork the repo and create a branch from `main` -2. Write tests for any new functionality -3. Make sure `pnpm validate` passes -4. Keep PRs focused — one feature or fix per PR -5. Write clear commit messages +1. Start by opening or confirming the issue you are working on +2. Fork the repo and create a branch from `develop` using the format `feature/` +3. Open pull requests against `develop`. Contributor changes should not target `main` directly +4. Write tests for any new functionality +5. Make sure `pnpm validate` passes +6. Keep PRs focused — one feature or fix per PR +7. Write clear commit messages + +## Release Flow + +Regular development happens on `develop`. +Contributors should create an issue first, then branch from `develop` as `feature/` and open the pull request back into `develop`. +To publish a release, maintainers merge `develop` into `main` and create the release tag from `main`. ## Code Style diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c2d7f62 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported Versions + +Security fixes are prioritized for: + +- the latest published release +- the current `develop` branch + +Older releases may not receive backported fixes. + +Releases are published by merging `develop` into `main` and creating a tag from `main`. + +## Reporting a Vulnerability + +Please do not open public GitHub issues for security vulnerabilities. + +Use GitHub's private vulnerability reporting flow if it is enabled for this repository. If private reporting is not available in the repository UI, contact the maintainers directly through GitHub before any public disclosure. + +Include the following in your report when possible: + +- affected package and version +- impact and attack surface +- reproduction steps or proof of concept +- any known mitigation or workaround + +Valid reports will be acknowledged, investigated privately, and addressed in a release or advisory when a fix is available. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..c43e739 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,21 @@ +# Support + +## Before Opening an Issue + +- Read the README and any package-specific docs first +- Search existing issues and pull requests for duplicates +- Prepare a minimal reproduction when reporting a bug + +## Where to Ask for Help + +- Reproducible bugs and actionable feature requests: GitHub Issues +- Contribution process and expectations: `CONTRIBUTING.md` +- Security vulnerabilities: `SECURITY.md` + +## Good Reports Include + +- package version +- Node.js and pnpm versions +- OS, browser, or runtime details +- minimal reproduction +- expected behavior and actual behavior From 297e2825917a02db519baf78533385ea87866a4d Mon Sep 17 00:00:00 2001 From: ochairo <19258508+ochairo@users.noreply.github.com> Date: Thu, 14 May 2026 01:46:16 +0900 Subject: [PATCH 2/5] release: beat@1.6.10 --- README.md | 14 ++++++++++++-- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- src/dom.ts | 19 ++++++++++++++++--- src/jsx-runtime.ts | 31 ++++++++++++++++++++++++++++--- tests/dom.test.ts | 36 ++++++++++++++++++++++++++++++++++++ tests/render.test.ts | 11 +++++++++++ 7 files changed, 110 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b6a2497..c183972 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@

beat

-[Pulse](https://github.com/ochairo/pulse?tab=readme-ov-file#-pulse)-native JSX framework for direct-DOM applications.
-_Fine-grained rendering with explicit routing, async primitives, and SSR._ +JSX framework built on [Pulse](https://github.com/ochairo/pulse?tab=readme-ov-file#-pulse) for predictable apps with local updates.
+_Run-once components, local updates, explicit routing, async resources, and SSR._ [![npm version](https://img.shields.io/npm/v/@ochairo/beat)](https://www.npmjs.com/package/@ochairo/beat) [![npm downloads](https://img.shields.io/npm/dm/@ochairo/beat)](https://www.npmjs.com/package/@ochairo/beat) @@ -16,6 +16,16 @@ _Fine-grained rendering with explicit routing, async primitives, and SSR._ +## Overview + +Beat is built for apps that should stay understandable as they grow. +The public model stays explicit: + +- **Run-once components** — a component behaves like setup code, not a rerender loop +- **Local updates** — Pulse exact-path subscriptions update only the binding that depends on the changed value +- **Explicit router and resources** — routing state and async state stay in normal userland APIs +- **Shared client/server model** — the same component tree and router power rendering, hydration, and SSR + ## Scaffold Start a new app with the scaffolder: diff --git a/package.json b/package.json index 1f56aa6..a6aaa14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ochairo/beat", - "version": "1.6.6", + "version": "1.6.10", "description": "Pulse-native SPA runtime for DOM-first applications", "type": "module", "main": "./dist/index.js", @@ -64,7 +64,7 @@ "url": "https://github.com/ochairo/beat/issues" }, "dependencies": { - "@ochairo/pulse": "^1.6.6" + "@ochairo/pulse": "^1.6.10" }, "devDependencies": { "@types/node": "^20.10.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bfd618..27d50be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@ochairo/pulse': - specifier: ^1.6.6 - version: 1.6.6 + specifier: ^1.6.10 + version: 1.6.10 devDependencies: '@types/node': specifier: ^20.10.5 @@ -192,8 +192,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@ochairo/pulse@1.6.6': - resolution: {integrity: sha512-snhjTEtlX7jvw9s019Ff9J1ARHqmKLIRCnproYQZEiNIkCSsBtICSozcZe8/zJRIFfBSwaNmk31rdtprQQrobA==} + '@ochairo/pulse@1.6.10': + resolution: {integrity: sha512-mwR0rNcYD5yRoKcyvD3bHi4gO3DRHN5W89kt7ZDtRGGL1pvm/+etwfnFStTVw+/v2xbKB/HEjvlB4Sxd8SeD+Q==} engines: {node: '>=20.0.0'} '@rollup/rollup-android-arm-eabi@4.60.1': @@ -737,7 +737,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@ochairo/pulse@1.6.6': {} + '@ochairo/pulse@1.6.10': {} '@rollup/rollup-android-arm-eabi@4.60.1': optional: true diff --git a/src/dom.ts b/src/dom.ts index 0c6039e..e132109 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -40,12 +40,25 @@ function createPropertyWriter( const dynamicElement = element as unknown as Record; const writeAsProperty = canWriteAsProperty(element, propertyName); + const clearProperty = (): void => { + if (!writeAsProperty) { + return; + } + + if (propertyName === "value") { + dynamicElement[propertyName] = ""; + return; + } + + if (propertyName === "checked" || propertyName === "selected") { + dynamicElement[propertyName] = false; + } + }; + return (value: unknown): void => { if (value === null || value === undefined) { element.removeAttribute(propertyName); - if (writeAsProperty) { - dynamicElement[propertyName] = undefined; - } + clearProperty(); return; } diff --git a/src/jsx-runtime.ts b/src/jsx-runtime.ts index bd5d858..1ee2ef2 100644 --- a/src/jsx-runtime.ts +++ b/src/jsx-runtime.ts @@ -267,12 +267,25 @@ function createPropertyOrAttributeApplier( }; } + const clearProperty = (): void => { + if (!writeAsProperty) { + return; + } + + if (propertyName === "value") { + dynamicElement[propertyName] = ""; + return; + } + + if (propertyName === "checked" || propertyName === "selected") { + dynamicElement[propertyName] = false; + } + }; + return (value: unknown): void => { if (value === null || value === undefined) { element.removeAttribute(propertyName); - if (writeAsProperty) { - dynamicElement[propertyName] = undefined; - } + clearProperty(); return; } @@ -688,6 +701,8 @@ export function forEach( const unsubscribeItems = items.on((event) => { if ( + Array.isArray(event.currentValue) && + Array.isArray(event.previousValue) && isStructuralArrayChange(event.changes) && event.currentValue.length === event.previousValue.length ) { @@ -700,6 +715,16 @@ export function forEach( return; } + if (!Array.isArray(event.currentValue)) { + const parent = end.parentNode; + if (!parent) { + return; + } + + mountEntries(items.get(), parent); + return; + } + updateStableEntries(event.currentValue); }); diff --git a/tests/dom.test.ts b/tests/dom.test.ts index c8593d7..c694d21 100644 --- a/tests/dom.test.ts +++ b/tests/dom.test.ts @@ -110,6 +110,26 @@ describe("dom bindings", () => { rendered.cleanup?.(); }); + it("For survives ancestor replacements that update a keyed array", () => { + const state = pulse({ rows: [{ id: 1, label: "Ada" }] }); + const rendered = For({ + each: state.rows as Pulse, + key: (value) => value.id, + children: (row) => jsx("span", { children: row.label }), + }); + + const host = document.createElement("div"); + host.append(rendered.node); + + expect(host.textContent).toBe("Ada"); + + state.set({ rows: [{ id: 1, label: "Grace" }] }); + + expect(host.textContent).toBe("Grace"); + + rendered.cleanup?.(); + }); + it("jsx binds pulse children and event listeners", () => { const label = pulse("Save"); let clicked = 0; @@ -246,6 +266,22 @@ describe("dom bindings", () => { cleanupChecked(); }); + it("bindProperty clears id when the pulse becomes undefined", () => { + const id = pulse("root"); + const element = document.createElement("div"); + + const cleanup = bindProperty(element, "id", id); + + expect(element.id).toBe("root"); + + id.set(undefined); + + expect(element.hasAttribute("id")).toBe(false); + expect(element.id).toBe(""); + + cleanup(); + }); + it("Show swaps branches and cleans up the previous branch", () => { const visible = pulse(true); const host = document.createElement("div"); diff --git a/tests/render.test.ts b/tests/render.test.ts index 18a9a89..fbb2773 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -62,6 +62,17 @@ describe("render root", () => { expect(target.textContent).toBe(""); }); + it("omits undefined id attributes instead of rendering the string undefined", () => { + const target = document.createElement("div"); + + render(target, jsx("div", { id: undefined, children: "content" })); + + const child = target.firstElementChild as HTMLDivElement | null; + expect(child).not.toBeNull(); + expect(child?.hasAttribute("id")).toBe(false); + expect(child?.id).toBe(""); + }); + it("component scope cleanup runs when a root is destroyed", () => { const target = document.createElement("div"); let cleanupCount = 0; From da324e916ff45d528a5e026f5aacd2b3fdc7a930 Mon Sep 17 00:00:00 2001 From: ochairo <19258508+ochairo@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:36:35 +0900 Subject: [PATCH 3/5] fix(security): enforce route guards, secure SSR, and fix router races --- .github/workflows/publish.yml | 15 +- .github/workflows/validate.yml | 18 +- README.md | 73 +++- package.json | 14 +- pnpm-lock.yaml | 764 +++++++++++++++++---------------- src/router.ts | 218 ++++++---- src/server.ts | 86 +++- tests/router-security.test.ts | 321 ++++++++++++++ tests/server-security.test.ts | 87 ++++ 9 files changed, 1110 insertions(+), 486 deletions(-) create mode 100644 tests/router-security.test.ts create mode 100644 tests/server-security.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 641b07e..ed4ad62 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,22 +15,25 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: - version: 10 + version: 10.34.7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version: "24.x" + check-latest: true cache: pnpm registry-url: https://registry.npmjs.org - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Verify tag matches package version run: | diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 93dd436..6f6c349 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -6,27 +6,33 @@ on: - main pull_request: +permissions: + contents: read + jobs: validate: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: - version: 10 + version: 10.34.7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version: "24.x" + check-latest: true cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Run validation run: pnpm validate diff --git a/README.md b/README.md index c183972..25113e7 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,29 @@ import { renderToString, waitForRouter } from "@ochairo/beat/server"; export async function render(url: string): Promise { const win = new Window({ url }); - globalThis.document = win.document as unknown as Document; - const router = createRouter({ routes, initialUrl: url }); - await waitForRouter(router, { signal: AbortSignal.timeout(5_000) }); - const html = renderToString(() => ); - - win.happyDOM.close(); - return `
${html}
`; + try { + await waitForRouter(router, { signal: AbortSignal.timeout(5_000) }); + const previousGlobals = { + document: globalThis.document, Node: globalThis.Node, + HTMLElement: globalThis.HTMLElement, SVGElement: globalThis.SVGElement, + DocumentFragment: globalThis.DocumentFragment, + }; + Object.assign(globalThis, { + document: win.document, Node: win.Node, + HTMLElement: win.HTMLElement, SVGElement: win.SVGElement, + DocumentFragment: win.DocumentFragment, + }); + try { + const html = renderToString(() => ); + return `
${html}
`; + } finally { + Object.assign(globalThis, previousGlobals); + } + } finally { + router.dispose(); + await win.happyDOM.close(); + } } // entry-client.ts @@ -107,3 +122,47 @@ hydrate(document.getElementById("app")!, ); - `waitForRouter` — waits for route loaders to settle before rendering; accepts `AbortSignal` - `renderToString` — takes a factory `() => JSX`; suppresses `onMount` on the server - `hydrate` — single atomic swap from server HTML to live Beat tree, no blank frame + +### SSR security boundaries + +`renderToString` rejects raw-text elements (`script`, `style`, `xmp`, `iframe`, +`noembed`, `noframes`, and `noscript`) whose text contains `<`, whose children are +not text, or which appear inside foreign content, `select`, or `frameset`. +`textarea` and `title` also require text-only children; `plaintext` is unsupported. +Validation includes template content, and rejection still disposes the rendered tree. + +Use external scripts/styles for code containing `<` (including JavaScript comparisons). +For JSON embedded in a script data block, encode `<` in the serialized string as +`\u003c`. Do not substitute that escape for a JavaScript operator. Ordinary text +keeps native HTML escaping. This boundary does not sanitize caller-supplied DOM +attributes, executable URLs, JavaScript, CSS, or explicitly assigned HTML. + +Install a request's matching DOM globals only during synchronous rendering, after +awaiting loaders, and restore the previous globals in `finally`. `document` alone +is insufficient: Beat also uses the DOM constructors shown above. Do not share +mutable request DOM globals across asynchronous work. Dispose each request's router and DOM. + +### Route guard admission + +Guards run from parent to child on the main branch, then on matched named branches, +before loaders, prefetch, history changes, or rendering. A rejected initial URL has +an empty matched branch. Redirects restart admission for the new destination. +Guard `to.route` and `to.depth` identify the owning route; parameters retain the +main matched route's parameter mapping. Malformed encoded parameters match no route. + +Guards control client navigation and SSR rendering; backend data access still needs +authorization. Invalidate prefetch data when the authenticated identity changes. + +`invalidatePrefetch()` cancels all prefetches. With a URL it invalidates that +canonical key directly, without running guards or redirects. After prefetching a +redirecting alias, invalidate its destination or invalidate everything. Canceling +a navigation waiting on prefetch immediately settles it as unmatched and not +loading, even if its loader ignores abort. Navigate or reload to try again. +Prefetch completion rechecks guards before publishing data or restarting loads. +Disposal cancels work and makes subsequent navigation methods inert. + +If admission throws while resuming a pending prefetch navigation, the router +settles unmatched and reports `onError` with `phase: "guard"`. This adds `"guard"` +to `BeatRouteErrorPhase`; update exhaustive phase switches when upgrading. +Synchronous `navigate`/`prefetch` guard exceptions still propagate to the caller. +Application `onError` callbacks must not throw. diff --git a/package.json b/package.json index a6aaa14..8c96b88 100644 --- a/package.json +++ b/package.json @@ -67,14 +67,14 @@ "@ochairo/pulse": "^1.6.10" }, "devDependencies": { - "@types/node": "^20.10.5", - "esbuild": "^0.27.2", - "happy-dom": "^20.0.7", - "typescript": "^5.3.3", - "vite": "^7.1.9", - "vitest": "^4.0.15" + "@types/node": "^24.19.2", + "esbuild": "^0.28.2", + "happy-dom": "^20.14.6", + "typescript": "^5.9.3", + "vite": "^7.3.7", + "vitest": "^4.1.12" }, "engines": { - "node": ">=20.0.0" + "node": "^24.22.1 || >=26.12.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27d50be..ae2bd0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,311 +13,331 @@ importers: version: 1.6.10 devDependencies: '@types/node': - specifier: ^20.10.5 - version: 20.19.37 + specifier: ^24.19.2 + version: 24.19.2 esbuild: - specifier: ^0.27.2 - version: 0.27.4 + specifier: ^0.28.2 + version: 0.28.2 happy-dom: - specifier: ^20.0.7 - version: 20.8.9 + specifier: ^20.14.6 + version: 20.14.6 typescript: - specifier: ^5.3.3 + specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^7.1.9 - version: 7.3.1(@types/node@20.19.37)(lightningcss@1.32.0) + specifier: ^7.3.7 + version: 7.3.7(@types/node@24.19.2)(lightningcss@1.32.0) vitest: - specifier: ^4.0.15 - version: 4.1.2(@types/node@20.19.37)(happy-dom@20.8.9)(vite@7.3.1(@types/node@20.19.37)(lightningcss@1.32.0)) + specifier: ^4.1.12 + version: 4.1.12(@types/node@24.19.2)(happy-dom@20.14.6)(vite@7.3.7(@types/node@24.19.2)(lightningcss@1.32.0)) packages: - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] '@ochairo/pulse@1.6.10': resolution: {integrity: sha512-mwR0rNcYD5yRoKcyvD3bHi4gO3DRHN5W89kt7ZDtRGGL1pvm/+etwfnFStTVw+/v2xbKB/HEjvlB4Sxd8SeD+Q==} engines: {node: '>=20.0.0'} - '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + '@rollup/rollup-android-arm-eabi@4.65.0': + resolution: {integrity: sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + '@rollup/rollup-android-arm64@4.65.0': + resolution: {integrity: sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + '@rollup/rollup-darwin-arm64@4.65.0': + resolution: {integrity: sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + '@rollup/rollup-darwin-x64@4.65.0': + resolution: {integrity: sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + '@rollup/rollup-freebsd-arm64@4.65.0': + resolution: {integrity: sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + '@rollup/rollup-freebsd-x64@4.65.0': + resolution: {integrity: sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + '@rollup/rollup-linux-arm-gnueabihf@4.65.0': + resolution: {integrity: sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + '@rollup/rollup-linux-arm-musleabihf@4.65.0': + resolution: {integrity: sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + '@rollup/rollup-linux-arm64-gnu@4.65.0': + resolution: {integrity: sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + '@rollup/rollup-linux-arm64-musl@4.65.0': + resolution: {integrity: sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + '@rollup/rollup-linux-loong64-gnu@4.65.0': + resolution: {integrity: sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + '@rollup/rollup-linux-loong64-musl@4.65.0': + resolution: {integrity: sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==} cpu: [loong64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + '@rollup/rollup-linux-ppc64-gnu@4.65.0': + resolution: {integrity: sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + '@rollup/rollup-linux-ppc64-musl@4.65.0': + resolution: {integrity: sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==} cpu: [ppc64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + '@rollup/rollup-linux-riscv64-gnu@4.65.0': + resolution: {integrity: sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + '@rollup/rollup-linux-riscv64-musl@4.65.0': + resolution: {integrity: sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + '@rollup/rollup-linux-s390x-gnu@4.65.0': + resolution: {integrity: sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + '@rollup/rollup-linux-x64-gnu@4.65.0': + resolution: {integrity: sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + '@rollup/rollup-linux-x64-musl@4.65.0': + resolution: {integrity: sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==} cpu: [x64] os: [linux] + libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + '@rollup/rollup-openbsd-x64@4.65.0': + resolution: {integrity: sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + '@rollup/rollup-openharmony-arm64@4.65.0': + resolution: {integrity: sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + '@rollup/rollup-win32-arm64-msvc@4.65.0': + resolution: {integrity: sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + '@rollup/rollup-win32-ia32-msvc@4.65.0': + resolution: {integrity: sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + '@rollup/rollup-win32-x64-gnu@4.65.0': + resolution: {integrity: sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + '@rollup/rollup-win32-x64-msvc@4.65.0': + resolution: {integrity: sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==} cpu: [x64] os: [win32] @@ -330,23 +350,23 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@20.19.37': - resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} - '@vitest/expect@4.1.2': - resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + '@vitest/expect@4.1.12': + resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==} - '@vitest/mocker@4.1.2': - resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + '@vitest/mocker@4.1.12': + resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -356,27 +376,31 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.2': - resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + '@vitest/pretty-format@4.1.12': + resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==} - '@vitest/runner@4.1.2': - resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + '@vitest/runner@4.1.12': + resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==} - '@vitest/snapshot@4.1.2': - resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + '@vitest/snapshot@4.1.12': + resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==} - '@vitest/spy@4.1.2': - resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + '@vitest/spy@4.1.12': + resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==} - '@vitest/utils@4.1.2': - resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@vitest/utils@4.1.12': + resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} convert-source-map@2.0.0: @@ -390,19 +414,19 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} fdir@6.5.0: @@ -419,8 +443,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - happy-dom@20.8.9: - resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} + happy-dom@20.14.6: + resolution: {integrity: sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q==} engines: {node: '>=20.0.0'} lightningcss-android-arm64@1.32.0: @@ -458,24 +482,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -496,13 +524,14 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -510,45 +539,45 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} - rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + rollup@4.65.0: + resolution: {integrity: sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.4: - resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} typescript@5.9.3: @@ -556,11 +585,11 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.7: + resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -599,18 +628,20 @@ packages: yaml: optional: true - vitest@4.1.2: - resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + vitest@4.1.12: + resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.2 - '@vitest/browser-preview': 4.1.2 - '@vitest/browser-webdriverio': 4.1.2 - '@vitest/ui': 4.1.2 + '@vitest/browser-playwright': 4.1.12 + '@vitest/browser-preview': 4.1.12 + '@vitest/browser-webdriverio': ^4.1.11 + '@vitest/coverage-istanbul': 4.1.12 + '@vitest/coverage-v8': 4.1.12 + '@vitest/ui': 4.1.12 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -627,6 +658,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -643,8 +678,8 @@ packages: engines: {node: '>=8'} hasBin: true - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -657,161 +692,164 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.27.4': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.27.4': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.27.4': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.27.4': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.27.4': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.27.4': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.27.4': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.27.4': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.27.4': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.27.4': + '@esbuild/win32-x64@0.28.2': optional: true - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true '@ochairo/pulse@1.6.10': {} - '@rollup/rollup-android-arm-eabi@4.60.1': + '@rollup/rollup-android-arm-eabi@4.65.0': optional: true - '@rollup/rollup-android-arm64@4.60.1': + '@rollup/rollup-android-arm64@4.65.0': optional: true - '@rollup/rollup-darwin-arm64@4.60.1': + '@rollup/rollup-darwin-arm64@4.65.0': optional: true - '@rollup/rollup-darwin-x64@4.60.1': + '@rollup/rollup-darwin-x64@4.65.0': optional: true - '@rollup/rollup-freebsd-arm64@4.60.1': + '@rollup/rollup-freebsd-arm64@4.65.0': optional: true - '@rollup/rollup-freebsd-x64@4.60.1': + '@rollup/rollup-freebsd-x64@4.65.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + '@rollup/rollup-linux-arm-gnueabihf@4.65.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.1': + '@rollup/rollup-linux-arm-musleabihf@4.65.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.1': + '@rollup/rollup-linux-arm64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.1': + '@rollup/rollup-linux-arm64-musl@4.65.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.1': + '@rollup/rollup-linux-loong64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.1': + '@rollup/rollup-linux-loong64-musl@4.65.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.1': + '@rollup/rollup-linux-ppc64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.1': + '@rollup/rollup-linux-ppc64-musl@4.65.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.1': + '@rollup/rollup-linux-riscv64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.1': + '@rollup/rollup-linux-riscv64-musl@4.65.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.1': + '@rollup/rollup-linux-s390x-gnu@4.65.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.1': + '@rollup/rollup-linux-x64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-x64-musl@4.60.1': + '@rollup/rollup-linux-x64-musl@4.65.0': optional: true - '@rollup/rollup-openbsd-x64@4.60.1': + '@rollup/rollup-openbsd-x64@4.65.0': optional: true - '@rollup/rollup-openharmony-arm64@4.60.1': + '@rollup/rollup-openharmony-arm64@4.65.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.1': + '@rollup/rollup-win32-arm64-msvc@4.65.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.1': + '@rollup/rollup-win32-ia32-msvc@4.65.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.1': + '@rollup/rollup-win32-x64-gnu@4.65.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.1': + '@rollup/rollup-win32-x64-msvc@4.65.0': optional: true '@standard-schema/spec@1.1.0': {} @@ -823,62 +861,66 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} - '@types/node@20.19.37': + '@types/node@24.19.2': dependencies: - undici-types: 6.21.0 + undici-types: 7.24.6 '@types/whatwg-mimetype@3.0.2': {} - '@types/ws@8.18.1': + '@types/ws@8.18.2': dependencies: - '@types/node': 20.19.37 + '@types/node': 24.19.2 - '@vitest/expect@4.1.2': + '@vitest/expect@4.1.12': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 - chai: 6.2.2 - tinyrainbow: 3.1.0 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 + chai: 6.3.0 + tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@20.19.37)(lightningcss@1.32.0))': + '@vitest/mocker@4.1.12(vite@7.3.7(@types/node@24.19.2)(lightningcss@1.32.0))': dependencies: - '@vitest/spy': 4.1.2 + '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@20.19.37)(lightningcss@1.32.0) + vite: 7.3.7(@types/node@24.19.2)(lightningcss@1.32.0) - '@vitest/pretty-format@4.1.2': + '@vitest/pretty-format@4.1.12': dependencies: - tinyrainbow: 3.1.0 + tinyrainbow: 3.2.0 - '@vitest/runner@4.1.2': + '@vitest/runner@4.1.12': dependencies: - '@vitest/utils': 4.1.2 + '@vitest/utils': 4.1.12 pathe: 2.0.3 - '@vitest/snapshot@4.1.2': + '@vitest/snapshot@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.2 - '@vitest/utils': 4.1.2 + '@vitest/pretty-format': 4.1.12 + '@vitest/utils': 4.1.12 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.2': {} + '@vitest/spy@4.1.12': {} - '@vitest/utils@4.1.2': + '@vitest/utils@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.2 + '@vitest/pretty-format': 4.1.12 convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.2.0 assertion-error@2.0.1: {} - chai@6.2.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.19.2 + + chai@6.3.0: {} convert-source-map@2.0.0: {} @@ -887,58 +929,59 @@ snapshots: entities@7.0.1: {} - es-module-lexer@2.0.0: {} + es-module-lexer@2.3.2: {} - esbuild@0.27.4: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 - expect-type@1.3.0: {} + expect-type@1.4.0: {} - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 fsevents@2.3.3: optional: true - happy-dom@20.8.9: + happy-dom@20.14.6: dependencies: - '@types/node': 20.19.37 + '@types/node': 24.19.2 '@types/whatwg-mimetype': 3.0.2 - '@types/ws': 8.18.1 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.20.0 + ws: 8.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -995,116 +1038,117 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - nanoid@3.3.11: {} + nanoid@3.3.20: {} - obug@2.1.1: {} + obug@2.2.1: {} pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@4.0.4: {} + picomatch@4.0.7: {} - postcss@8.5.8: + postcss@8.5.31: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 - rollup@4.60.1: + rollup@4.65.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.1 - '@rollup/rollup-android-arm64': 4.60.1 - '@rollup/rollup-darwin-arm64': 4.60.1 - '@rollup/rollup-darwin-x64': 4.60.1 - '@rollup/rollup-freebsd-arm64': 4.60.1 - '@rollup/rollup-freebsd-x64': 4.60.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 - '@rollup/rollup-linux-arm-musleabihf': 4.60.1 - '@rollup/rollup-linux-arm64-gnu': 4.60.1 - '@rollup/rollup-linux-arm64-musl': 4.60.1 - '@rollup/rollup-linux-loong64-gnu': 4.60.1 - '@rollup/rollup-linux-loong64-musl': 4.60.1 - '@rollup/rollup-linux-ppc64-gnu': 4.60.1 - '@rollup/rollup-linux-ppc64-musl': 4.60.1 - '@rollup/rollup-linux-riscv64-gnu': 4.60.1 - '@rollup/rollup-linux-riscv64-musl': 4.60.1 - '@rollup/rollup-linux-s390x-gnu': 4.60.1 - '@rollup/rollup-linux-x64-gnu': 4.60.1 - '@rollup/rollup-linux-x64-musl': 4.60.1 - '@rollup/rollup-openbsd-x64': 4.60.1 - '@rollup/rollup-openharmony-arm64': 4.60.1 - '@rollup/rollup-win32-arm64-msvc': 4.60.1 - '@rollup/rollup-win32-ia32-msvc': 4.60.1 - '@rollup/rollup-win32-x64-gnu': 4.60.1 - '@rollup/rollup-win32-x64-msvc': 4.60.1 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.65.0 + '@rollup/rollup-android-arm64': 4.65.0 + '@rollup/rollup-darwin-arm64': 4.65.0 + '@rollup/rollup-darwin-x64': 4.65.0 + '@rollup/rollup-freebsd-arm64': 4.65.0 + '@rollup/rollup-freebsd-x64': 4.65.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.65.0 + '@rollup/rollup-linux-arm-musleabihf': 4.65.0 + '@rollup/rollup-linux-arm64-gnu': 4.65.0 + '@rollup/rollup-linux-arm64-musl': 4.65.0 + '@rollup/rollup-linux-loong64-gnu': 4.65.0 + '@rollup/rollup-linux-loong64-musl': 4.65.0 + '@rollup/rollup-linux-ppc64-gnu': 4.65.0 + '@rollup/rollup-linux-ppc64-musl': 4.65.0 + '@rollup/rollup-linux-riscv64-gnu': 4.65.0 + '@rollup/rollup-linux-riscv64-musl': 4.65.0 + '@rollup/rollup-linux-s390x-gnu': 4.65.0 + '@rollup/rollup-linux-x64-gnu': 4.65.0 + '@rollup/rollup-linux-x64-musl': 4.65.0 + '@rollup/rollup-openbsd-x64': 4.65.0 + '@rollup/rollup-openharmony-arm64': 4.65.0 + '@rollup/rollup-win32-arm64-msvc': 4.65.0 + '@rollup/rollup-win32-ia32-msvc': 4.65.0 + '@rollup/rollup-win32-x64-gnu': 4.65.0 + '@rollup/rollup-win32-x64-msvc': 4.65.0 fsevents: 2.3.3 siginfo@2.0.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} stackback@0.0.2: {} - std-env@4.0.0: {} + std-env@4.3.0: {} tinybench@2.9.0: {} - tinyexec@1.0.4: {} + tinyexec@1.3.1: {} - tinyglobby@0.2.15: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 - tinyrainbow@3.1.0: {} + tinyrainbow@3.2.0: {} typescript@5.9.3: {} - undici-types@6.21.0: {} + undici-types@7.24.6: {} - vite@7.3.1(@types/node@20.19.37)(lightningcss@1.32.0): + vite@7.3.7(@types/node@24.19.2)(lightningcss@1.32.0): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.1 - tinyglobby: 0.2.15 + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.31 + rollup: 4.65.0 + tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 20.19.37 + '@types/node': 24.19.2 fsevents: 2.3.3 lightningcss: 1.32.0 - vitest@4.1.2(@types/node@20.19.37)(happy-dom@20.8.9)(vite@7.3.1(@types/node@20.19.37)(lightningcss@1.32.0)): + vitest@4.1.12(@types/node@24.19.2)(happy-dom@20.14.6)(vite@7.3.7(@types/node@24.19.2)(lightningcss@1.32.0)): dependencies: - '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@20.19.37)(lightningcss@1.32.0)) - '@vitest/pretty-format': 4.1.2 - '@vitest/runner': 4.1.2 - '@vitest/snapshot': 4.1.2 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 + '@vitest/expect': 4.1.12 + '@vitest/mocker': 4.1.12(vite@7.3.7(@types/node@24.19.2)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.12 + '@vitest/runner': 4.1.12 + '@vitest/snapshot': 4.1.12 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.2.1 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@20.19.37)(lightningcss@1.32.0) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.2.0 + vite: 7.3.7(@types/node@24.19.2)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.19.37 - happy-dom: 20.8.9 + '@types/node': 24.19.2 + happy-dom: 20.14.6 transitivePeerDependencies: - msw @@ -1115,4 +1159,4 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - ws@8.20.0: {} + ws@8.22.0: {} diff --git a/src/router.ts b/src/router.ts index a73046a..d223494 100644 --- a/src/router.ts +++ b/src/router.ts @@ -94,7 +94,7 @@ export interface BeatRouteGuardContext { readonly router: BeatRouter; } -export type BeatRouteErrorPhase = "load" | "render"; +export type BeatRouteErrorPhase = "load" | "render" | "guard"; export interface BeatRouteErrorEvent { readonly phase: BeatRouteErrorPhase; @@ -110,6 +110,7 @@ export interface BeatRouter { resolve(to: string): URL; navigate(to: string, options?: BeatNavigateOptions): void; prefetch(to: string): Promise; + /** Invalidate a canonical URL key directly (no guards/redirects), or all keys. */ invalidatePrefetch(to?: string): void; reload(): void; back(): void; @@ -319,7 +320,7 @@ function getMatchingRoutes( function buildParams( pathnameSegments: readonly string[], route: CompiledRoute, -): Readonly> { +): Readonly> | undefined { const params: Record = {}; for (const [index, segment] of route.segments.entries()) { @@ -329,7 +330,12 @@ function buildParams( const current = pathnameSegments[index]; if (current !== undefined) { - params[segment.slice(1)] = decodeURIComponent(current); + try { + params[segment.slice(1)] = decodeURIComponent(current); + } catch (error) { + if (error instanceof URIError) return undefined; + throw error; + } } } @@ -418,7 +424,7 @@ function matchRoute( const leafRoute = primaryRoute.definitions[primaryRoute.definitions.length - 1]; - if (leafRoute) { + if (leafRoute && params) { return { path: pathname || "/", fullPath: `${pathname || "/"}${url.search}`, @@ -895,11 +901,14 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { }; const current = pulse( - matchRoute(new URL(targetWindow.location.href), compiledRoutes, basePath), + // A requested route becomes public only after all of its guards accept it. + matchRoute(new URL(targetWindow.location.href), [], basePath), ); let router!: BeatRouter; + let disposed = false; let activeLoadToken = 0; let activeLoadControllers: AbortController[] = []; + let pendingPrefetchNavigation: { key: string; token: number; url: URL } | undefined; const prefetchCacheMaxEntries = options.prefetchCacheMaxEntries ?? DEFAULT_PREFETCH_CACHE_MAX_ENTRIES; const prefetchedRouteStates = new Map(); @@ -908,15 +917,19 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { { promise: Promise; controllers: AbortController[]; + cancelled: boolean; } >(); - const abortActiveLoads = (): void => { - activeLoadToken += 1; - for (const controller of activeLoadControllers) { + const abortActiveLoads = (): number => { + const token = ++activeLoadToken; + const controllers = activeLoadControllers; + activeLoadControllers = []; + pendingPrefetchNavigation = undefined; + for (const controller of controllers) { controller.abort(); } - activeLoadControllers = []; + return token; }; const touchPrefetchedRouteState = ( @@ -959,15 +972,25 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { return; } + activePrefetch.cancelled = true; + activePrefetches.delete(key); + const pending = pendingPrefetchNavigation; for (const controller of activePrefetch.controllers) { controller.abort(); } - - activePrefetches.delete(key); + if ( + !disposed && pending && pending === pendingPrefetchNavigation && + pending.key === key && pending.token === activeLoadToken + ) { + const token = abortActiveLoads(); + if (!disposed && token === activeLoadToken) { + current.set(matchRoute(pending.url, [], basePath)); + } + } }; const abortAllActivePrefetches = (): void => { - for (const key of activePrefetches.keys()) { + for (const key of [...activePrefetches.keys()]) { abortActivePrefetch(key); } }; @@ -990,7 +1013,7 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { let nextUrl = initialUrl; let replace = forceReplace; - for (let depth = 0; depth < MAX_REDIRECT_DEPTH; depth += 1) { + navigation: for (let depth = 0; depth < MAX_REDIRECT_DEPTH; depth += 1) { const match = matchRoute(nextUrl, compiledRoutes, basePath); const route = match.route; @@ -1005,22 +1028,28 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { continue; } - if (route?.beforeEnter) { - const guardResult = route.beforeEnter({ - from, - to: match, - router, - }); - - if (guardResult === false) { - return undefined; - } - - if (guardResult && guardResult !== true) { - const redirect = normalizeTarget(guardResult); - nextUrl = resolveUrl(redirect.to); - replace = replace || redirect.replace !== false; - continue; + const branches = [ + getMainBranch(match), + ...match.namedOutlets.map((outlet) => + getNamedOutletBranch(match, outlet.parentDepth, outlet.name), + ), + ]; + for (const branch of branches) { + if (!branch) continue; + for (const [depthIndex, entry] of branch.matches.entries()) { + if (!entry.route.beforeEnter) continue; + const guardResult = entry.route.beforeEnter({ + from, + to: createScopedMatch(router, match, branch, depthIndex), + router, + }); + if (guardResult === false) return undefined; + if (guardResult && guardResult !== true) { + const redirect = normalizeTarget(guardResult); + nextUrl = resolveUrl(redirect.to); + replace = replace || redirect.replace !== false; + continue navigation; + } } } @@ -1039,6 +1068,9 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { writeHistory: boolean, usePrefetchedState = true, ): void => { + if (disposed) return; + const loadToken = abortActiveLoads(); + if (disposed || loadToken !== activeLoadToken) return; if (writeHistory) { if (resolved.replace) { targetWindow.history.replaceState(null, "", resolved.url); @@ -1066,43 +1098,41 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { nextRouteDataStates, nextNamedRouteDataStates, ); + pendingPrefetchNavigation = { key: prefetchKey, token: loadToken, url: resolved.url }; current.set(decoratedMatch); - abortActiveLoads(); - void activePrefetch.promise - .then(() => { - const currentMatch = current.get(); - if (currentMatch.fullPath !== resolved.match.fullPath) { - return; - } - - const prefetchedState = getPrefetchedRouteState(prefetchKey); - if ( - prefetchedState && - isPrefetchedStateCompatible(resolved.match, prefetchedState) - ) { - current.set( - decorateRouteMatch( - resolved.match, - cloneRouteDataStates(prefetchedState.routeDataStates), - cloneNamedRouteDataStates( - prefetchedState.namedOutletRouteDataStates, - ), - ), - ); - return; - } - - applyResolvedNavigation(resolved, false, false); - }) - .catch(() => { - const currentMatch = current.get(); - if (currentMatch.fullPath !== resolved.match.fullPath) { - return; + const resume = (): void => { + const currentMatch = current.get(); + if ( + disposed || loadToken !== activeLoadToken || activePrefetch.cancelled || + currentMatch.fullPath !== resolved.match.fullPath + ) return; + let fresh: BeatResolvedNavigation | undefined; + try { + fresh = resolveNavigation(resolved.url, currentMatch, resolved.replace); + } catch (error) { + if (disposed || loadToken !== activeLoadToken) return; + const token = abortActiveLoads(); + if (disposed || token !== activeLoadToken) return; + current.set(matchRoute(resolved.url, [], basePath)); + if (disposed || token !== activeLoadToken) return; + router.onError?.({ + phase: "guard", error, match: currentMatch, + ...(currentMatch.route ? { route: currentMatch.route } : {}), + }); + return; + } + if (disposed || loadToken !== activeLoadToken) return; + if (!fresh) { + const token = abortActiveLoads(); + if (!disposed && token === activeLoadToken) { + current.set(matchRoute(resolved.url, [], basePath)); } - - applyResolvedNavigation(resolved, false, false); - }); + return; + } + applyResolvedNavigation(fresh, false); + }; + void activePrefetch.promise.then(resume, resume); return; } @@ -1120,7 +1150,6 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { cloneNamedRouteDataStates(prefetchedState.namedOutletRouteDataStates), ); current.set(decoratedPrefetchedMatch); - abortActiveLoads(); return; } @@ -1137,10 +1166,8 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { ); current.set(decoratedMatch); - abortActiveLoads(); - const loadToken = activeLoadToken; - for (const target of collectLoadTargets(decoratedMatch)) { + if (disposed || loadToken !== activeLoadToken) return; const controller = new AbortController(); activeLoadControllers.push(controller); void target.route.load!( @@ -1198,6 +1225,9 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { ), ...(target.outlet ? { outlet: target.outlet } : {}), }); + if (disposed || controller.signal.aborted || loadToken !== activeLoadToken) { + return; + } applyLoadTargetState(nextStates, nextNamedStates, target, { status: "rejected", loading: false, @@ -1214,6 +1244,7 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { const prefetchResolvedNavigation = ( resolved: BeatResolvedNavigation, ): Promise => { + if (disposed) return Promise.resolve(); const key = resolved.match.fullPath; const cachedState = getPrefetchedRouteState(key); if ( @@ -1240,9 +1271,18 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { namedOutletRouteDataStates, ); const controllers: AbortController[] = []; + let complete!: () => void; + let fail!: (error: unknown) => void; + const promise = new Promise((resolve, reject) => { + complete = resolve; + fail = reject; + }); + const entry = { promise, controllers, cancelled: false }; + activePrefetches.set(key, entry); - const promise = Promise.all( + void Promise.all( collectLoadTargets(prefetchedMatch).map(async (target) => { + if (disposed || entry.cancelled) return; const controller = new AbortController(); controllers.push(controller); @@ -1257,7 +1297,7 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { controller.signal, ); - if (controller.signal.aborted) { + if (disposed || entry.cancelled || controller.signal.aborted) { return; } @@ -1273,7 +1313,7 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { }, ); } catch (error) { - if (controller.signal.aborted) { + if (disposed || entry.cancelled || controller.signal.aborted) { return; } @@ -1295,7 +1335,7 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { }), ) .then(() => { - if (controllers.some((controller) => controller.signal.aborted)) { + if (disposed || entry.cancelled || controllers.some((controller) => controller.signal.aborted)) { return; } @@ -1306,19 +1346,22 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { ), }); }) - .finally(() => { - activePrefetches.delete(key); - }); - - activePrefetches.set(key, { - promise, - controllers, - }); + .then( + () => { + if (activePrefetches.get(key) === entry) activePrefetches.delete(key); + complete(); + }, + (error: unknown) => { + if (activePrefetches.get(key) === entry) activePrefetches.delete(key); + fail(error); + }, + ); return promise; }; const sync = (usePrefetchedState = true): void => { + if (disposed) return; const from = current.get(); const resolved = resolveNavigation( new URL(targetWindow.location.href), @@ -1348,6 +1391,7 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { return resolveUrl(to); }, navigate(to: string, options?: BeatNavigateOptions): void { + if (disposed) return; const resolved = resolveNavigation( resolveUrl(to), current.get(), @@ -1361,6 +1405,7 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { applyResolvedNavigation(resolved, true); }, prefetch(to: string): Promise { + if (disposed) return Promise.resolve(); const resolved = resolveNavigation(resolveUrl(to), current.get(), true); if (!resolved) { @@ -1370,28 +1415,27 @@ export function createRouter(options: CreateBeatRouterOptions): BeatRouter { return prefetchResolvedNavigation(resolved); }, invalidatePrefetch(to?: string): void { + if (disposed) return; if (to === undefined) { abortAllActivePrefetches(); prefetchedRouteStates.clear(); return; } - const resolved = resolveNavigation(resolveUrl(to), current.get(), true); - - if (!resolved) { - return; - } - - abortActivePrefetch(resolved.match.fullPath); - prefetchedRouteStates.delete(resolved.match.fullPath); + const key = matchRoute(resolveUrl(to), compiledRoutes, basePath).fullPath; + prefetchedRouteStates.delete(key); + abortActivePrefetch(key); }, reload(): void { sync(false); }, back(): void { + if (disposed) return; targetWindow.history.back(); }, dispose(): void { + if (disposed) return; + disposed = true; abortActiveLoads(); abortAllActivePrefetches(); prefetchedRouteStates.clear(); diff --git a/src/server.ts b/src/server.ts index 447f324..1c0d82b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,19 +2,62 @@ import { enterSsr, exitSsr } from "./ssr-context.js"; import { toRendered, type BeatJsxChild } from "./jsx-runtime.js"; import type { BeatRouter } from "./router.js"; +const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; +const RAW_TEXT_ELEMENTS = new Set([ + "script", "style", "xmp", "iframe", "noembed", "noframes", "noscript", +]); + +/** Rejects text trees whose HTML serialization could become active markup. */ +function validateSerialization(node: Node, restrictedContext = false): void { + if (node.nodeType !== 1) { + for (const child of node.childNodes) validateSerialization(child, restrictedContext); + return; + } + const element = node as Element; + const tag = element.localName.toLowerCase(); + const html = element.namespaceURI === HTML_NAMESPACE; + const restricted = restrictedContext || !html || + tag === "svg" || tag === "math" || tag === "select" || tag === "frameset"; + + if (html && tag === "plaintext") { + throw new TypeError("Beat SSR cannot safely serialize ."); + } + if (html && (RAW_TEXT_ELEMENTS.has(tag) || tag === "textarea" || tag === "title")) { + if (Array.from(element.childNodes).some((child) => child.nodeType !== 3)) { + throw new TypeError(`Beat SSR requires text-only children in <${tag}>.`); + } + if (RAW_TEXT_ELEMENTS.has(tag) && (restricted || (element.textContent ?? "").includes("<"))) { + throw new TypeError( + `Beat SSR cannot safely serialize <${tag}> in this context or with '<' in its text. Use an external script/style or escape '<' in serialized string data.`, + ); + } + } + for (const child of element.childNodes) validateSerialization(child, restricted); + if (html && tag === "template" && "content" in element) { + validateSerialization((element as HTMLTemplateElement).content, restricted); + } +} + /** * Renders a Beat JSX tree to an HTML string. * * `onMount` callbacks are suppressed during rendering — they will not fire * against the server-side DOM. * - * Requires a DOM environment (`globalThis.document`) to be installed before - * calling — use happy-dom or jsdom in your server entry point. + * Requires matching DOM globals (document, Node, HTMLElement, SVGElement, and + * DocumentFragment) during synchronous rendering; see the happy-dom example below. * * Pair with `createRouter({ initialUrl })` so the router resolves the correct * route without trying to read `window.location`. For routes with `load` * functions, await `waitForRouter(router)` before calling this. * + * Raw-text elements (including script/style) require text-only children without + * '<', and cannot appear in foreign, select, or frameset contexts. Use external + * scripts/styles for code containing '<'; encode '<' as '\\u003c' only inside + * serialized JavaScript string data. textarea/title also require text children; + * plaintext is unsupported. This is not a sanitizer for trusted DOM attributes + * or intentionally executable JavaScript/CSS. + * * @example * ```ts * // entry-server.ts @@ -24,14 +67,28 @@ import type { BeatRouter } from "./router.js"; * * export async function render(url: string): Promise<string> { * const win = new Window({ url }); - * globalThis.document = win.document as unknown as Document; - * * const router = createRouter({ routes, initialUrl: url }); - * await waitForRouter(router); - * const html = renderToString(() => <App router={router} />); - * - * win.happyDOM.close(); - * return html; + * try { + * await waitForRouter(router, { signal: AbortSignal.timeout(5_000) }); + * const previousGlobals = { + * document: globalThis.document, Node: globalThis.Node, + * HTMLElement: globalThis.HTMLElement, SVGElement: globalThis.SVGElement, + * DocumentFragment: globalThis.DocumentFragment, + * }; + * Object.assign(globalThis, { + * document: win.document, Node: win.Node, + * HTMLElement: win.HTMLElement, SVGElement: win.SVGElement, + * DocumentFragment: win.DocumentFragment, + * }); + * try { + * return renderToString(() => <App router={router} />); + * } finally { + * Object.assign(globalThis, previousGlobals); + * } + * } finally { + * router.dispose(); + * await win.happyDOM.close(); + * } * } * ``` */ @@ -40,10 +97,13 @@ export function renderToString(factory: () => BeatJsxChild): string { try { const container = document.createElement("div"); const rendered = toRendered(factory()); - container.append(rendered.node); - const html = container.innerHTML; - rendered.cleanup?.(); - return html; + try { + container.append(rendered.node); + validateSerialization(container); + return container.innerHTML; + } finally { + rendered.cleanup?.(); + } } finally { exitSsr(); } diff --git a/tests/router-security.test.ts b/tests/router-security.test.ts new file mode 100644 index 0000000..9fcd934 --- /dev/null +++ b/tests/router-security.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRoot, createRouter, Outlet } from "../src/index.js"; +import { jsx } from "../src/jsx-runtime.js"; +import { renderToString } from "../src/server.js"; + +describe("route admission", () => { + beforeEach(() => window.history.replaceState(null, "", "/")); + + it.each([false, true])("never publishes an initially denied route (SSR=%s)", (ssr) => { + window.history.replaceState(null, "", "/private"); + const view = vi.fn(() => "secret"); + const load = vi.fn(async () => "secret"); + const router = createRouter({ + routes: [ + { path: "/private", beforeEnter: () => false, view, load }, + { path: "/public", view: () => "public" }, + ], + ...(ssr ? { initialUrl: "https://example.com/private" } : { window }), + }); + expect(router.current.get().matches).toEqual([]); + expect(router.current.get().route).toBeUndefined(); + expect(renderToString(() => Outlet({ router }))).not.toContain("secret"); + expect(view).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + router.navigate("/public"); + expect(renderToString(() => Outlet({ router }))).toContain("public"); + router.dispose(); + }); + + it.each(["parent", "named"] as const)("checks %s guards before navigation, prefetch, and popstate effects", async (denied) => { + const calls: string[] = []; + const load = vi.fn(async () => "secret"); + const view = vi.fn(() => "secret"); + const router = createRouter({ + routes: [ + { path: "/", view: () => "public" }, + { + path: "/secure", + beforeEnter({ to }) { + calls.push(`parent:${to.route?.path}:${to.depth}`); + return denied !== "parent"; + }, + load, + view: (match) => jsx("div", { children: [match.outlet(), match.outlet("tools")] }), + children: [ + { path: "page", beforeEnter: () => { calls.push("leaf"); }, load, view }, + { path: "page", outlet: "tools", beforeEnter: () => { calls.push("named"); return false; }, load, view }, + ], + }, + ], + window, + }); + const target = document.createElement("div"); + const root = createRoot(target); + root.render(Outlet({ router })); + router.navigate("/secure/page"); + await router.prefetch("/secure/page"); + window.history.pushState(null, "", "/secure/page"); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(window.location.pathname).toBe("/"); + expect(target.textContent).toBe("public"); + expect(load).not.toHaveBeenCalled(); + expect(view).not.toHaveBeenCalled(); + const sequence = denied === "parent" ? ["parent:/secure:0"] : ["parent:/secure:0", "leaf", "named"]; + expect(calls).toEqual([...sequence, ...sequence, ...sequence]); + root.destroy(); + router.dispose(); + }); + + it("recomputes branches after a parent guard redirects", () => { + const denied = vi.fn(() => "secret"); + const router = createRouter({ + initialUrl: "https://example.com/private/page", + routes: [ + { path: "/login", view: () => "login" }, + { path: "/private", beforeEnter: () => "/login", view: denied, children: [{ path: "page", view: denied }] }, + ], + }); + expect(router.current.get().path).toBe("/login"); + expect(renderToString(() => Outlet({ router }))).toContain("login"); + expect(denied).not.toHaveBeenCalled(); + router.dispose(); + }); + + it("rechecks guards before using prefetched data", async () => { + let allowed = true; + const load = vi.fn(async () => "secret"); + const view = vi.fn(() => "secret"); + const router = createRouter({ + routes: [{ path: "/private", beforeEnter: () => allowed, load, view }], window, + }); + await router.prefetch("/private"); + expect(load).toHaveBeenCalledTimes(1); + allowed = false; + router.navigate("/private"); + expect(router.current.get().route).toBeUndefined(); + expect(view).not.toHaveBeenCalled(); + expect(load).toHaveBeenCalledTimes(1); + router.dispose(); + }); + + it.each(["%", "%FF", "%E0%A4%A"])("handles malformed parameters %s and permits recovery", async (parameter) => { + const view = vi.fn(() => "page"); + const load = vi.fn(async () => "data"); + const router = createRouter({ + initialUrl: `https://example.com/docs/${parameter}`, + routes: [{ path: "/docs/:slug", view, load }], + }); + expect(router.current.get().route).toBeUndefined(); + expect(() => router.navigate(`/docs/${parameter}`)).not.toThrow(); + await router.prefetch(`/docs/${parameter}`); + expect(load).not.toHaveBeenCalled(); + router.navigate("/docs/%E6%97%A5%E6%9C%AC"); + expect(router.current.get().params["slug"]).toBe("日本"); + router.dispose(); + }); + + it.each(["all", "target", "dispose"] as const)("cancels a waiting navigation on %s without restarting loaders", async (mode) => { + let allowed = true; + let finish!: (value: string) => void; + const load = vi.fn(() => new Promise<string>((resolve) => { finish = resolve; })); + const router = createRouter({ window, routes: [ + { path: "/private", beforeEnter: () => allowed, load, view: () => "private" }, + ] }); + const prefetched = router.prefetch("/private"); + router.navigate("/private"); + expect(router.current.get().loading).toBe(true); + allowed = false; + if (mode === "dispose") router.dispose(); + else router.invalidatePrefetch(mode === "target" ? "/private" : undefined); + if (mode !== "dispose") { + // Cancellation settles even when the underlying loader ignores its signal. + expect(router.current.get().loading).toBe(false); + expect(router.current.get().matches).toEqual([]); + } + finish("secret"); + await prefetched; + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(1); + expect(router.current.get().data).not.toBe("secret"); + allowed = true; + if (mode === "dispose") { + router.navigate("/private"); + await router.prefetch("/private"); + router.reload(); + expect(load).toHaveBeenCalledTimes(1); + } else { + router.navigate("/private"); + expect(load).toHaveBeenCalledTimes(2); + } + router.dispose(); + }); + + it.each([false, true])("rechecks admission when a pending prefetch settles (reject=%s)", async (reject) => { + let allowed = true; + let finish!: () => void; + const load = vi.fn(() => new Promise<string>((resolve, fail) => { + finish = () => reject ? fail(new Error("prefetch failed")) : resolve("secret"); + })); + const guard = vi.fn(() => allowed); + const router = createRouter({ window, routes: [ + { path: "/private", beforeEnter: guard, load, view: () => "private" }, + ] }); + const prefetched = router.prefetch("/private").catch(() => undefined); + router.navigate("/private"); + allowed = false; + finish(); + await prefetched; + await Promise.resolve(); + expect(guard).toHaveBeenCalledTimes(3); + expect(load).toHaveBeenCalledTimes(1); + expect(router.current.get().matches).toEqual([]); + expect(router.current.get().loading).toBe(false); + router.dispose(); + }); + + it("does not let an old prefetch completion remove a replacement for the same URL", async () => { + const finish: Array<(value: string) => void> = []; + const load = vi.fn(() => new Promise<string>((resolve) => { finish.push(resolve); })); + const router = createRouter({ window, routes: [{ path: "/private", load, view: () => "private" }] }); + const first = router.prefetch("/private"); + router.navigate("/private"); + router.invalidatePrefetch("/private"); + const second = router.prefetch("/private"); + finish[0]!("stale"); + await first; + router.navigate("/private"); + expect(load).toHaveBeenCalledTimes(2); + finish[1]!("fresh"); + await second; + await Promise.resolve(); + expect(router.current.get().data).toBe("fresh"); + router.dispose(); + }); + + it("preserves a newer navigation started by an abort handler", () => { + let winnerSignal!: AbortSignal; + const loserLoad = vi.fn(async () => "loser"); + const winnerLoad = vi.fn((_match: unknown, signal: AbortSignal) => { + winnerSignal = signal; + return new Promise<string>(() => {}); + }); + const router = createRouter({ window, routes: [ + { path: "/first", view: () => "first", load: (_match, signal) => { + signal.addEventListener("abort", () => router.navigate("/winner"), { once: true }); + return new Promise<string>(() => {}); + } }, + { path: "/loser", load: loserLoad, view: () => "loser" }, + { path: "/winner", load: winnerLoad, view: () => "winner" }, + ] }); + router.navigate("/first"); + router.navigate("/loser"); + expect(router.current.get().path).toBe("/winner"); + expect(window.location.pathname).toBe("/winner"); + expect(loserLoad).not.toHaveBeenCalled(); + expect(winnerLoad).toHaveBeenCalledTimes(1); + expect(winnerSignal.aborted).toBe(false); + router.dispose(); + expect(winnerSignal.aborted).toBe(true); + }); + + it("does not start stale loaders after a subscriber navigates", () => { + const staleLoad = vi.fn(async () => "stale"); + const router = createRouter({ window, routes: [ + { path: "/stale", load: staleLoad, view: () => "stale" }, + { path: "/winner", view: () => "winner" }, + ] }); + const off = router.current.on(() => { + if (router.current.get().path === "/stale") router.navigate("/winner"); + }); + router.navigate("/stale"); + expect(router.current.get().path).toBe("/winner"); + expect(staleLoad).not.toHaveBeenCalled(); + off(); + router.dispose(); + }); + + it("does not start prefetch loaders after a guard disposes the router", async () => { + const load = vi.fn(async () => "secret"); + const router = createRouter({ window, routes: [{ + path: "/private", view: () => "private", load, + beforeEnter: () => { router.dispose(); return true; }, + }] }); + await router.prefetch("/private"); + expect(load).not.toHaveBeenCalled(); + }); + + it.each(["dispose", "invalidate"])("registers prefetch cancellation before loaders can %s", async (mode) => { + let parentSignal!: AbortSignal; + const childLoad = vi.fn(async () => "child"); + const router = createRouter({ window, routes: [{ + path: "/private", view: () => "parent", + load: async (_match, signal) => { + parentSignal = signal; + if (mode === "dispose") router.dispose(); + else router.invalidatePrefetch("/private/page"); + return "parent"; + }, + children: [{ path: "page", view: () => "child", load: childLoad }], + }] }); + await router.prefetch("/private/page"); + expect(parentSignal.aborted).toBe(true); + expect(childLoad).not.toHaveBeenCalled(); + router.dispose(); + }); + + it.each(["throw", "redirect-loop"])("settles and reports %s during resumed admission", async (mode) => { + let failAdmission = false; + let finish!: (value: string) => void; + const onError = vi.fn(); + const load = vi.fn(() => new Promise<string>((resolve) => { finish = resolve; })); + const router = createRouter({ window, onError, routes: [{ + path: "/private", view: () => "private", load, + beforeEnter: () => { + if (!failAdmission) return true; + if (mode === "throw") throw new Error("guard unavailable"); + return "/private"; + }, + }] }); + const prefetched = router.prefetch("/private"); + router.navigate("/private"); + failAdmission = true; + finish("secret"); + await prefetched; + await Promise.resolve(); + expect(router.current.get().matches).toEqual([]); + expect(router.current.get().loading).toBe(false); + expect(load).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ phase: "guard", error: expect.any(Error) })); + router.dispose(); + }); + + it.each(["navigate", "dispose"])("preserves recovery when onError calls %s", async (mode) => { + let reject!: (reason: Error) => void; + let allowed = true; + const router = createRouter({ window, routes: [ + { path: "/login", view: () => "login" }, + { path: "/private", view: () => "private", beforeEnter: () => allowed, + load: async () => "parent secret", children: [{ + path: "page", view: () => "page", + load: () => new Promise<string>((_resolve, fail) => { reject = fail; }), + }] }, + ], onError: () => { + allowed = false; + if (mode === "navigate") router.navigate("/login"); + else router.dispose(); + } }); + router.navigate("/private/page"); + await Promise.resolve(); + const beforeFailure = router.current.get(); + reject(new Error("denied")); + await Promise.resolve(); + await Promise.resolve(); + if (mode === "navigate") { + expect(router.current.get().path).toBe("/login"); + expect(router.current.get().data).toBeUndefined(); + } else expect(router.current.get()).toBe(beforeFailure); + router.dispose(); + }); +}); diff --git a/tests/server-security.test.ts b/tests/server-security.test.ts new file mode 100644 index 0000000..6193e88 --- /dev/null +++ b/tests/server-security.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { component, onCleanup, onMount, render } from "../src/index.js"; +import { jsx } from "../src/jsx-runtime.js"; +import { renderToString } from "../src/server.js"; + +describe("SSR text serialization boundary", () => { + it.each(["script", "style", "xmp", "iframe", "noembed", "noframes", "noscript"])("rejects raw-text breakout in %s", (tag) => { + const data = `</${tag}><img src=x onerror=alert(1)>`; + expect(() => renderToString(() => jsx(tag, { children: data }))).toThrow(TypeError); + }); + + it.each(["</ScRiPt\t>", "</script/>", "<!--<script>"])("rejects script parser state transitions: %s", (data) => { + expect(() => renderToString(() => jsx("script", { type: "application/json", children: JSON.stringify({ data }) }))).toThrow(TypeError); + }); + + it("checks text spanning adjacent nodes", () => { + expect(() => renderToString(() => { + const script = document.createElement("script"); + script.append(document.createTextNode("</scr"), document.createTextNode("ipt><img src=x>")); + return script; + })).toThrow(TypeError); + }); + + it.each(["script", "style", "textarea", "title"])("rejects non-text children of %s", (tag) => { + expect(() => renderToString(() => jsx(tag, { children: jsx("style", { children: "</textarea><img src=x>" }) }))).toThrow(TypeError); + }); + + it.each(["svg", "math", "select", "frameset"])("rejects raw text in unsupported %s context", (tag) => { + expect(() => renderToString(() => jsx(tag, { children: jsx("style", { children: "body{color:red}" }) }))).toThrow(TypeError); + }); + + it("checks serialized template content", () => { + expect(() => renderToString(() => { + const template = document.createElement("template"); + const script = document.createElement("script"); + script.textContent = "</script><img src=x>"; + template.content.append(script); + return template; + })).toThrow(TypeError); + }); + + it("rejects plaintext serialization", () => { + expect(() => renderToString(() => jsx("plaintext", { children: "text" }))).toThrow(TypeError); + }); + + it("preserves safe JS, CSS, and escaped JSON string data", () => { + const js = 'const value = "ok & done";'; + const css = "body { color: red; }"; + const data = JSON.stringify({ value: "</script>" }).replace(/</g, "\\u003c"); + const html = renderToString(() => [ + jsx("script", { children: js }), + jsx("style", { children: css }), + jsx("script", { type: "application/json", children: data }), + ]); + expect(html).toContain(js); + expect(html).toContain(css); + expect(html).toContain(data); + }); + + it.each(["div", "textarea", "title"])("retains native text escaping in %s", (tag) => { + const text = '<img src=x> & "日本"'; + const html = renderToString(() => jsx(tag, { children: text })); + const target = document.createElement("div"); + target.innerHTML = html; + expect(target.querySelector("img")).toBeNull(); + expect(target.textContent).toBe(text); + }); + + it.each([false, true])("cleans once and exits SSR after rejection (cleanup throws=%s)", async (throws) => { + const cleanup = vi.fn(() => { if (throws) throw new Error("cleanup failed"); }); + const Unsafe = component(() => { + onCleanup(cleanup); + return jsx("script", { children: "</script>" }); + }); + expect(() => renderToString(() => Unsafe({}))).toThrow(); + expect(cleanup).toHaveBeenCalledTimes(1); + const mounted = vi.fn(); + const Safe = component(() => { + onMount(mounted); + return jsx("p", { children: "safe" }); + }); + const dispose = render(document.createElement("div"), Safe({})); + await Promise.resolve(); + expect(mounted).toHaveBeenCalledTimes(1); + dispose(); + }); +}); From 079bebc02ecab90dff7956eb426f2fe3e6d16ce0 Mon Sep 17 00:00:00 2001 From: ochairo <19258508+ochairo@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:09:02 +0900 Subject: [PATCH 4/5] fix: prepare beat 2.0.0 with complete plugin dependencies and ARIA values --- README.md | 14 ++++++++++++++ package.json | 14 +++++++++++--- pnpm-lock.yaml | 18 +++++++++--------- src/dom.ts | 6 ++++++ src/jsx-runtime.ts | 6 ++++++ tests/dom.test.ts | 26 ++++++++++++++++++++++++++ tests/server.test.ts | 5 +++++ 7 files changed, 77 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 25113e7..5d20b17 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,20 @@ The public model stays explicit: - **Explicit router and resources** — routing state and async state stay in normal userland APIs - **Shared client/server model** — the same component tree and router power rendering, hydration, and SSR +## Upgrading to 2.0.0 + +Node.js consumers now require Node 24.22.1 or later within the 24.x line, or +Node 26.12.1 or later. Beat now depends on Pulse 2; update direct +`@ochairo/pulse` dependencies to `^2.0.0` as well. +The Vite plugin declares Vite `^7.3.7 || ^8.3.5` as an optional peer and includes +its TypeScript compiler dependency. Runtime-only applications do not need Vite. + +Route guards now cover initial URLs, parents, named branches, and prefetch reuse. +Update exhaustive `BeatRouteErrorPhase` switches to handle `"guard"`. +SSR rejects unsafe raw-text contexts and literal `<` in raw-text content; +follow the [SSR security boundaries](#ssr-security-boundaries) when migrating +inline scripts, styles, or serialized data. + ## Scaffold Start a new app with the scaffolder: diff --git a/package.json b/package.json index 8c96b88..1de37df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ochairo/beat", - "version": "1.6.10", + "version": "2.0.0", "description": "Pulse-native SPA runtime for DOM-first applications", "type": "module", "main": "./dist/index.js", @@ -64,13 +64,21 @@ "url": "https://github.com/ochairo/beat/issues" }, "dependencies": { - "@ochairo/pulse": "^1.6.10" + "@ochairo/pulse": "^2.0.0", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "vite": "^7.3.7 || ^8.3.5" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } }, "devDependencies": { "@types/node": "^24.19.2", "esbuild": "^0.28.2", "happy-dom": "^20.14.6", - "typescript": "^5.9.3", "vite": "^7.3.7", "vitest": "^4.1.12" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae2bd0d..b26ab9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,11 @@ importers: .: dependencies: '@ochairo/pulse': - specifier: ^1.6.10 - version: 1.6.10 + specifier: ^2.0.0 + version: 2.0.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 devDependencies: '@types/node': specifier: ^24.19.2 @@ -21,9 +24,6 @@ importers: happy-dom: specifier: ^20.14.6 version: 20.14.6 - typescript: - specifier: ^5.9.3 - version: 5.9.3 vite: specifier: ^7.3.7 version: 7.3.7(@types/node@24.19.2)(lightningcss@1.32.0) @@ -199,9 +199,9 @@ packages: os: [linux] libc: [glibc] - '@ochairo/pulse@1.6.10': - resolution: {integrity: sha512-mwR0rNcYD5yRoKcyvD3bHi4gO3DRHN5W89kt7ZDtRGGL1pvm/+etwfnFStTVw+/v2xbKB/HEjvlB4Sxd8SeD+Q==} - engines: {node: '>=20.0.0'} + '@ochairo/pulse@2.0.0': + resolution: {integrity: sha512-//DPSJqrq7/x6DD7ug2mfssMpccFiQtW4VEPWGO90jRvrjFKvbDdfdeB2nwanofqEKOh4k5E7LGmarkNu5/oSg==} + engines: {node: ^24.22.1 || >=26.12.1} '@rollup/rollup-android-arm-eabi@4.65.0': resolution: {integrity: sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==} @@ -775,7 +775,7 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@ochairo/pulse@1.6.10': {} + '@ochairo/pulse@2.0.0': {} '@rollup/rollup-android-arm-eabi@4.65.0': optional: true diff --git a/src/dom.ts b/src/dom.ts index e132109..ec89ccc 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -62,6 +62,12 @@ function createPropertyWriter( return; } + // ARIA boolean values are explicit tokens, unlike HTML boolean attributes. + if (propertyName.startsWith("aria-") && typeof value === "boolean") { + element.setAttribute(propertyName, String(value)); + return; + } + if (value === false) { element.removeAttribute(propertyName); if (writeAsProperty) { diff --git a/src/jsx-runtime.ts b/src/jsx-runtime.ts index 1ee2ef2..92e0f34 100644 --- a/src/jsx-runtime.ts +++ b/src/jsx-runtime.ts @@ -289,6 +289,12 @@ function createPropertyOrAttributeApplier( return; } + // ARIA boolean values are explicit tokens, unlike HTML boolean attributes. + if (propertyName.startsWith("aria-") && typeof value === "boolean") { + element.setAttribute(propertyName, String(value)); + return; + } + if (value === false) { element.removeAttribute(propertyName); if (writeAsProperty) { diff --git a/tests/dom.test.ts b/tests/dom.test.ts index c694d21..3c30f9a 100644 --- a/tests/dom.test.ts +++ b/tests/dom.test.ts @@ -10,6 +10,32 @@ import { jsx } from "../src/jsx-runtime.js"; import { For, Show } from "../src/index.js"; describe("dom bindings", () => { + it("preserves false and true ARIA tokens in direct reactive bindings", () => { + const expanded = pulse<boolean | undefined>(false); + const element = document.createElement("button"); + const cleanup = bindProperty(element, "aria-expanded", expanded); + expect(element.getAttribute("aria-expanded")).toBe("false"); + expanded.set(true); + expect(element.getAttribute("aria-expanded")).toBe("true"); + expanded.set(undefined); + expect(element.hasAttribute("aria-expanded")).toBe(false); + cleanup(); + }); + + it("serializes static and reactive JSX ARIA booleans without changing HTML booleans", () => { + const expanded = pulse(false); + const rendered = jsx("button", { "aria-expanded": expanded, "aria-hidden": true, disabled: true }); + const element = rendered.node as HTMLButtonElement; + expect(element.getAttribute("aria-expanded")).toBe("false"); + expect(element.getAttribute("aria-hidden")).toBe("true"); + expect(element.disabled).toBe(true); + expanded.set(true); + expect(element.getAttribute("aria-expanded")).toBe("true"); + expanded.set(false); + expect(element.getAttribute("aria-expanded")).toBe("false"); + rendered.cleanup?.(); + }); + it("bindText updates the same text node when the pulse changes", () => { const count = pulse(1); const binding = bindText(count, (value) => `${value}`); diff --git a/tests/server.test.ts b/tests/server.test.ts index 3f4c6c0..d5546c1 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -14,6 +14,11 @@ import { import { renderToString, waitForRouter } from "../src/server.js"; describe("renderToString", () => { + it("preserves explicit ARIA boolean values in serialized HTML", () => { + expect(renderToString(() => jsx("button", { "aria-expanded": false, "aria-hidden": true }))) + .toBe('<button aria-expanded="false" aria-hidden="true"></button>'); + }); + it("serializes a plain element to HTML", () => { const html = renderToString(() => jsx("div", { class: "app", children: "hello" }), From ceec65a00bb5e56639993ba24c5cf14ef0d5bd56 Mon Sep 17 00:00:00 2001 From: ochairo <19258508+ochairo@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:47:26 +0900 Subject: [PATCH 5/5] fix: clear nullish DOM properties and address PR feedback --- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- CONTRIBUTING.md | 2 +- src/dom.ts | 7 +++- src/jsx-runtime.ts | 7 +++- tests/dom.test.ts | 45 ++++++++++++++++++++- tests/render.test.ts | 46 ++++++++++++++++++++-- 6 files changed, 99 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cf9e8fd..d1a2c9f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -55,9 +55,9 @@ body: attributes: label: Breaking change risk options: - - No + - "No" - Possibly - - Yes + - "Yes" validations: required: true - type: textarea diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6dc52b1..5f9bf89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in contributing to Beat. ## Prerequisites -- Node.js `>=24 <25` +- Node.js `^24.22.1 || >=26.12.1` - pnpm `>=10` ## Setup diff --git a/src/dom.ts b/src/dom.ts index ec89ccc..9aa9a0d 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -45,12 +45,15 @@ function createPropertyWriter( return; } - if (propertyName === "value") { + if (propertyName === "value" || propertyName === "textContent") { dynamicElement[propertyName] = ""; return; } - if (propertyName === "checked" || propertyName === "selected") { + if ( + propertyName === "checked" || propertyName === "selected" || + propertyName === "indeterminate" + ) { dynamicElement[propertyName] = false; } }; diff --git a/src/jsx-runtime.ts b/src/jsx-runtime.ts index 92e0f34..1050cf9 100644 --- a/src/jsx-runtime.ts +++ b/src/jsx-runtime.ts @@ -272,12 +272,15 @@ function createPropertyOrAttributeApplier( return; } - if (propertyName === "value") { + if (propertyName === "value" || propertyName === "textContent") { dynamicElement[propertyName] = ""; return; } - if (propertyName === "checked" || propertyName === "selected") { + if ( + propertyName === "checked" || propertyName === "selected" || + propertyName === "indeterminate" + ) { dynamicElement[propertyName] = false; } }; diff --git a/tests/dom.test.ts b/tests/dom.test.ts index 3c30f9a..c5528b6 100644 --- a/tests/dom.test.ts +++ b/tests/dom.test.ts @@ -6,10 +6,51 @@ import { bindText, type BeatRendered, } from "../src/dom.js"; -import { jsx } from "../src/jsx-runtime.js"; +import { jsx, toRendered } from "../src/jsx-runtime.js"; import { For, Show } from "../src/index.js"; describe("dom bindings", () => { + it.each([undefined, null])("clears a bound indeterminate property on %s", (empty) => { + const state = pulse<boolean | null | undefined>(true); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + const cleanup = bindProperty(checkbox, "indeterminate", state); + expect(checkbox.indeterminate).toBe(true); + + state.set(empty); + expect(checkbox.indeterminate).toBe(false); + expect(checkbox.hasAttribute("indeterminate")).toBe(false); + + state.set(true); + expect(checkbox.indeterminate).toBe(true); + cleanup(); + }); + + it.each([undefined, null])("clears bound textContent on %s", (empty) => { + const state = pulse<string | null | undefined>("previous text"); + const element = document.createElement("div"); + const cleanup = bindProperty(element, "textContent", state); + expect(element.textContent).toBe("previous text"); + + state.set(empty); + expect(element.textContent).toBe(""); + expect(element.hasAttribute("textContent")).toBe(false); + + state.set("next text"); + expect(element.textContent).toBe("next text"); + cleanup(); + }); + + it.each([undefined, null])("removes a reflected bound id on %s", (empty) => { + const state = pulse<string | null | undefined>("previous-id"); + const element = document.createElement("div"); + const cleanup = bindProperty(element, "id", state); + state.set(empty); + expect(element.id).toBe(""); + expect(element.hasAttribute("id")).toBe(false); + cleanup(); + }); + it("preserves false and true ARIA tokens in direct reactive bindings", () => { const expanded = pulse<boolean | undefined>(false); const element = document.createElement("button"); @@ -24,7 +65,7 @@ describe("dom bindings", () => { it("serializes static and reactive JSX ARIA booleans without changing HTML booleans", () => { const expanded = pulse(false); - const rendered = jsx("button", { "aria-expanded": expanded, "aria-hidden": true, disabled: true }); + const rendered = toRendered(jsx("button", { "aria-expanded": expanded, "aria-hidden": true, disabled: true })); const element = rendered.node as HTMLButtonElement; expect(element.getAttribute("aria-expanded")).toBe("false"); expect(element.getAttribute("aria-hidden")).toBe("true"); diff --git a/tests/render.test.ts b/tests/render.test.ts index fbb2773..008e964 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -1,6 +1,6 @@ import { derived, pulse } from "@ochairo/pulse"; import { describe, expect, it } from "vitest"; -import { jsx } from "../src/jsx-runtime.js"; +import { jsx, toRendered } from "../src/jsx-runtime.js"; import { component, createRoot, @@ -11,6 +11,46 @@ import { } from "../src/index.js"; describe("render root", () => { + it.each([undefined, null])("clears reactive JSX indeterminate on %s", (empty) => { + const state = pulse<boolean | null | undefined>(true); + const rendered = toRendered(jsx("input", { type: "checkbox", indeterminate: state })); + const checkbox = rendered.node as HTMLInputElement; + expect(checkbox.indeterminate).toBe(true); + + state.set(empty); + expect(checkbox.indeterminate).toBe(false); + expect(checkbox.hasAttribute("indeterminate")).toBe(false); + + state.set(true); + expect(checkbox.indeterminate).toBe(true); + rendered.cleanup?.(); + }); + + it.each([undefined, null])("clears reactive JSX textContent on %s", (empty) => { + const state = pulse<string | null | undefined>("previous text"); + const rendered = toRendered(jsx("div", { textContent: state })); + const element = rendered.node as HTMLDivElement; + expect(element.textContent).toBe("previous text"); + + state.set(empty); + expect(element.textContent).toBe(""); + expect(element.hasAttribute("textContent")).toBe(false); + + state.set("next text"); + expect(element.textContent).toBe("next text"); + rendered.cleanup?.(); + }); + + it.each([undefined, null])("removes a reflected reactive JSX id on %s", (empty) => { + const state = pulse<string | null | undefined>("previous-id"); + const rendered = toRendered(jsx("div", { id: state })); + const element = rendered.node as HTMLDivElement; + state.set(empty); + expect(element.id).toBe(""); + expect(element.hasAttribute("id")).toBe(false); + rendered.cleanup?.(); + }); + it("creates a root that replaces the previous rendered scope", () => { const target = document.createElement("div"); const root = createRoot(target); @@ -117,7 +157,7 @@ describe("onMount", () => { expect(order).toEqual(["setup", "return", "after-render"]); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise<void>((resolve) => queueMicrotask(resolve)); expect(order).toEqual(["setup", "return", "after-render", "mount"]); }); @@ -146,7 +186,7 @@ describe("onMount", () => { expect(refElement).toBeInstanceOf(HTMLSpanElement); expect(mountedElement).toBeUndefined(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise<void>((resolve) => queueMicrotask(resolve)); expect(mountedElement).toBeInstanceOf(HTMLSpanElement);