Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/browser-history-verification.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions packages/router/browser/README.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions packages/router/browser/history.pw.ts
Original file line number Diff line number Diff line change
@@ -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");
});
}
11 changes: 11 additions & 0 deletions packages/router/browser/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>TanStack browser history verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions packages/router/browser/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
15 changes: 10 additions & 5 deletions packages/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
21 changes: 21 additions & 0 deletions packages/router/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
60 changes: 60 additions & 0 deletions packages/router/src/browser-history/HistoryFixture.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Browser history confirmation fixture</h1>
<p>
Location: <output aria-label="Location">{pathname}</output>
</p>
<p>
Blocked attempts: <output aria-label="Blocked attempts">{blockedAttempts}</output>
</p>
<p>
Allowed attempts: <output aria-label="Allowed attempts">{allowedAttempts}</output>
</p>
<label>
<input
type="checkbox"
checked={blocking}
onChange={(event) => {
setBlocking(event.target.checked);
}}
/>
Protect navigation
</label>
<nav aria-label="Destinations">
<Link to="/">Home</Link> <Link to="/next">Next</Link> <Link to="/other">Other</Link>
</nav>
{dialogState && (
<div role="dialog" aria-label={dialogState.message}>
<button onClick={onCancel}>Stay</button>
<button onClick={onConfirm}>Leave</button>
</div>
)}
</main>
);
}
Loading
Loading