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 @@
+
+
+