From a96c71a8d3fcb38e7fdb275713763dd0c940d345 Mon Sep 17 00:00:00 2001 From: Nitzan Savion <59604278+nizans@users.noreply.github.com> Date: Thu, 12 Jun 2025 03:21:11 +0300 Subject: [PATCH] Add global blocking functionality to router fix: standardize 'DISMISS_BLOCK' action naming in history module clean empty line proceedAll functionality and example global blocking --- .../basic-file-based/src/routeTree.gen.ts | 78 ++++++++++++ .../basic-file-based/src/routes/__root.tsx | 8 ++ .../global-blocker/_layout.multi-blockers.tsx | 30 +++++ .../src/routes/global-blocker/_layout.tsx | 52 ++++++++ .../basic-file-based/tests/app.spec.ts | 107 ++++++++++++++++ .../react/global-blocking-state/.gitignore | 10 ++ .../.vscode/settings.json | 11 ++ .../react/global-blocking-state/README.md | 6 + .../react/global-blocking-state/index.html | 12 ++ .../react/global-blocking-state/package.json | 30 +++++ .../global-blocking-state/postcss.config.mjs | 6 + .../react/global-blocking-state/src/main.tsx | 26 ++++ .../src/routeTree.gen.ts | 95 ++++++++++++++ .../src/routes/__root.tsx | 119 ++++++++++++++++++ .../src/routes/blocking.index.tsx | 17 +++ .../src/routes/index.tsx | 14 +++ .../src/routes/multi-blockers.index.tsx | 30 +++++ .../global-blocking-state/src/styles.css | 13 ++ .../global-blocking-state/tailwind.config.mjs | 4 + .../global-blocking-state/tsconfig.dev.json | 10 ++ .../react/global-blocking-state/tsconfig.json | 11 ++ .../global-blocking-state/vite.config.js | 11 ++ packages/history/src/index.ts | 89 ++++++++++++- packages/react-router/src/index.tsx | 1 + packages/react-router/src/useBlocker.tsx | 7 +- .../src/useNavigationBlockingState.ts | 51 ++++++++ packages/router-core/src/router.ts | 1 - packages/solid-router/src/useBlocker.tsx | 7 +- pnpm-lock.yaml | 49 ++++++++ ...tack-router-e2e-react-basic-file-based.txt | 1 + 30 files changed, 894 insertions(+), 12 deletions(-) create mode 100644 e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.multi-blockers.tsx create mode 100644 e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.tsx create mode 100644 examples/react/global-blocking-state/.gitignore create mode 100644 examples/react/global-blocking-state/.vscode/settings.json create mode 100644 examples/react/global-blocking-state/README.md create mode 100644 examples/react/global-blocking-state/index.html create mode 100644 examples/react/global-blocking-state/package.json create mode 100644 examples/react/global-blocking-state/postcss.config.mjs create mode 100644 examples/react/global-blocking-state/src/main.tsx create mode 100644 examples/react/global-blocking-state/src/routeTree.gen.ts create mode 100644 examples/react/global-blocking-state/src/routes/__root.tsx create mode 100644 examples/react/global-blocking-state/src/routes/blocking.index.tsx create mode 100644 examples/react/global-blocking-state/src/routes/index.tsx create mode 100644 examples/react/global-blocking-state/src/routes/multi-blockers.index.tsx create mode 100644 examples/react/global-blocking-state/src/styles.css create mode 100644 examples/react/global-blocking-state/tailwind.config.mjs create mode 100644 examples/react/global-blocking-state/tsconfig.dev.json create mode 100644 examples/react/global-blocking-state/tsconfig.json create mode 100644 examples/react/global-blocking-state/vite.config.js create mode 100644 packages/react-router/src/useNavigationBlockingState.ts create mode 100644 port-tanstack-router-e2e-react-basic-file-based.txt diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts index 8150c3354d..38a1e78db3 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as StructuralSharingEnabledRouteImport } from './routes/structura import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +import { Route as GlobalBlockerLayoutRouteImport } from './routes/global-blocker/_layout' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' import { Route as groupLazyinsideRouteImport } from './routes/(group)/lazyinside' import { Route as groupInsideRouteImport } from './routes/(group)/inside' @@ -48,13 +49,20 @@ import { Route as ParamsPsWildcardSplatRouteImport } from './routes/params-ps/wi import { Route as ParamsPsNamedChar123fooChar125suffixRouteImport } from './routes/params-ps/named/{$foo}suffix' import { Route as ParamsPsNamedPrefixChar123fooChar125RouteImport } from './routes/params-ps/named/prefix{$foo}' import { Route as ParamsPsNamedFooRouteImport } from './routes/params-ps/named/$foo' +import { Route as GlobalBlockerLayoutMultiBlockersRouteImport } from './routes/global-blocker/_layout.multi-blockers' import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b' import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a' import { Route as groupSubfolderInsideRouteImport } from './routes/(group)/subfolder/inside' import { Route as groupLayoutInsidelayoutRouteImport } from './routes/(group)/_layout.insidelayout' +const GlobalBlockerRouteImport = createFileRoute('/global-blocker')() const groupRouteImport = createFileRoute('/(group)')() +const GlobalBlockerRoute = GlobalBlockerRouteImport.update({ + id: '/global-blocker', + path: '/global-blocker', + getParentRoute: () => rootRouteImport, +} as any) const groupRoute = groupRouteImport.update({ id: '/(group)', getParentRoute: () => rootRouteImport, @@ -140,6 +148,10 @@ const PostsPostIdRoute = PostsPostIdRouteImport.update({ path: '/$postId', getParentRoute: () => PostsRoute, } as any) +const GlobalBlockerLayoutRoute = GlobalBlockerLayoutRouteImport.update({ + id: '/_layout', + getParentRoute: () => GlobalBlockerRoute, +} as any) const LayoutLayout2Route = LayoutLayout2RouteImport.update({ id: '/_layout-2', getParentRoute: () => LayoutRoute, @@ -251,6 +263,12 @@ const ParamsPsNamedFooRoute = ParamsPsNamedFooRouteImport.update({ path: '/params-ps/named/$foo', getParentRoute: () => rootRouteImport, } as any) +const GlobalBlockerLayoutMultiBlockersRoute = + GlobalBlockerLayoutMultiBlockersRouteImport.update({ + id: '/multi-blockers', + path: '/multi-blockers', + getParentRoute: () => GlobalBlockerLayoutRoute, + } as any) const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBRouteImport.update({ id: '/layout-b', path: '/layout-b', @@ -283,6 +301,7 @@ export interface FileRoutesByFullPath { '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute + '/global-blocker': typeof GlobalBlockerLayoutRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute @@ -295,6 +314,7 @@ export interface FileRoutesByFullPath { '/subfolder/inside': typeof groupSubfolderInsideRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute + '/global-blocker/multi-blockers': typeof GlobalBlockerLayoutMultiBlockersRoute '/params-ps/named/$foo': typeof ParamsPsNamedFooRoute '/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route '/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute @@ -321,6 +341,7 @@ export interface FileRoutesByTo { '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute + '/global-blocker': typeof GlobalBlockerLayoutRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/search-params/default': typeof SearchParamsDefaultRoute '/structural-sharing/$enabled': typeof StructuralSharingEnabledRoute @@ -332,6 +353,7 @@ export interface FileRoutesByTo { '/subfolder/inside': typeof groupSubfolderInsideRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute + '/global-blocker/multi-blockers': typeof GlobalBlockerLayoutMultiBlockersRoute '/params-ps/named/$foo': typeof ParamsPsNamedFooRoute '/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route '/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute @@ -365,6 +387,8 @@ export interface FileRoutesById { '/(group)/inside': typeof groupInsideRoute '/(group)/lazyinside': typeof groupLazyinsideRoute '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren + '/global-blocker': typeof GlobalBlockerRouteWithChildren + '/global-blocker/_layout': typeof GlobalBlockerLayoutRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute @@ -377,6 +401,7 @@ export interface FileRoutesById { '/(group)/subfolder/inside': typeof groupSubfolderInsideRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute + '/global-blocker/_layout/multi-blockers': typeof GlobalBlockerLayoutMultiBlockersRoute '/params-ps/named/$foo': typeof ParamsPsNamedFooRoute '/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route '/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute @@ -407,6 +432,7 @@ export interface FileRouteTypes { | '/onlyrouteinside' | '/inside' | '/lazyinside' + | '/global-blocker' | '/posts/$postId' | '/redirect/$target' | '/search-params/default' @@ -419,6 +445,7 @@ export interface FileRouteTypes { | '/subfolder/inside' | '/layout-a' | '/layout-b' + | '/global-blocker/multi-blockers' | '/params-ps/named/$foo' | '/params-ps/named/prefix{$foo}' | '/params-ps/named/{$foo}suffix' @@ -445,6 +472,7 @@ export interface FileRouteTypes { | '/onlyrouteinside' | '/inside' | '/lazyinside' + | '/global-blocker' | '/posts/$postId' | '/search-params/default' | '/structural-sharing/$enabled' @@ -456,6 +484,7 @@ export interface FileRouteTypes { | '/subfolder/inside' | '/layout-a' | '/layout-b' + | '/global-blocker/multi-blockers' | '/params-ps/named/$foo' | '/params-ps/named/prefix{$foo}' | '/params-ps/named/{$foo}suffix' @@ -488,6 +517,8 @@ export interface FileRouteTypes { | '/(group)/inside' | '/(group)/lazyinside' | '/_layout/_layout-2' + | '/global-blocker' + | '/global-blocker/_layout' | '/posts/$postId' | '/redirect/$target' | '/search-params/default' @@ -500,6 +531,7 @@ export interface FileRouteTypes { | '/(group)/subfolder/inside' | '/_layout/_layout-2/layout-a' | '/_layout/_layout-2/layout-b' + | '/global-blocker/_layout/multi-blockers' | '/params-ps/named/$foo' | '/params-ps/named/prefix{$foo}' | '/params-ps/named/{$foo}suffix' @@ -529,6 +561,7 @@ export interface RootRouteChildren { Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route anotherGroupOnlyrouteinsideRoute: typeof anotherGroupOnlyrouteinsideRoute groupRoute: typeof groupRouteWithChildren + GlobalBlockerRoute: typeof GlobalBlockerRouteWithChildren RedirectTargetRoute: typeof RedirectTargetRouteWithChildren StructuralSharingEnabledRoute: typeof StructuralSharingEnabledRoute ParamsPsIndexRoute: typeof ParamsPsIndexRoute @@ -550,6 +583,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/global-blocker': { + id: '/global-blocker' + path: '/global-blocker' + fullPath: '/global-blocker' + preLoaderRoute: typeof GlobalBlockerRouteImport + parentRoute: typeof rootRouteImport + } '/(group)': { id: '/(group)' path: '/' @@ -669,6 +709,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PostsPostIdRouteImport parentRoute: typeof PostsRoute } + '/global-blocker/_layout': { + id: '/global-blocker/_layout' + path: '/global-blocker' + fullPath: '/global-blocker' + preLoaderRoute: typeof GlobalBlockerLayoutRouteImport + parentRoute: typeof GlobalBlockerRoute + } '/_layout/_layout-2': { id: '/_layout/_layout-2' path: '' @@ -816,6 +863,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ParamsPsNamedFooRouteImport parentRoute: typeof rootRouteImport } + '/global-blocker/_layout/multi-blockers': { + id: '/global-blocker/_layout/multi-blockers' + path: '/multi-blockers' + fullPath: '/global-blocker/multi-blockers' + preLoaderRoute: typeof GlobalBlockerLayoutMultiBlockersRouteImport + parentRoute: typeof GlobalBlockerLayoutRoute + } '/_layout/_layout-2/layout-b': { id: '/_layout/_layout-2/layout-b' path: '/layout-b' @@ -925,6 +979,29 @@ const groupRouteChildren: groupRouteChildren = { const groupRouteWithChildren = groupRoute._addFileChildren(groupRouteChildren) +interface GlobalBlockerLayoutRouteChildren { + GlobalBlockerLayoutMultiBlockersRoute: typeof GlobalBlockerLayoutMultiBlockersRoute +} + +const GlobalBlockerLayoutRouteChildren: GlobalBlockerLayoutRouteChildren = { + GlobalBlockerLayoutMultiBlockersRoute: GlobalBlockerLayoutMultiBlockersRoute, +} + +const GlobalBlockerLayoutRouteWithChildren = + GlobalBlockerLayoutRoute._addFileChildren(GlobalBlockerLayoutRouteChildren) + +interface GlobalBlockerRouteChildren { + GlobalBlockerLayoutRoute: typeof GlobalBlockerLayoutRouteWithChildren +} + +const GlobalBlockerRouteChildren: GlobalBlockerRouteChildren = { + GlobalBlockerLayoutRoute: GlobalBlockerLayoutRouteWithChildren, +} + +const GlobalBlockerRouteWithChildren = GlobalBlockerRoute._addFileChildren( + GlobalBlockerRouteChildren, +) + interface RedirectTargetRouteChildren { RedirectTargetViaBeforeLoadRoute: typeof RedirectTargetViaBeforeLoadRoute RedirectTargetViaLoaderRoute: typeof RedirectTargetViaLoaderRoute @@ -953,6 +1030,7 @@ const rootRouteChildren: RootRouteChildren = { Char45824Char54620Char48124Char44397Route, anotherGroupOnlyrouteinsideRoute: anotherGroupOnlyrouteinsideRoute, groupRoute: groupRouteWithChildren, + GlobalBlockerRoute: GlobalBlockerRouteWithChildren, RedirectTargetRoute: RedirectTargetRouteWithChildren, StructuralSharingEnabledRoute: StructuralSharingEnabledRoute, ParamsPsIndexRoute: ParamsPsIndexRoute, diff --git a/e2e/react-router/basic-file-based/src/routes/__root.tsx b/e2e/react-router/basic-file-based/src/routes/__root.tsx index 4062165c86..d97db6fd4b 100644 --- a/e2e/react-router/basic-file-based/src/routes/__root.tsx +++ b/e2e/react-router/basic-file-based/src/routes/__root.tsx @@ -131,6 +131,14 @@ function RootComponent() { > This Route Does Not Exist + + Multi Blockers +
diff --git a/e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.multi-blockers.tsx b/e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.multi-blockers.tsx new file mode 100644 index 0000000000..8512cd8647 --- /dev/null +++ b/e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.multi-blockers.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { createFileRoute, useBlocker } from '@tanstack/react-router' + +export const Route = createFileRoute('/global-blocker/_layout/multi-blockers')({ + component: MultiBlockersPage, +}) + +function MultiBlockersPage() { + const blocker1 = useBlocker({ + shouldBlockFn: async () => Promise.resolve(true), + enableBeforeUnload: true, + disabled: false, + withResolver: true, + }) + + const blocker2 = useBlocker({ + shouldBlockFn: async () => Promise.resolve(true), + enableBeforeUnload: true, + disabled: false, + withResolver: true, + }) + + return ( +
+

This page always blocks navigation

+
blocker1 is {blocker1.status}
+
blocker2 is {blocker2.status}
+
+ ) +} diff --git a/e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.tsx b/e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.tsx new file mode 100644 index 0000000000..7c2e0094a4 --- /dev/null +++ b/e2e/react-router/basic-file-based/src/routes/global-blocker/_layout.tsx @@ -0,0 +1,52 @@ +import { + Outlet, + createFileRoute, + useNavigationBlockingState, +} from '@tanstack/react-router' + +export const Route = createFileRoute('/global-blocker/_layout')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { proceed, reset, status, proceedAll } = useNavigationBlockingState() + + return ( +
+ {status === 'blocked' ? ( +
+

Global Blocking Modal

+
Navigation is blocked
+
+ + + +
+
+ ) : null} + +
+ ) +} diff --git a/e2e/react-router/basic-file-based/tests/app.spec.ts b/e2e/react-router/basic-file-based/tests/app.spec.ts index d41ed601c2..4643746a47 100644 --- a/e2e/react-router/basic-file-based/tests/app.spec.ts +++ b/e2e/react-router/basic-file-based/tests/app.spec.ts @@ -107,6 +107,113 @@ test('legacy Proceeding through blocked navigation works', async ({ page }) => { await expect(page.getByRole('heading')).toContainText('Editing A') }) +test('useNavigationBlockingState exposes state and resets navigation', async ({ + page, +}) => { + await page.goto('/global-blocker/multi-blockers') + + const blocker1Status = page.getByTestId('blocker-1-status') + const blocker2Status = page.getByTestId('blocker-2-status') + const globalBlockingModalHeading = page.getByRole('heading', { + name: 'Global Blocking Modal', + exact: true, + }) + const resetButton = page.getByRole('button', { name: 'Reset' }) + + await expect(page.getByRole('heading')).toContainText( + 'This page always blocks navigation', + ) + await expect(blocker1Status).toHaveText('blocker1 is idle') + await expect(blocker2Status).toHaveText('blocker2 is idle') + + await page.getByRole('link', { name: 'Home', exact: true }).click() + + await expect(blocker1Status).toHaveText('blocker1 is blocked') + await expect(blocker2Status).toHaveText('blocker2 is idle') + await expect(globalBlockingModalHeading).toBeVisible() + + await resetButton.click() + + await expect(globalBlockingModalHeading).not.toBeVisible() + await expect(blocker1Status).toHaveText('blocker1 is idle') + await expect(blocker2Status).toHaveText('blocker2 is idle') +}) + +test('useNavigationBlockingState exposes state and proceed navigation', async ({ + page, +}) => { + await page.goto('/global-blocker/multi-blockers') + + const blocker1Status = page.getByTestId('blocker-1-status') + const blocker2Status = page.getByTestId('blocker-2-status') + const globalBlockingModalHeading = page.getByRole('heading', { + name: 'Global Blocking Modal', + exact: true, + }) + const proceedButton = page.getByRole('button', { + name: 'Proceed', + exact: true, + }) + + await expect(page.getByRole('heading')).toContainText( + 'This page always blocks navigation', + ) + await expect(blocker1Status).toHaveText('blocker1 is idle') + await expect(blocker2Status).toHaveText('blocker2 is idle') + + await page.getByRole('link', { name: 'Home', exact: true }).click() + + await expect(blocker1Status).toHaveText('blocker1 is blocked') + await expect(blocker2Status).toHaveText('blocker2 is idle') + await expect(globalBlockingModalHeading).toBeVisible() + + await proceedButton.click() + + await expect(blocker1Status).toHaveText('blocker1 is idle') + await expect(blocker2Status).toHaveText('blocker2 is blocked') + await expect(globalBlockingModalHeading).toBeVisible() + + await proceedButton.click() + + await expect(globalBlockingModalHeading).not.toBeVisible() + await expect( + page.getByRole('heading', { name: 'Welcome Home!', exact: true }), + ).toBeVisible() +}) + +test('useNavigationBlockingState exposes state and proceed navigation using proceedAll', async ({ + page, +}) => { + await page.goto('/global-blocker/multi-blockers') + + const blocker1Status = page.getByTestId('blocker-1-status') + const blocker2Status = page.getByTestId('blocker-2-status') + const globalBlockingModalHeading = page.getByRole('heading', { + name: 'Global Blocking Modal', + exact: true, + }) + const proceedAllButton = page.getByRole('button', { name: 'Proceed All' }) + + await expect(page.getByRole('heading')).toContainText( + 'This page always blocks navigation', + ) + await expect(blocker1Status).toHaveText('blocker1 is idle') + await expect(blocker2Status).toHaveText('blocker2 is idle') + + await page.getByRole('link', { name: 'Home', exact: true }).click() + + await expect(blocker1Status).toHaveText('blocker1 is blocked') + await expect(blocker2Status).toHaveText('blocker2 is idle') + await expect(globalBlockingModalHeading).toBeVisible() + + await proceedAllButton.click() + + await expect(globalBlockingModalHeading).not.toBeVisible() + await expect( + page.getByRole('heading', { name: 'Welcome Home!', exact: true }), + ).toBeVisible() +}) + test('useCanGoBack correctly disables back button', async ({ page }) => { const getBackButtonDisabled = async () => { const backButton = page.getByTestId('back-button') diff --git a/examples/react/global-blocking-state/.gitignore b/examples/react/global-blocking-state/.gitignore new file mode 100644 index 0000000000..a6ea47e508 --- /dev/null +++ b/examples/react/global-blocking-state/.gitignore @@ -0,0 +1,10 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/examples/react/global-blocking-state/.vscode/settings.json b/examples/react/global-blocking-state/.vscode/settings.json new file mode 100644 index 0000000000..00b5278e58 --- /dev/null +++ b/examples/react/global-blocking-state/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/examples/react/global-blocking-state/README.md b/examples/react/global-blocking-state/README.md new file mode 100644 index 0000000000..115199d292 --- /dev/null +++ b/examples/react/global-blocking-state/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm start` or `yarn start` diff --git a/examples/react/global-blocking-state/index.html b/examples/react/global-blocking-state/index.html new file mode 100644 index 0000000000..9b6335c0ac --- /dev/null +++ b/examples/react/global-blocking-state/index.html @@ -0,0 +1,12 @@ + + + + + + Vite App + + +
+ + + diff --git a/examples/react/global-blocking-state/package.json b/examples/react/global-blocking-state/package.json new file mode 100644 index 0000000000..7b27802bc4 --- /dev/null +++ b/examples/react/global-blocking-state/package.json @@ -0,0 +1,30 @@ +{ + "name": "tanstack-router-react-example-global-blocking-state", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "build": "vite build && tsc --noEmit", + "serve": "vite preview", + "start": "vite" + }, + "dependencies": { + "@tanstack/react-router": "^1.120.20", + "@tanstack/react-router-devtools": "^1.120.20", + "@tanstack/router-plugin": "^1.120.20", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "postcss": "^8.5.1", + "autoprefixer": "^10.4.20", + "tailwindcss": "^3.4.17", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.1.0" + } +} \ No newline at end of file diff --git a/examples/react/global-blocking-state/postcss.config.mjs b/examples/react/global-blocking-state/postcss.config.mjs new file mode 100644 index 0000000000..2e7af2b7f1 --- /dev/null +++ b/examples/react/global-blocking-state/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/react/global-blocking-state/src/main.tsx b/examples/react/global-blocking-state/src/main.tsx new file mode 100644 index 0000000000..a6c8b068ac --- /dev/null +++ b/examples/react/global-blocking-state/src/main.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import './styles.css' + +const router = createRouter({ + routeTree, + defaultPreload: 'intent', + context: {}, + defaultPreloadStaleTime: 0, + scrollRestoration: true, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app')! + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render() +} diff --git a/examples/react/global-blocking-state/src/routeTree.gen.ts b/examples/react/global-blocking-state/src/routeTree.gen.ts new file mode 100644 index 0000000000..4f8c592672 --- /dev/null +++ b/examples/react/global-blocking-state/src/routeTree.gen.ts @@ -0,0 +1,95 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as MultiBlockersIndexRouteImport } from './routes/multi-blockers.index' +import { Route as BlockingIndexRouteImport } from './routes/blocking.index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const MultiBlockersIndexRoute = MultiBlockersIndexRouteImport.update({ + id: '/multi-blockers/', + path: '/multi-blockers/', + getParentRoute: () => rootRouteImport, +} as any) +const BlockingIndexRoute = BlockingIndexRouteImport.update({ + id: '/blocking/', + path: '/blocking/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/blocking': typeof BlockingIndexRoute + '/multi-blockers': typeof MultiBlockersIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/blocking': typeof BlockingIndexRoute + '/multi-blockers': typeof MultiBlockersIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/blocking/': typeof BlockingIndexRoute + '/multi-blockers/': typeof MultiBlockersIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/blocking' | '/multi-blockers' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/blocking' | '/multi-blockers' + id: '__root__' | '/' | '/blocking/' | '/multi-blockers/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + BlockingIndexRoute: typeof BlockingIndexRoute + MultiBlockersIndexRoute: typeof MultiBlockersIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/multi-blockers/': { + id: '/multi-blockers/' + path: '/multi-blockers' + fullPath: '/multi-blockers' + preLoaderRoute: typeof MultiBlockersIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/blocking/': { + id: '/blocking/' + path: '/blocking' + fullPath: '/blocking' + preLoaderRoute: typeof BlockingIndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + BlockingIndexRoute: BlockingIndexRoute, + MultiBlockersIndexRoute: MultiBlockersIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/examples/react/global-blocking-state/src/routes/__root.tsx b/examples/react/global-blocking-state/src/routes/__root.tsx new file mode 100644 index 0000000000..a1d3ee6a8e --- /dev/null +++ b/examples/react/global-blocking-state/src/routes/__root.tsx @@ -0,0 +1,119 @@ +import * as React from 'react' +import { + Link, + Outlet, + createRootRoute, + useNavigationBlockingState, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + const { proceed, reset, status, proceedAll } = useNavigationBlockingState() + + return ( + <> +
+ + + + + + + + + + + + + {status === 'blocked' ? ( +
+ This modal rendered from __root.tsx, but blocking comes from + blocking.index.tsx + + + +
+ ) : ( +
+ Router not blocking +
+ )} +
+
+ + + + + ) +} diff --git a/examples/react/global-blocking-state/src/routes/blocking.index.tsx b/examples/react/global-blocking-state/src/routes/blocking.index.tsx new file mode 100644 index 0000000000..60a9d2ef29 --- /dev/null +++ b/examples/react/global-blocking-state/src/routes/blocking.index.tsx @@ -0,0 +1,17 @@ +import * as React from 'react' +import { createFileRoute, useBlocker } from '@tanstack/react-router' + +export const Route = createFileRoute('/blocking/')({ + component: PostsIndexComponent, +}) + +function PostsIndexComponent() { + useBlocker({ + shouldBlockFn: async () => Promise.resolve(true), + enableBeforeUnload: true, + disabled: false, + withResolver: true, + }) + + return
This page always blocks navigation
+} diff --git a/examples/react/global-blocking-state/src/routes/index.tsx b/examples/react/global-blocking-state/src/routes/index.tsx new file mode 100644 index 0000000000..a80ee027c1 --- /dev/null +++ b/examples/react/global-blocking-state/src/routes/index.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

This page does not block anything

+
+ ) +} diff --git a/examples/react/global-blocking-state/src/routes/multi-blockers.index.tsx b/examples/react/global-blocking-state/src/routes/multi-blockers.index.tsx new file mode 100644 index 0000000000..6ff6052899 --- /dev/null +++ b/examples/react/global-blocking-state/src/routes/multi-blockers.index.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { createFileRoute, useBlocker } from '@tanstack/react-router' + +export const Route = createFileRoute('/multi-blockers/')({ + component: MultiBlockers, +}) + +function MultiBlockers() { + const blocker1 = useBlocker({ + shouldBlockFn: async () => Promise.resolve(true), + enableBeforeUnload: true, + disabled: false, + withResolver: true, + }) + + const blocker2 = useBlocker({ + shouldBlockFn: async () => Promise.resolve(true), + enableBeforeUnload: true, + disabled: false, + withResolver: true, + }) + + return ( +
+ This page always blocks navigation +
blocker1 is {blocker1.status}
+
blocker2 is {blocker2.status}
+
+ ) +} diff --git a/examples/react/global-blocking-state/src/styles.css b/examples/react/global-blocking-state/src/styles.css new file mode 100644 index 0000000000..0b8e317099 --- /dev/null +++ b/examples/react/global-blocking-state/src/styles.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + color-scheme: light dark; +} +* { + @apply border-gray-200 dark:border-gray-800; +} +body { + @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200; +} diff --git a/examples/react/global-blocking-state/tailwind.config.mjs b/examples/react/global-blocking-state/tailwind.config.mjs new file mode 100644 index 0000000000..4986094b9d --- /dev/null +++ b/examples/react/global-blocking-state/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], +} diff --git a/examples/react/global-blocking-state/tsconfig.dev.json b/examples/react/global-blocking-state/tsconfig.dev.json new file mode 100644 index 0000000000..285a09b0dc --- /dev/null +++ b/examples/react/global-blocking-state/tsconfig.dev.json @@ -0,0 +1,10 @@ +{ + "composite": true, + "extends": "../../../tsconfig.base.json", + + "files": ["src/main.tsx"], + "include": [ + "src" + // "__tests__/**/*.test.*" + ] +} diff --git a/examples/react/global-blocking-state/tsconfig.json b/examples/react/global-blocking-state/tsconfig.json new file mode 100644 index 0000000000..09e87d501d --- /dev/null +++ b/examples/react/global-blocking-state/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "skipLibCheck": true + } +} diff --git a/examples/react/global-blocking-state/vite.config.js b/examples/react/global-blocking-state/vite.config.js new file mode 100644 index 0000000000..b819774577 --- /dev/null +++ b/examples/react/global-blocking-state/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { TanStackRouterVite } from '@tanstack/router-plugin/vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + TanStackRouterVite({ target: 'react', autoCodeSplitting: true }), + react(), + ], +}) diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 3178cb90bb..5ea470acb2 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -8,12 +8,18 @@ export interface NavigateOptions { type SubscriberHistoryAction = | { - type: Exclude + type: Exclude } | { type: 'GO' index: number } + | { + type: 'BLOCK' + proceedAll: () => void + proceed: () => void + reset: () => void + } type SubscriberArgs = { location: HistoryLocation @@ -60,7 +66,14 @@ export type ParsedHistoryState = HistoryState & { type ShouldAllowNavigation = any -export type HistoryAction = 'PUSH' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO' +export type HistoryAction = + | 'PUSH' + | 'REPLACE' + | 'FORWARD' + | 'BACK' + | 'GO' + | 'BLOCK' + | 'DISMISS_BLOCK' export type BlockerFnArgs = { currentLocation: HistoryLocation @@ -70,7 +83,7 @@ export type BlockerFnArgs = { export type BlockerFn = ( args: BlockerFnArgs, -) => Promise | ShouldAllowNavigation +) => AsyncGenerator<(value: boolean) => void, ShouldAllowNavigation, unknown> export type NavigationBlocker = { blockerFn: BlockerFn @@ -143,11 +156,45 @@ export function createHistory(opts: { if (typeof document !== 'undefined' && blockers.length && isPushOrReplace) { for (const blocker of blockers) { const nextLocation = parseHref(actionInfo.path, actionInfo.state) - const isBlocked = await blocker.blockerFn({ + + const generator = blocker.blockerFn({ currentLocation: location, nextLocation, action: actionInfo.type, }) + + let isBlocked = false + let blockNotified = false + let proceedAllCalled = false + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { value, done } = await generator.next() + + if (!done) { + const resolver = value + notify({ + type: 'BLOCK', + proceed: () => resolver(false), + reset: () => resolver(true), + proceedAll: () => { + proceedAllCalled = true + resolver(false) + }, + }) + blockNotified = true + } + + if (done) { + isBlocked = value + if (blockNotified) notify({ type: 'DISMISS_BLOCK' }) + break + } + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (proceedAllCalled) break + if (isBlocked) { opts.onBlocked?.() return @@ -431,11 +478,43 @@ export function createBrowserHistory(opts?: { const blockers = _getBlockers() if (typeof document !== 'undefined' && blockers.length) { for (const blocker of blockers) { - const isBlocked = await blocker.blockerFn({ + const generator = blocker.blockerFn({ currentLocation, nextLocation, action, }) + + let isBlocked = false + let blockNotified = false + let proceedAllCalled = false + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { value, done } = await generator.next() + + if (!done) { + const resolver = value + history.notify({ + type: 'BLOCK', + proceed: () => resolver(false), + reset: () => resolver(true), + proceedAll: () => { + proceedAllCalled = true + resolver(false) + }, + }) + blockNotified = true + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (proceedAllCalled) break + + if (done) { + isBlocked = value + if (blockNotified) history.notify({ type: 'DISMISS_BLOCK' }) + break + } + } + if (isBlocked) { ignoreNextPop = true win.history.go(1) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 2d32671951..f2061a074f 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -297,6 +297,7 @@ export { export type { UseBlockerOpts, ShouldBlockFn } from './useBlocker' export { useBlocker, Block } from './useBlocker' +export { useNavigationBlockingState } from './useNavigationBlockingState' export { useNavigate, Navigate } from './useNavigate' diff --git a/packages/react-router/src/useBlocker.tsx b/packages/react-router/src/useBlocker.tsx index 84a8eba9db..7c6bf37d8f 100644 --- a/packages/react-router/src/useBlocker.tsx +++ b/packages/react-router/src/useBlocker.tsx @@ -172,7 +172,7 @@ export function useBlocker( }) React.useEffect(() => { - const blockerFnComposed = async (blockerFnArgs: BlockerFnArgs) => { + async function* blockerFnComposed(blockerFnArgs: BlockerFnArgs) { function getLocation( location: HistoryLocation, ): AnyShouldBlockFnLocation { @@ -208,8 +208,9 @@ export function useBlocker( if (!shouldBlock) { return false } - + let resolvePromise: (value: boolean) => void = () => {} const promise = new Promise((resolve) => { + resolvePromise = resolve setResolver({ status: 'blocked', current, @@ -219,7 +220,7 @@ export function useBlocker( reset: () => resolve(true), }) }) - + yield resolvePromise const canNavigateAsync = await promise setResolver({ status: 'idle', diff --git a/packages/react-router/src/useNavigationBlockingState.ts b/packages/react-router/src/useNavigationBlockingState.ts new file mode 100644 index 0000000000..7604f10feb --- /dev/null +++ b/packages/react-router/src/useNavigationBlockingState.ts @@ -0,0 +1,51 @@ +import React from 'react' +import { useRouter } from './useRouter' +import type { RouterHistory } from '@tanstack/history' + +type BlockerState = { + status: 'idle' | 'blocked' + reset: () => void + proceed: () => void + proceedAll: () => void +} + +const initialState = () => + ({ + status: 'idle', + reset: () => {}, + proceed: () => {}, + proceedAll: () => {}, + }) as const + +export const useNavigationBlockingState = () => { + const { history } = useRouter() + const [state, setState] = React.useState(initialState) + + React.useEffect(() => { + if (!history) { + return + } + const unsubscribe = (history as RouterHistory).subscribe((event) => { + if (event.action.type === 'BLOCK') { + const { proceed, proceedAll, reset } = event.action + setState({ + status: 'blocked', + proceed, + proceedAll, + reset, + }) + return + } + + if (event.action.type === 'DISMISS_BLOCK') { + setState(initialState()) + } + }) + + return () => { + unsubscribe() + } + }, [history]) + + return state +} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 81e6abb24e..15bceadafd 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -780,7 +780,6 @@ export class RouterCore< viewTransitionPromise?: ControlledPromise isScrollRestoring = false isScrollRestorationSetup = false - // Must build in constructor __store!: Store> options!: PickAsRequired< diff --git a/packages/solid-router/src/useBlocker.tsx b/packages/solid-router/src/useBlocker.tsx index 7bc23280e5..f2fe9f8665 100644 --- a/packages/solid-router/src/useBlocker.tsx +++ b/packages/solid-router/src/useBlocker.tsx @@ -181,7 +181,7 @@ export function useBlocker( }) Solid.createEffect(() => { - const blockerFnComposed = async (blockerFnArgs: BlockerFnArgs) => { + async function* blockerFnComposed(blockerFnArgs: BlockerFnArgs) { function getLocation( location: HistoryLocation, ): AnyShouldBlockFnLocation { @@ -217,8 +217,9 @@ export function useBlocker( if (!shouldBlock) { return false } - + let resolvePromise: (value: boolean) => void = () => {} const promise = new Promise((resolve) => { + resolvePromise = resolve setResolver({ status: 'blocked', current, @@ -228,7 +229,7 @@ export function useBlocker( reset: () => resolve(true), }) }) - + yield resolvePromise const canNavigateAsync = await promise setResolver({ status: 'idle', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18c05e4864..1917cfc794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3423,6 +3423,55 @@ importers: specifier: 6.3.5 version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + examples/react/global-blocking-state: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.3) + postcss: + specifier: ^8.5.1 + version: 8.5.3 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + zod: + specifier: ^3.24.2 + version: 3.25.32 + devDependencies: + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.1.4(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + typescript: + specifier: ^5.7.2 + version: 5.8.2 + vite: + specifier: 6.1.4 + version: 6.1.4(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + examples/react/kitchen-sink: dependencies: '@tanstack/react-router': diff --git a/port-tanstack-router-e2e-react-basic-file-based.txt b/port-tanstack-router-e2e-react-basic-file-based.txt new file mode 100644 index 0000000000..44e74ae391 --- /dev/null +++ b/port-tanstack-router-e2e-react-basic-file-based.txt @@ -0,0 +1 @@ +53549 \ No newline at end of file