diff --git a/.changeset/browser-history-verification.md b/.changeset/browser-history-verification.md new file mode 100644 index 0000000..da09aaa --- /dev/null +++ b/.changeset/browser-history-verification.md @@ -0,0 +1,6 @@ +--- +--- + +Add real-browser TanStack back/forward confirmation tests, CI verification, and +versioned evidence. This is test and documentation infrastructure with no public +runtime behavior change, so no package release is required. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 505cc27..2e94fb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,24 @@ jobs: - name: Test run: pnpm run test:coverage + - name: Install Chrome for navigation tests + if: matrix.node == 22 + run: pnpm --filter @okyrychenko-dev/react-action-guard-router exec playwright install --with-deps chrome + + - name: Verify real browser back and forward + if: matrix.node == 22 + timeout-minutes: 3 + run: pnpm --filter @okyrychenko-dev/react-action-guard-router test:browser + + - name: Upload browser verification results + if: always() && matrix.node == 22 + uses: actions/upload-artifact@v4 + with: + name: navigation-browser-results + path: packages/router/.cache/browser-history + include-hidden-files: true + if-no-files-found: ignore + - name: Upload coverage if: matrix.node == 22 uses: codecov/codecov-action@v4 diff --git a/packages/router/README.md b/packages/router/README.md index 0064b26..608015a 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -312,7 +312,7 @@ Sync and async `onConfirm` are evaluated once per blocked navigation attempt. Ac **Not-found limitation:** In the verified TanStack Router 1.170.41, navigation from an unmatched URL (`__notFound__`) to a matched route bypasses native `useBlocker` before this adapter's callback runs, even with `when: true` or an active scope. `onBlock`, `onConfirm`, and `onAllow` are not called for that transition. A blocker mounted above the not-found UI therefore cannot protect this exit; do not rely on it to guard unsaved work there. Subsequent matched-to-matched navigation remains guarded. This is upstream behavior introduced by [TanStack router #4917](https://github.com/TanStack/router/pull/4917), covered by real-router tests for both condition sources. The adapter does not compensate for the bypass. -Compatibility tests exercise the public hook at 1.170.41 with real memory history for in-app navigation and browser history in the DOM test environment for unload protection. The tests intercept document location assignment and dispatch beforeunload events; they do not establish real-browser back/forward or prompt UI behavior. The verified floor was raised from 1.170.28 because the older locked router-core/history combination skipped blockers for external navigation. The native API is documented in [TanStack navigation blocking](https://tanstack.com/router/latest/docs/guide/navigation-blocking). +Compatibility tests exercise the public hook at 1.170.41 with real memory history for in-app navigation and browser history in the DOM test environment for unload protection. The unload tests intercept document location assignment and dispatch beforeunload events; they do not establish browser prompt UI behavior. Separate Playwright tests verified same-document back/forward on matched routes in Chrome 154.0.8037.57 on 2026-10-03: custom-dialog cancellation retains the original route and URL, confirmation reaches the intended destination once, and subsequent history attempts remain protected. This evidence does not establish other browsers or cross-document history transitions. Reproduction steps, evaluated versions, and result artifacts are documented in [browser-history verification](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/router/browser/README.md). The verified floor was raised from 1.170.28 because the older locked router-core/history combination skipped blockers for external navigation. The native API is documented in [TanStack navigation blocking](https://tanstack.com/router/latest/docs/guide/navigation-blocking). ### Next.js Pages Router diff --git a/packages/router/browser/README.md b/packages/router/browser/README.md new file mode 100644 index 0000000..e16d659 --- /dev/null +++ b/packages/router/browser/README.md @@ -0,0 +1,54 @@ +# TanStack browser-history verification + +The fixture uses a real TanStack Router with `createBrowserHistory`, React Strict Mode, +and the public navigation-blocker and custom-dialog hooks. No history APIs are mocked. +The two Playwright tests exercise native `window.history.go(-1)` and +`window.history.go(1)`, corresponding to browser back and forward. + +## Run + +From the repository root, after installing workspace dependencies: + +```sh +pnpm --filter @okyrychenko-dev/react-action-guard build +pnpm --filter @okyrychenko-dev/react-action-guard-router test:browser +``` + +The runner starts and stops a Vite fixture server at `http://127.0.0.1:4173` and +uses installed Google Chrome in headless mode. If Chrome is unavailable, install +it using `pnpm --filter @okyrychenko-dev/react-action-guard-router exec playwright install chrome`. +CI installs Chrome with its system dependencies and runs the tests on Node 22 +after the workspace build. + +Playwright launches the Vite CLI directly through Node. Using `pnpm run` inside +`webServer.command` can leave Vite in a separate process group with pnpm 12.6.0: +tests finish, but Playwright waits indefinitely for server teardown. The CI browser +step has a three-minute limit covering startup, test execution, and teardown. + +Results are written to `packages/router/.cache/browser-history/results.json`. +Each test records the browser version. Failure traces are retained in the adjacent +`artifacts` directory; CI uploads that directory and the result report. + +For interactive reproduction, run +`pnpm --filter @okyrychenko-dev/react-action-guard-router browser:serve` and open +the fixture URL. Visit Home, Next, and Other before enabling Protect navigation. +For forward checks, go back to Next before enabling protection. Try browser +back/forward, choose Stay or Leave, and inspect the location and callback counts. + +## Executed evidence + +Verified on 2026-10-03 on Linux with Chrome **154.0.8037.57**, Playwright **1.63.0**, +TanStack Router **1.170.41**, React/React DOM **19.2.8**, and Vite **8.2.1**. +Both browser tests passed without retries or skips. +Server teardown also completed with `CI=true` and pnpm **12.6.0**: the full +Playwright run exited successfully in 4.7 seconds after launching Vite directly. + +In both directions, cancellation retains the original rendered route and browser +URL. Confirmation reaches the intended history destination with one `onAllow`. +A subsequent history attempt opens another dialog and can be cancelled without +leaving that destination. Each attempt produces one `onBlock`. + +This evidence covers matched routes within the same document in the tested Chrome +version. It does not establish cross-document navigation, other browsers, browser +prompt UI, or the documented TanStack not-found bypass. Unload protection is disabled +in this fixture to keep the check focused on in-app history transitions. diff --git a/packages/router/browser/history.pw.ts b/packages/router/browser/history.pw.ts new file mode 100644 index 0000000..e59b7a4 --- /dev/null +++ b/packages/router/browser/history.pw.ts @@ -0,0 +1,60 @@ +import { expect, test as it } from "@playwright/test"; + +for (const direction of ["back", "forward"]) { + const step = direction === "back" ? -1 : 1; + + it(`should cancel and confirm browser ${direction} while protecting subsequent attempts`, async ({ + page, + browser, + }, testInfo) => { + testInfo.annotations.push({ type: "browser-version", description: browser.version() }); + + await page.goto("/"); + await expect(page.getByRole("status", { name: "Location" })).toHaveText("/"); + await page.getByRole("link", { name: "Next", exact: true }).click(); + await expect(page).toHaveURL("/next"); + await page.getByRole("link", { name: "Other", exact: true }).click(); + await expect(page).toHaveURL("/other"); + + if (direction === "forward") { + await page.goBack(); + await expect(page).toHaveURL("/next"); + } + + const origin = direction === "back" ? "/other" : "/next"; + const destination = direction === "back" ? "/next" : "/other"; + + await page.getByRole("checkbox", { name: "Protect navigation" }).check(); + await page.evaluate((movement) => { + window.history.go(movement); + }, step); + await expect(page.getByRole("dialog", { name: "Leave editor?" })).toBeVisible(); + await expect(page.getByRole("status", { name: "Location" })).toHaveText(origin); + await page.getByRole("button", { name: "Stay", exact: true }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(origin); + await expect(page.getByRole("status", { name: "Location" })).toHaveText(origin); + await expect(page.getByRole("status", { name: "Allowed attempts" })).toHaveText("0"); + + await page.evaluate((movement) => { + window.history.go(movement); + }, step); + await expect(page.getByRole("dialog", { name: "Leave editor?" })).toBeVisible(); + await page.getByRole("button", { name: "Leave", exact: true }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(destination); + await expect(page.getByRole("status", { name: "Location" })).toHaveText(destination); + await expect(page.getByRole("status", { name: "Allowed attempts" })).toHaveText("1"); + + await page.evaluate((movement) => { + window.history.go(movement); + }, -step); + await expect(page.getByRole("dialog", { name: "Leave editor?" })).toBeVisible(); + await page.getByRole("button", { name: "Stay", exact: true }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(destination); + await expect(page.getByRole("status", { name: "Location" })).toHaveText(destination); + await expect(page.getByRole("status", { name: "Blocked attempts" })).toHaveText("3"); + await expect(page.getByRole("status", { name: "Allowed attempts" })).toHaveText("1"); + }); +} diff --git a/packages/router/browser/index.html b/packages/router/browser/index.html new file mode 100644 index 0000000..65a8b65 --- /dev/null +++ b/packages/router/browser/index.html @@ -0,0 +1,11 @@ + + + + + TanStack browser history verification + + +
+ + + diff --git a/packages/router/browser/main.tsx b/packages/router/browser/main.tsx new file mode 100644 index 0000000..735082e --- /dev/null +++ b/packages/router/browser/main.tsx @@ -0,0 +1,30 @@ +import { + RouterProvider, + createBrowserHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { HistoryFixture } from "../src/browser-history/HistoryFixture"; + +const rootRoute = createRootRoute({ component: HistoryFixture }); +const routes = ["/", "/next", "/other"].map((path) => + createRoute({ getParentRoute: () => rootRoute, path }) +); +const router = createRouter({ + routeTree: rootRoute.addChildren(routes), + history: createBrowserHistory(), +}); +const element = document.getElementById("root"); + +if (!element) { + throw new Error("Missing fixture root"); +} + +createRoot(element).render( + + + +); diff --git a/packages/router/package.json b/packages/router/package.json index 43a6efc..e7c8325 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -43,16 +43,18 @@ "clean": "rm -rf dist", "prepublishOnly": "pnpm run clean && pnpm run build", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", - "lint": "eslint src --ext .ts,.tsx", - "lint:fix": "eslint src --ext .ts,.tsx --fix", - "format": "prettier --write \"src/**/*.{ts,tsx}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", + "lint": "eslint src browser --ext .ts,.tsx", + "lint:fix": "eslint src browser --ext .ts,.tsx --fix", + "format": "prettier --write \"src/**/*.{ts,tsx}\" \"browser/**/*.{ts,tsx,html,md}\" playwright.config.ts", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"browser/**/*.{ts,tsx,html,md}\" playwright.config.ts", "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", "test:coverage": "vitest run --coverage", "storybook": "storybook dev -p 6007", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "browser:serve": "vite browser --host 127.0.0.1 --port 4173 --strictPort", + "test:browser": "playwright test" }, "keywords": [ "react", @@ -100,6 +102,7 @@ }, "devDependencies": { "@okyrychenko-dev/react-action-guard": "workspace:*", + "@playwright/test": "^1.63.0", "@storybook/addon-a11y": "^10.3.6", "@storybook/addon-docs": "^10.3.6", "@storybook/react-vite": "^10.3.6", @@ -109,6 +112,7 @@ "@testing-library/react": "^16.3.0", "@types/node": "^24.10.4", "@types/react": "^19.2.5", + "@types/react-dom": "~19.2.7", "clsx": "^2.1.1", "eslint-plugin-storybook": "^10.3.6", "next": "^15.1.6", @@ -117,6 +121,7 @@ "react-router-dom": "7.14.2", "remark-gfm": "^4.0.1", "storybook": "^10.3.6", + "vite": "8.2.1", "zustand": "^5.0.8" }, "allowScripts": { diff --git a/packages/router/playwright.config.ts b/packages/router/playwright.config.ts new file mode 100644 index 0000000..f7228f6 --- /dev/null +++ b/packages/router/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./browser", + testMatch: "**/*.pw.ts", + workers: 1, + outputDir: "./.cache/browser-history/artifacts", + reporter: [["list"], ["json", { outputFile: "./.cache/browser-history/results.json" }]], + use: { + baseURL: "http://127.0.0.1:4173", + browserName: "chromium", + channel: "chrome", + trace: "retain-on-failure", + }, + webServer: { + command: + "node ./node_modules/vite/bin/vite.js browser --host 127.0.0.1 --port 4173 --strictPort", + url: "http://127.0.0.1:4173", + reuseExistingServer: false, + }, +}); diff --git a/packages/router/src/browser-history/HistoryFixture.tsx b/packages/router/src/browser-history/HistoryFixture.tsx new file mode 100644 index 0000000..11dff13 --- /dev/null +++ b/packages/router/src/browser-history/HistoryFixture.tsx @@ -0,0 +1,60 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { useState } from "react"; +import { useDialogState } from "../core"; +import { useNavigationBlocker } from "../tanstack-router"; +import type { ReactElement } from "react"; + +export function HistoryFixture(): ReactElement { + const [blocking, setBlocking] = useState(false); + const [blockedAttempts, setBlockedAttempts] = useState(0); + const [allowedAttempts, setAllowedAttempts] = useState(0); + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const { dialogState, confirm, onConfirm, onCancel } = useDialogState(); + + useNavigationBlocker({ + when: blocking, + message: "Leave editor?", + onConfirm: confirm, + onBlock: () => { + setBlockedAttempts((count) => count + 1); + }, + onAllow: () => { + setAllowedAttempts((count) => count + 1); + }, + blockBrowserUnload: false, + }); + + return ( +
+

Browser history confirmation fixture

+

+ Location: {pathname} +

+

+ Blocked attempts: {blockedAttempts} +

+

+ Allowed attempts: {allowedAttempts} +

+ + + {dialogState && ( +
+ + +
+ )} +
+ ); +} diff --git a/packages/router/src/tanstack-router/__tests__/dialogNavigation.test.tsx b/packages/router/src/tanstack-router/__tests__/dialogNavigation.test.tsx new file mode 100644 index 0000000..5a31f7d --- /dev/null +++ b/packages/router/src/tanstack-router/__tests__/dialogNavigation.test.tsx @@ -0,0 +1,205 @@ +import { StrictMode, useCallback } from "react"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useDialogState } from "../../core"; +import { useNavigationBlocker } from "../../tanstack-router"; +import type { DialogState } from "../../core"; +import type { RouterHistory } from "@tanstack/react-router"; + +const histories: Array = []; + +async function mountDialogNavigation() { + const outcomes: Array = []; + const onBlock = vi.fn(); + const onAllow = vi.fn(); + let currentDialog: DialogState | null = null; + + function Root() { + const { dialogState, confirm, onConfirm, onCancel } = useDialogState(); + const requestConfirmation = useCallback( + (message: string) => { + const promise = confirm(message); + + void promise.then((outcome) => outcomes.push(outcome)); + + return promise; + }, + [confirm] + ); + + currentDialog = dialogState; + + useNavigationBlocker({ + when: true, + message: "Leave editor?", + onConfirm: requestConfirmation, + onBlock, + onAllow, + }); + + return ( + <> + + {dialogState && ( +
+ + +
+ )} + + ); + } + + const history = createMemoryHistory({ initialEntries: ["/"] }); + const root = createRootRoute({ component: Root }); + const routes = ["/", "/next", "/other"].map((path) => + createRoute({ getParentRoute: () => root, path, component: () =>
Editor
}) + ); + const router = createRouter({ routeTree: root.addChildren(routes), history }); + + histories.push(history); + + await router.load(); + + const view = render( + + + + ); + + await waitFor(() => expect(view.getByText("Editor")).toBeTruthy()); + + async function navigate(to: string) { + await act(async () => { + history.push(to); + }); + + await waitFor(() => expect(view.getByRole("dialog")).toBeTruthy()); + } + + function captureDialog() { + if (!currentDialog) { + throw new Error("Expected an open dialog"); + } + + return currentDialog; + } + + return { ...view, router, navigate, outcomes, onBlock, onAllow, captureDialog }; +} + +afterEach(() => { + cleanup(); + histories.splice(0).forEach((history) => history.destroy()); +}); + +describe("TanStack navigation with a custom dialog", () => { + it("should settle a replaced request false and only allow the current destination", async () => { + const { router, navigate, captureDialog, outcomes, onAllow, onBlock, queryByRole } = + await mountDialogNavigation(); + + await navigate("/next"); + + const first = captureDialog(); + + await navigate("/other"); + + expect(outcomes).toEqual([false]); + + const second = captureDialog(); + + await act(async () => { + first.resolve(true); + }); + + expect(router.state.location.pathname).toBe("/"); + expect(queryByRole("dialog")).toBeTruthy(); + expect(outcomes).toEqual([false]); + expect(onAllow).not.toHaveBeenCalled(); + + await act(async () => { + second.resolve(true); + second.resolve(false); + }); + + await waitFor(() => expect(router.state.location.pathname).toBe("/other")); + + expect(outcomes).toEqual([false, true]); + expect(queryByRole("dialog")).toBeNull(); + expect(onAllow).toHaveBeenCalledTimes(1); + expect(onBlock).toHaveBeenCalledTimes(2); + }); + + it("should settle an unmounted dialog false without replaying its destination", async () => { + const { router, navigate, captureDialog, outcomes, onAllow, unmount } = + await mountDialogNavigation(); + + await navigate("/next"); + + const pending = captureDialog(); + + await act(async () => { + unmount(); + }); + + expect(outcomes).toEqual([false]); + expect(router.history.location.pathname).toBe("/"); + + await act(async () => { + pending.resolve(true); + }); + + expect(outcomes).toEqual([false]); + expect(router.history.location.pathname).toBe("/"); + expect(onAllow).not.toHaveBeenCalled(); + }); + + it("should cancel and confirm real transitions while protecting later navigation", async () => { + const { router, navigate, getByRole, queryByRole, outcomes, onBlock, onAllow } = + await mountDialogNavigation(); + + await navigate("/next"); + + expect(router.state.location.pathname).toBe("/"); + + await act(async () => { + fireEvent.click(getByRole("button", { name: "Stay" })); + }); + + expect(outcomes).toEqual([false]); + expect(router.state.location.pathname).toBe("/"); + expect(queryByRole("dialog")).toBeNull(); + expect(onAllow).not.toHaveBeenCalled(); + + await navigate("/next"); + + await act(async () => { + fireEvent.click(getByRole("button", { name: "Leave" })); + }); + + await waitFor(() => expect(router.state.location.pathname).toBe("/next")); + + expect(outcomes).toEqual([false, true]); + expect(queryByRole("dialog")).toBeNull(); + expect(onAllow).toHaveBeenCalledTimes(1); + + await navigate("/other"); + + await act(async () => { + fireEvent.click(getByRole("button", { name: "Stay" })); + }); + + expect(router.state.location.pathname).toBe("/next"); + expect(outcomes).toEqual([false, true, false]); + expect(onBlock).toHaveBeenCalledTimes(3); + expect(onAllow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/router/tsconfig.typecheck.json b/packages/router/tsconfig.typecheck.json index 66182cf..9415429 100644 --- a/packages/router/tsconfig.typecheck.json +++ b/packages/router/tsconfig.typecheck.json @@ -7,5 +7,5 @@ }, "rootDir": ".." }, - "include": ["src", "../core/src/middleware/window.types.ts"] + "include": ["src", "browser", "playwright.config.ts", "../core/src/middleware/window.types.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c2da6b..5343236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,10 +82,10 @@ importers: version: 10.5.8(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-docs': specifier: ^10.3.6 - version: 10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + version: 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@storybook/react-vite': specifier: ^10.3.6 - version: 10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + version: 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -94,7 +94,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.4(@testing-library/dom@10.4.1) @@ -152,10 +152,10 @@ importers: version: 10.5.8(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-docs': specifier: ^10.3.6 - version: 10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + version: 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@storybook/react-vite': specifier: ^10.3.6 - version: 10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + version: 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -164,7 +164,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/node': specifier: ^24.10.4 version: 24.13.3 @@ -220,15 +220,18 @@ importers: '@okyrychenko-dev/react-action-guard': specifier: workspace:* version: link:../core + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@storybook/addon-a11y': specifier: ^10.3.6 version: 10.5.8(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-docs': specifier: ^10.3.6 - version: 10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + version: 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@storybook/react-vite': specifier: ^10.3.6 - version: 10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + version: 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@tanstack/react-router': specifier: ^1.170.41 version: 1.170.41(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -240,13 +243,16 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/node': specifier: ^24.10.4 version: 24.13.3 '@types/react': specifier: ^19.2.5 version: 19.2.18 + '@types/react-dom': + specifier: ~19.2.7 + version: 19.2.7(@types/react@19.2.18) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -255,7 +261,7 @@ importers: version: 10.5.8(eslint@9.39.5(supports-color@7.2.0))(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3) next: specifier: ^15.1.6 - version: 15.5.23(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 15.5.23(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: ^19.2.0 version: 19.2.8 @@ -271,6 +277,9 @@ importers: storybook: specifier: ^10.3.6 version: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + vite: + specifier: 8.2.1 + version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0) zustand: specifier: ^5.0.8 version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) @@ -295,7 +304,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/node': specifier: ^24.10.4 version: 24.13.3 @@ -332,7 +341,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/node': specifier: ^24.10.4 version: 24.13.3 @@ -1722,6 +1731,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2328,6 +2342,11 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} @@ -4376,6 +4395,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -6459,6 +6488,10 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@polka/url@1.0.0-next.29': {} '@rolldown/binding-android-arm64@1.2.4': @@ -6656,12 +6689,12 @@ snapshots: axe-core: 4.13.0 storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) - '@storybook/addon-docs@10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0))': + '@storybook/addon-docs@10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@storybook/icons': 2.1.0(react@19.2.8) - '@storybook/react-dom-shim': 10.5.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) @@ -6701,20 +6734,21 @@ snapshots: dependencies: react: 19.2.8 - '@storybook/react-dom-shim@10.5.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + '@storybook/react-dom-shim@10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@storybook/react-vite@10.5.8(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0))': + '@storybook/react-vite@10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.4) '@storybook/builder-vite': 10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) - '@storybook/react': 10.5.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3) + '@storybook/react': 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 @@ -6734,10 +6768,10 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)': + '@storybook/react@10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 react-docgen: 8.0.3(supports-color@7.2.0) react-docgen-typescript: 2.4.0(typescript@5.9.3) @@ -6745,6 +6779,7 @@ snapshots: storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6817,7 +6852,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 @@ -6825,6 +6860,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': dependencies: @@ -7023,6 +7059,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -9364,7 +9404,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.5.23(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@15.5.23(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 15.5.23 '@swc/helpers': 0.5.15 @@ -9382,6 +9422,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.23 '@next/swc-win32-arm64-msvc': 15.5.23 '@next/swc-win32-x64-msvc': 15.5.23 + '@playwright/test': 1.63.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -9596,6 +9637,12 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + points-on-curve@0.2.0: {} points-on-path@0.2.1: