From a6beb0c48222cc9dedfcb717812c248036157197 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 20:57:47 +0300 Subject: [PATCH 1/3] test(router): verify custom dialog navigation composition --- .../__tests__/dialogNavigation.test.tsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 packages/router/src/tanstack-router/__tests__/dialogNavigation.test.tsx 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); + }); +}); From 9c0735dbd615777a12700c261035a3f7186d3a98 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 21:20:24 +0300 Subject: [PATCH 2/3] test(router): verify browser history confirmation in Chrome --- .changeset/browser-history-verification.md | 6 ++ .github/workflows/ci.yml | 17 ++++ packages/router/README.md | 2 +- packages/router/browser/README.md | 47 ++++++++++ packages/router/browser/history.pw.ts | 60 +++++++++++++ packages/router/browser/index.html | 11 +++ packages/router/browser/main.tsx | 30 +++++++ packages/router/package.json | 15 ++-- packages/router/playwright.config.ts | 20 +++++ .../src/browser-history/HistoryFixture.tsx | 60 +++++++++++++ packages/router/tsconfig.typecheck.json | 2 +- pnpm-lock.yaml | 89 ++++++++++++++----- 12 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 .changeset/browser-history-verification.md create mode 100644 packages/router/browser/README.md create mode 100644 packages/router/browser/history.pw.ts create mode 100644 packages/router/browser/index.html create mode 100644 packages/router/browser/main.tsx create mode 100644 packages/router/playwright.config.ts create mode 100644 packages/router/src/browser-history/HistoryFixture.tsx 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..20a20f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,23 @@ 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 + 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..5f77b58 --- /dev/null +++ b/packages/router/browser/README.md @@ -0,0 +1,47 @@ +# 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. + +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. + +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..412254a --- /dev/null +++ b/packages/router/playwright.config.ts @@ -0,0 +1,20 @@ +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: "pnpm run browser:serve", + 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/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: From 6888ecc871539d2888e46691231c3f1ac5e787d3 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 3 Oct 2026 21:45:08 +0300 Subject: [PATCH 3/3] fix(ci): prevent browser server teardown from hanging --- .github/workflows/ci.yml | 1 + packages/router/browser/README.md | 7 +++++++ packages/router/playwright.config.ts | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20a20f8..2e94fb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: - 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 diff --git a/packages/router/browser/README.md b/packages/router/browser/README.md index 5f77b58..e16d659 100644 --- a/packages/router/browser/README.md +++ b/packages/router/browser/README.md @@ -20,6 +20,11 @@ it using `pnpm --filter @okyrychenko-dev/react-action-guard-router exec playwrig 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. @@ -35,6 +40,8 @@ back/forward, choose Stay or Leave, and inspect the location and callback counts 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`. diff --git a/packages/router/playwright.config.ts b/packages/router/playwright.config.ts index 412254a..f7228f6 100644 --- a/packages/router/playwright.config.ts +++ b/packages/router/playwright.config.ts @@ -13,7 +13,8 @@ export default defineConfig({ trace: "retain-on-failure", }, webServer: { - command: "pnpm run browser:serve", + 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, },