diff --git a/docs/framework/react/api/router/useBlockerHook.md b/docs/framework/react/api/router/useBlockerHook.md index 699dff7d2a..fac4ffc64b 100644 --- a/docs/framework/react/api/router/useBlockerHook.md +++ b/docs/framework/react/api/router/useBlockerHook.md @@ -5,17 +5,61 @@ title: useBlocker hook The `useBlocker` method is a hook that [blocks navigation](../../guide/navigation-blocking.md) when a condition is met. +> ⚠️ The following new `useBlocker` API is currently _experimental_. + ## useBlocker options -The `useBlocker` hook accepts a single _optional_ argument, an option object: +The `useBlocker` hook accepts a single _required_ argument, an option object: + +### `options.shouldBlockFn` option + +- Required +- Type: `ShouldBlockFn` +- This function should return a `boolean` or a `Promise` that tells the blocker if it should block the current navigation +- The function has the argument of type `ShouldBlockFnArgs` passed to it, which tells you information about the current and next route and the action performed +- Think of this function as telling the router if it should block the navigation, so returning `true` mean that it should block the navgation and `false` that it should be allowed + +```ts +interface ShouldBlockFnLocation<...> { + routeId: TRouteId + fullPath: TFullPath + pathname: string + params: TAllParams + search: TFullSearchSchema +} + +type ShouldBlockFnArgs = { + current: ShouldBlockFnLocation + next: ShouldBlockFnLocation + action: HistoryAction +} +``` + +### `options.disabled` option -### `options.blockerFn` option +- Optional - defaults to `false` +- Type: `boolean` +- Specifies if the blocker should be entirely disabled or not + +### `options.enableBeforeUnload` option + +- Optional - defaults to `true` +- Type: `boolean | (() => boolean)` +- Tell the blocker to sometimes or always block the browser `beforeUnload` event or not + +### `options.withResolver` option + +- Optional - defaults to `false` +- Type: `boolean` +- Specify if the resolver returned by the hook should be used or whether your `shouldBlockFn` function itself resolves the blocking + +### `options.blockerFn` option (⚠️ deprecated) - Optional - Type: `BlockerFn` - The function that returns a `boolean` or `Promise` indicating whether to allow navigation. -### `options.condition` option +### `options.condition` option (⚠️ deprecated) - Optional - defaults to `true` - Type: `boolean` @@ -26,8 +70,15 @@ The `useBlocker` hook accepts a single _optional_ argument, an option object: An object with the controls to allow manual blocking and unblocking of navigation. - `status` - A string literal that can be either `'blocked'` or `'idle'` -- `proceed` - A function that allows navigation to continue -- `reset` - A function that cancels navigation (`status` will be be reset to `'idle'`) +- `next` - When status is `blocked`, a type narrrowable object that contains information about the next location +- `current` - When status is `blocked`, a type narrrowable object that contains information about the current location +- `action` - When status is `blocked`, a `HistoryAction` string that shows the action that triggered the navigation +- `proceed` - When status is `blocked`, a function that allows navigation to continue +- `reset` - When status is `blocked`, a function that cancels navigation (`status` will be be reset to `'idle'`) + +or + +`void` when `withResolver` is `false` ## Examples @@ -42,8 +93,7 @@ function MyComponent() { const [formIsDirty, setFormIsDirty] = useState(false) useBlocker({ - blockerFn: () => window.confirm('Are you sure you want to leave?'), - condition: formIsDirty, + shouldBlockFn: () => formIsDirty, }) // ... @@ -58,8 +108,39 @@ import { useBlocker } from '@tanstack/react-router' function MyComponent() { const [formIsDirty, setFormIsDirty] = useState(false) + const { proceed, reset, status, next } = useBlocker({ + shouldBlockFn: () => formIsDirty, + withResolver: true, + }) + + // ... + + return ( + <> + {/* ... */} + {status === 'blocked' && ( +
+

You are navigating to {next.pathname}

+

Are you sure you want to leave?

+ + +
+ )} + +} +``` + +### Conditional blocking + +```tsx +import { useBlocker } from '@tanstack/react-router' + +function MyComponent() { const { proceed, reset, status } = useBlocker({ - condition: formIsDirty, + shouldBlockFn: ({ nextLocation }) => { + return !nextLocation.pathname.includes('step/') + }, + withResolver: true, }) // ... @@ -75,5 +156,58 @@ function MyComponent() { )} + ) +} +``` + +### Without resolver + +```tsx +import { useBlocker } from '@tanstack/react-router' + +function MyComponent() { + const [formIsDirty, setFormIsDirty] = useState(false) + + useBlocker({ + shouldBlockFn: ({ nextLocation }) => { + if (nextLocation.pathname.includes('step/')) { + return false + } + + const shouldLeave = confirm('Are you sure you want to leave?') + return !shouldLeave + }, + }) + + // ... +} +``` + +### Type narrowing + +```tsx +import { useBlocker } from '@tanstack/react-router' + +function MyComponent() { + const [formIsDirty, setFormIsDirty] = useState(false) + + // block going from editor-1 to /foo/123?hello=world + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: ({ current, next }) => { + if ( + current.routeId === '/editor-1' && + next.fullPath === '/foo/$id' && + next.params.id === '123' && + next.search.hello === 'world' + ) { + return true + } + return false + }, + enableBeforeUnload: false, + withResolver: true, + }) + + // ... } ``` diff --git a/docs/framework/react/guide/navigation-blocking.md b/docs/framework/react/guide/navigation-blocking.md index f00b774e35..6bb85edab4 100644 --- a/docs/framework/react/guide/navigation-blocking.md +++ b/docs/framework/react/guide/navigation-blocking.md @@ -20,11 +20,7 @@ Navigation blocking adds one or more layers of "blockers" to the entire underlyi - Custom UI - If the navigation is triggered by something we control at the router level, we can allow you to perform any task or show any UI you'd like to the user to confirm the action. Each blocker's `blocker` function will be asynchronously and sequentially executed. If any blocker function resolves or returns `true`, the navigation will be allowed and all other blockers will continue to do the same until all blockers have been allowed to proceed. If any single blocker resolves or returns `false`, the navigation will be canceled and the rest of the `blocker` functions will be ignored. - The `onbeforeunload` event - - For page events that we cannot control directly, we rely on the browser's `onbeforeunload` event. If the user attempts to close the tab or window, refresh, or "unload" the page assets in any way, the browser's generic "Are you sure you want to leave?" dialog will be shown. If the user confirms, all blockers will be bypassed and the page will unload. If the user cancels, the unload will be cancelled, and the page will remain as is. It's important to note that **custom blocker functions will not be executed** when the `onbeforeunload` flow is triggered. - -## What about the back button? - -The back button is a special case. When the user clicks the back button, we cannot intercept or control the browser's behavior in a reliable way, and there is no official way to block it that works across all browsers equally. If you encounter a situation where you need to block the back button, it's recommended to rethink your UI/UX to avoid the back button being destructive to any unsaved user data. Saving data to session storage and restoring it if the user returns to the page is a safe and reliable pattern. + - For page events that we cannot control directly, we rely on the browser's `onbeforeunload` event. If the user attempts to close the tab or window, refresh, or "unload" the page assets in any way, the browser's generic "Are you sure you want to leave?" dialog will be shown. If the user confirms, all blockers will be bypassed and the page will unload. If the user cancels, the unload will be cancelled, and the page will remain as is. ## How do I use navigation blocking? @@ -44,8 +40,12 @@ function MyComponent() { const [formIsDirty, setFormIsDirty] = useState(false) useBlocker({ - blockerFn: () => window.confirm('Are you sure you want to leave?'), - condition: formIsDirty, + shouldBlockFn: () => { + if (!formIsDirty) return false + + const shouldLeave = confirm('Are you sure you want to leave?') + return !shouldLeave + }, }) // ... @@ -66,19 +66,20 @@ function MyComponent() { return ( window.confirm('Are you sure you want to leave?')} - condition={formIsDirty} + shouldBlockFn={() => { + if (!formIsDirty) return false + + const shouldLeave = confirm('Are you sure you want to leave?') + return !shouldLeave + }} /> ) // OR return ( - window.confirm('Are you sure you want to leave?')} - condition={formIsDirty} - > - {/* ... */} + !formIsDirty} withResolver> + {({ status, proceed, reset }) => <>{/* ... */}} ) } @@ -86,13 +87,13 @@ function MyComponent() { ## How can I show a custom UI? -In most cases, passing `window.confirm` to the `blockerFn` field of the hook input is enough since it will clearly show the user that the navigation is being blocked. +In most cases, using `window.confirm` in the `shouldBlockFn` function with `withResolver: false` in the hook is enough since it will clearly show the user that the navigation is being blocked and resolve the blocking based on their response. However, in some situations, you might want to show a custom UI that is intentionally less disruptive and more integrated with your app's design. -**Note:** The return value of `blockerFn` takes precedence, do not pass it if you want to use the manual `proceed` and `reset` functions. +**Note:** The return value of `shouldBlockFn` does not resolve the blocking if `withResolver` is `true`. -### Hook/logical-based custom UI +### Hook/logical-based custom UI with resolver ```tsx import { useBlocker } from '@tanstack/react-router' @@ -101,7 +102,8 @@ function MyComponent() { const [formIsDirty, setFormIsDirty] = useState(false) const { proceed, reset, status } = useBlocker({ - condition: formIsDirty, + shouldBlockFn: () => formIsDirty, + withResolver: true, }) // ... @@ -120,6 +122,45 @@ function MyComponent() { } ``` +### Hook/logical-based custom UI without resolver + +```tsx +import { useBlocker } from '@tanstack/react-router' + +function MyComponent() { + const [formIsDirty, setFormIsDirty] = useState(false) + + useBlocker({ + shouldBlockFn: () => { + if (!formIsDirty) return false + + const shouldLeave = new Promise((resolve) => { + // Using a modal manager of your choice + modals.open({ + title: 'Are you sure you want to leave?', + children: ( + { + modals.closeAll() + resolve(true) + }} + reject={() => { + modals.closeAll() + resolve(true) + }} + /> + ), + onClose: () => resolve(false), + }) + }) + return !shouldLeave + }, + }) + + // ... +} +``` + ### Component-based custom UI Similarly to the hook, the `Block` component returns the same state and functions as render props: @@ -131,7 +172,7 @@ function MyComponent() { const [formIsDirty, setFormIsDirty] = useState(false) return ( - + formIsDirty} withResolver> {({ status, proceed, reset }) => ( <> {/* ... */} 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 c5469a9232..0b0b79b45d 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -14,6 +14,8 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRoute } from './routes/__root' import { Route as PostsImport } from './routes/posts' +import { Route as EditingBImport } from './routes/editing-b' +import { Route as EditingAImport } from './routes/editing-a' import { Route as AnchorImport } from './routes/anchor' import { Route as LayoutImport } from './routes/_layout' import { Route as IndexImport } from './routes/index' @@ -53,6 +55,18 @@ const PostsRoute = PostsImport.update({ getParentRoute: () => rootRoute, } as any) +const EditingBRoute = EditingBImport.update({ + id: '/editing-b', + path: '/editing-b', + getParentRoute: () => rootRoute, +} as any) + +const EditingARoute = EditingAImport.update({ + id: '/editing-a', + path: '/editing-a', + getParentRoute: () => rootRoute, +} as any) + const AnchorRoute = AnchorImport.update({ id: '/anchor', path: '/anchor', @@ -205,6 +219,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AnchorImport parentRoute: typeof rootRoute } + '/editing-a': { + id: '/editing-a' + path: '/editing-a' + fullPath: '/editing-a' + preLoaderRoute: typeof EditingAImport + parentRoute: typeof rootRoute + } + '/editing-b': { + id: '/editing-b' + path: '/editing-b' + fullPath: '/editing-b' + preLoaderRoute: typeof EditingBImport + parentRoute: typeof rootRoute + } '/posts': { id: '/posts' path: '/posts' @@ -435,6 +463,8 @@ export interface FileRoutesByFullPath { '/': typeof groupLayoutRouteWithChildren '': typeof LayoutLayout2RouteWithChildren '/anchor': typeof AnchorRoute + '/editing-a': typeof EditingARoute + '/editing-b': typeof EditingBRoute '/posts': typeof PostsRouteWithChildren '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute @@ -458,6 +488,8 @@ export interface FileRoutesByTo { '/': typeof groupLayoutRouteWithChildren '': typeof LayoutLayout2RouteWithChildren '/anchor': typeof AnchorRoute + '/editing-a': typeof EditingARoute + '/editing-b': typeof EditingBRoute '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute @@ -480,6 +512,8 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_layout': typeof LayoutRouteWithChildren '/anchor': typeof AnchorRoute + '/editing-a': typeof EditingARoute + '/editing-b': typeof EditingBRoute '/posts': typeof PostsRouteWithChildren '/(another-group)/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/(group)': typeof groupRouteWithChildren @@ -508,6 +542,8 @@ export interface FileRouteTypes { | '/' | '' | '/anchor' + | '/editing-a' + | '/editing-b' | '/posts' | '/onlyrouteinside' | '/inside' @@ -530,6 +566,8 @@ export interface FileRouteTypes { | '/' | '' | '/anchor' + | '/editing-a' + | '/editing-b' | '/onlyrouteinside' | '/inside' | '/lazyinside' @@ -550,6 +588,8 @@ export interface FileRouteTypes { | '/' | '/_layout' | '/anchor' + | '/editing-a' + | '/editing-b' | '/posts' | '/(another-group)/onlyrouteinside' | '/(group)' @@ -577,6 +617,8 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute LayoutRoute: typeof LayoutRouteWithChildren AnchorRoute: typeof AnchorRoute + EditingARoute: typeof EditingARoute + EditingBRoute: typeof EditingBRoute PostsRoute: typeof PostsRouteWithChildren anotherGroupOnlyrouteinsideRoute: typeof anotherGroupOnlyrouteinsideRoute groupRoute: typeof groupRouteWithChildren @@ -590,6 +632,8 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LayoutRoute: LayoutRouteWithChildren, AnchorRoute: AnchorRoute, + EditingARoute: EditingARoute, + EditingBRoute: EditingBRoute, PostsRoute: PostsRouteWithChildren, anotherGroupOnlyrouteinsideRoute: anotherGroupOnlyrouteinsideRoute, groupRoute: groupRouteWithChildren, @@ -612,6 +656,8 @@ export const routeTree = rootRoute "/", "/_layout", "/anchor", + "/editing-a", + "/editing-b", "/posts", "/(another-group)/onlyrouteinside", "/(group)", @@ -633,6 +679,12 @@ export const routeTree = rootRoute "/anchor": { "filePath": "anchor.tsx" }, + "/editing-a": { + "filePath": "editing-a.tsx" + }, + "/editing-b": { + "filePath": "editing-b.tsx" + }, "/posts": { "filePath": "posts.tsx", "children": [ diff --git a/e2e/react-router/basic-file-based/src/routes/editing-a.tsx b/e2e/react-router/basic-file-based/src/routes/editing-a.tsx new file mode 100644 index 0000000000..cecd0dfed1 --- /dev/null +++ b/e2e/react-router/basic-file-based/src/routes/editing-a.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import { createFileRoute, useBlocker } from '@tanstack/react-router' + +export const Route = createFileRoute('/editing-a')({ + component: RouteComponent, +}) + +function RouteComponent() { + const navigate = Route.useNavigate() + const [input, setInput] = React.useState('') + + const { proceed, status } = useBlocker({ + shouldBlockFn: ({ next }) => { + if (next.fullPath === '/editing-b' && input.length > 0) { + return true + } + return false + }, + withResolver: true, + }) + + return ( +
+

Editing A

+ + + {status === 'blocked' && ( + + )} +
+ ) +} diff --git a/e2e/react-router/basic-file-based/src/routes/editing-b.tsx b/e2e/react-router/basic-file-based/src/routes/editing-b.tsx new file mode 100644 index 0000000000..1a85e72e26 --- /dev/null +++ b/e2e/react-router/basic-file-based/src/routes/editing-b.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { createFileRoute, useBlocker } from '@tanstack/react-router' + +export const Route = createFileRoute('/editing-b')({ + component: RouteComponent, +}) + +function RouteComponent() { + const navigate = Route.useNavigate() + const [input, setInput] = React.useState('') + + const { proceed, status } = useBlocker({ + condition: input, + }) + + return ( +
+

Editing B

+ + + {status === 'blocked' && ( + + )} +
+ ) +} 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 f1a32a0c2d..50eea5f4c1 100644 --- a/e2e/react-router/basic-file-based/tests/app.spec.ts +++ b/e2e/react-router/basic-file-based/tests/app.spec.ts @@ -33,6 +33,80 @@ test('Navigating to a not-found route', async ({ page }) => { await expect(page.getByRole('heading')).toContainText('Welcome Home!') }) +test("useBlocker doesn't block navigation if condition is not met", async ({ + page, +}) => { + await page.goto('/editing-a') + await expect(page.getByRole('heading')).toContainText('Editing A') + + await page.getByRole('button', { name: 'Go to next step' }).click() + await expect(page.getByRole('heading')).toContainText('Editing B') +}) + +test('useBlocker does block navigation if condition is met', async ({ + page, +}) => { + await page.goto('/editing-a') + await expect(page.getByRole('heading')).toContainText('Editing A') + + await page.getByLabel('Enter your name:').fill('foo') + + await page.getByRole('button', { name: 'Go to next step' }).click() + await expect(page.getByRole('heading')).toContainText('Editing A') + + await expect(page.getByRole('button', { name: 'Proceed' })).toBeVisible() +}) + +test('Proceeding through blocked navigation works', async ({ page }) => { + await page.goto('/editing-a') + await expect(page.getByRole('heading')).toContainText('Editing A') + + await page.getByLabel('Enter your name:').fill('foo') + + await page.getByRole('button', { name: 'Go to next step' }).click() + await expect(page.getByRole('heading')).toContainText('Editing A') + + await page.getByRole('button', { name: 'Proceed' }).click() + await expect(page.getByRole('heading')).toContainText('Editing B') +}) + +test("legacy useBlocker doesn't block navigation if condition is not met", async ({ + page, +}) => { + await page.goto('/editing-b') + await expect(page.getByRole('heading')).toContainText('Editing B') + + await page.getByRole('button', { name: 'Go back' }).click() + await expect(page.getByRole('heading')).toContainText('Editing A') +}) + +test('legacy useBlocker does block navigation if condition is met', async ({ + page, +}) => { + await page.goto('/editing-b') + await expect(page.getByRole('heading')).toContainText('Editing B') + + await page.getByLabel('Enter your name:').fill('foo') + + await page.getByRole('button', { name: 'Go back' }).click() + await expect(page.getByRole('heading')).toContainText('Editing B') + + await expect(page.getByRole('button', { name: 'Proceed' })).toBeVisible() +}) + +test('legacy Proceeding through blocked navigation works', async ({ page }) => { + await page.goto('/editing-b') + await expect(page.getByRole('heading')).toContainText('Editing B') + + await page.getByLabel('Enter your name:').fill('foo') + + await page.getByRole('button', { name: 'Go back' }).click() + await expect(page.getByRole('heading')).toContainText('Editing B') + + await page.getByRole('button', { name: 'Proceed' }).click() + await expect(page.getByRole('heading')).toContainText('Editing A') +}) + const testCases = [ { description: 'Navigating to a route inside a route group', diff --git a/examples/react/navigation-blocking/src/main.tsx b/examples/react/navigation-blocking/src/main.tsx index 4c6037a738..83a8ea2c20 100644 --- a/examples/react/navigation-blocking/src/main.tsx +++ b/examples/react/navigation-blocking/src/main.tsx @@ -16,6 +16,23 @@ const rootRoute = createRootRoute({ }) function RootComponent() { + // block going from editor-1 to /foo/123?hello=world + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: ({ current, next }) => { + if ( + current.routeId === '/editor-1' && + next.fullPath === '/foo/$id' && + next.params.id === '123' && + next.search.hello === 'world' + ) { + return true + } + return false + }, + enableBeforeUnload: false, + withResolver: true, + }) + return ( <>
@@ -35,9 +52,59 @@ function RootComponent() { }} > Editor 1 - + {' '} + + Editor 2 + {' '} + + foo 123 + {' '} + + foo 456 + {' '}

+ + {status === 'blocked' && ( +
+
+ Are you sure you want to leave editor 1 for /foo/123?hello=world ? +
+ + +
+ )} @@ -58,6 +125,13 @@ function IndexComponent() { ) } +const fooRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'foo/$id', + validateSearch: (search) => ({ hello: search.hello }) as { hello: string }, + component: () => <>foo {fooRoute.useParams().id}, +}) + const editor1Route = createRoute({ getParentRoute: () => rootRoute, path: 'editor-1', @@ -66,26 +140,17 @@ const editor1Route = createRoute({ function Editor1Component() { const [value, setValue] = React.useState('') - const [useCustomBlocker, setUseCustomBlocker] = React.useState(false) - const { proceed, reset, status } = useBlocker({ - blockerFn: useCustomBlocker - ? undefined - : () => window.confirm('Are you sure you want to leave editor 1?'), - condition: value, + // Block leaving editor-1 if there is text in the input + const { proceed, reset, next, current, status } = useBlocker({ + shouldBlockFn: () => value !== '', + enableBeforeUnload: () => value !== '', + withResolver: true, }) return (

Editor 1

-
+
+ Go to Editor 2 + + {status === 'blocked' && (
Are you sure you want to leave editor 1?
+
+ You are going from {current.pathname} to {next.pathname} +
)} -
- Go to Editor 2 -
) } @@ -126,11 +195,6 @@ const editor2Route = createRoute({ function Editor2Component() { const [value, setValue] = React.useState('') - useBlocker({ - blockerFn: () => window.confirm('Are you sure you want to leave editor 2?'), - condition: value, - }) - return (

Editor 2

@@ -145,6 +209,7 @@ function Editor2Component() { const routeTree = rootRoute.addChildren([ indexRoute, + fooRoute, editor1Route.addChildren([editor2Route]), ]) diff --git a/examples/react/navigation-blocking/tsconfig.dev.json b/examples/react/navigation-blocking/tsconfig.dev.json deleted file mode 100644 index 285a09b0dc..0000000000 --- a/examples/react/navigation-blocking/tsconfig.dev.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "composite": true, - "extends": "../../../tsconfig.base.json", - - "files": ["src/main.tsx"], - "include": [ - "src" - // "__tests__/**/*.test.*" - ] -} diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index ab99760493..2a85d14950 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -5,21 +5,36 @@ export interface NavigateOptions { ignoreBlocker?: boolean } + +type SubscriberHistoryAction = + | { + type: HistoryAction | 'ROLLBACK' + } + | { + type: 'GO' + index: number + } + +type SubscriberArgs = { + location: HistoryLocation + action: SubscriberHistoryAction +} + export interface RouterHistory { location: HistoryLocation length: number - subscribers: Set<(opts: { location: HistoryLocation }) => void> - subscribe: (cb: (opts: { location: HistoryLocation }) => void) => () => void + subscribers: Set<(opts: SubscriberArgs) => void> + subscribe: (cb: (opts: SubscriberArgs) => void) => () => void push: (path: string, state?: any, navigateOpts?: NavigateOptions) => void replace: (path: string, state?: any, navigateOpts?: NavigateOptions) => void go: (index: number, navigateOpts?: NavigateOptions) => void back: (navigateOpts?: NavigateOptions) => void forward: (navigateOpts?: NavigateOptions) => void createHref: (href: string) => string - block: (blocker: BlockerFn) => () => void + block: (blocker: NavigationBlocker) => () => void flush: () => void destroy: () => void - notify: () => void + notify: (action: SubscriberHistoryAction) => void _ignoreSubscribers?: boolean } @@ -40,25 +55,46 @@ export interface HistoryState { type ShouldAllowNavigation = any -export type BlockerFn = () => - | Promise - | ShouldAllowNavigation +export type HistoryAction = + | 'PUSH' + | 'POP' + | 'REPLACE' + | 'FORWARD' + | 'BACK' + | 'GO' + +export type BlockerFnArgs = { + currentLocation: HistoryLocation + nextLocation: HistoryLocation + action: HistoryAction +} -const pushStateEvent = 'pushstate' -const popStateEvent = 'popstate' -const beforeUnloadEvent = 'beforeunload' +export type BlockerFn = ( + args: BlockerFnArgs, +) => Promise | ShouldAllowNavigation -const beforeUnloadListener = (event: Event) => { - event.preventDefault() - // @ts-expect-error - return (event.returnValue = '') +export type NavigationBlocker = { + blockerFn: BlockerFn + enableBeforeUnload?: (() => boolean) | boolean } -const stopBlocking = () => { - removeEventListener(beforeUnloadEvent, beforeUnloadListener, { - capture: true, - }) -} +type TryNavigateArgs = { + task: () => void + type: 'PUSH' | 'REPLACE' | 'BACK' | 'FORWARD' | 'GO' + navigateOpts?: NavigateOptions +} & ( + | { + type: 'PUSH' | 'REPLACE' + path: string + state: any + } + | { + type: 'BACK' | 'FORWARD' | 'GO' + } +) + +const popStateEvent = 'popstate' +const beforeUnloadEvent = 'beforeunload' export function createHistory(opts: { getLocation: () => HistoryLocation @@ -66,32 +102,54 @@ export function createHistory(opts: { pushState: (path: string, state: any) => void replaceState: (path: string, state: any) => void go: (n: number) => void - back: () => void - forward: () => void + back: (ignoreBlocker: boolean) => void + forward: (ignoreBlocker: boolean) => void createHref: (path: string) => string flush?: () => void destroy?: () => void onBlocked?: (onUpdate: () => void) => void + getBlockers?: () => Array + setBlockers?: (blockers: Array) => void }): RouterHistory { let location = opts.getLocation() - const subscribers = new Set<(opts: { location: HistoryLocation }) => void>() - let blockers: Array = [] + const subscribers = new Set<(opts: SubscriberArgs) => void>() - const notify = () => { + const notify = (action: SubscriberHistoryAction) => { location = opts.getLocation() - subscribers.forEach((subscriber) => subscriber({ location })) + subscribers.forEach((subscriber) => subscriber({ location, action })) } - const tryNavigation = async ( - task: () => void, - navigateOpts?: NavigateOptions, - ) => { + const _notifyRollback = () => { + location = opts.getLocation() + subscribers.forEach((subscriber) => + subscriber({ location, action: { type: 'ROLLBACK' } }), + ) + } + + const tryNavigation = async ({ + task, + navigateOpts, + ...actionInfo + }: TryNavigateArgs) => { const ignoreBlocker = navigateOpts?.ignoreBlocker ?? false - if (!ignoreBlocker && typeof document !== 'undefined' && blockers.length) { + if (ignoreBlocker) { + task() + return + } + + const blockers = opts.getBlockers?.() ?? [] + const isPushOrReplace = + actionInfo.type === 'PUSH' || actionInfo.type === 'REPLACE' + if (typeof document !== 'undefined' && blockers.length && isPushOrReplace) { for (const blocker of blockers) { - const allowed = await blocker() - if (!allowed) { - opts.onBlocked?.(notify) + const nextLocation = parseHref(actionInfo.path, actionInfo.state) + const isBlocked = await blocker.blockerFn({ + currentLocation: location, + nextLocation, + action: actionInfo.type, + }) + if (isBlocked) { + opts.onBlocked?.(_notifyRollback) return } } @@ -108,7 +166,7 @@ export function createHistory(opts: { return opts.getLength() }, subscribers, - subscribe: (cb: (opts: { location: HistoryLocation }) => void) => { + subscribe: (cb: (opts: SubscriberArgs) => void) => { subscribers.add(cb) return () => { @@ -117,52 +175,69 @@ export function createHistory(opts: { }, push: (path, state, navigateOpts) => { state = assignKey(state) - tryNavigation(() => { - opts.pushState(path, state) - notify() - }, navigateOpts) + tryNavigation({ + task: () => { + opts.pushState(path, state) + notify({ type: 'PUSH' }) + }, + navigateOpts, + type: 'PUSH', + path, + state, + }) }, replace: (path, state, navigateOpts) => { state = assignKey(state) - tryNavigation(() => { - opts.replaceState(path, state) - notify() - }, navigateOpts) + tryNavigation({ + task: () => { + opts.replaceState(path, state) + notify({ type: 'REPLACE' }) + }, + navigateOpts, + type: 'REPLACE', + path, + state, + }) }, go: (index, navigateOpts) => { - tryNavigation(() => { - opts.go(index) - notify() - }, navigateOpts) + tryNavigation({ + task: () => { + opts.go(index) + notify({ type: 'GO', index }) + }, + navigateOpts, + type: 'GO', + }) }, back: (navigateOpts) => { - tryNavigation(() => { - opts.back() - notify() - }, navigateOpts) + tryNavigation({ + task: () => { + opts.back(navigateOpts?.ignoreBlocker ?? false) + notify({ type: 'BACK' }) + }, + navigateOpts, + type: 'BACK', + }) }, forward: (navigateOpts) => { - tryNavigation(() => { - opts.forward() - notify() - }, navigateOpts) + tryNavigation({ + task: () => { + opts.forward(navigateOpts?.ignoreBlocker ?? false) + notify({ type: 'FORWARD' }) + }, + navigateOpts, + type: 'FORWARD', + }) }, createHref: (str) => opts.createHref(str), block: (blocker) => { - blockers.push(blocker) - - if (blockers.length === 1) { - addEventListener(beforeUnloadEvent, beforeUnloadListener, { - capture: true, - }) - } + if (!opts.setBlockers) return () => {} + const blockers = opts.getBlockers?.() ?? [] + opts.setBlockers([...blockers, blocker]) return () => { - blockers = blockers.filter((b) => b !== blocker) - - if (!blockers.length) { - stopBlocking() - } + const blockers = opts.getBlockers?.() ?? [] + opts.setBlockers?.(blockers.filter((b) => b !== blocker)) } }, flush: () => opts.flush?.(), @@ -209,6 +284,11 @@ export function createBrowserHistory(opts?: { const originalPushState = win.history.pushState const originalReplaceState = win.history.replaceState + let blockers: Array = [] + const _getBlockers = () => blockers + const _setBlockers = (newBlockers: Array) => + (blockers = newBlockers) + const createHref = opts?.createHref ?? ((path) => path) const parseLocation = opts?.parseLocation ?? @@ -221,6 +301,10 @@ export function createBrowserHistory(opts?: { let currentLocation = parseLocation() let rollbackLocation: HistoryLocation | undefined + let ignoreNextPop = false + let skipBlockerNextPop = false + let ignoreNextBeforeUnload = false + const getLocation = () => currentLocation let next: @@ -293,7 +377,74 @@ export function createBrowserHistory(opts?: { const onPushPop = () => { currentLocation = parseLocation() - history.notify() + history.notify({ type: 'POP' }) + } + + const onPushPopEvent = async () => { + if (ignoreNextPop) { + ignoreNextPop = false + return + } + + if (skipBlockerNextPop) { + skipBlockerNextPop = false + } else { + const blockers = _getBlockers() + if (typeof document !== 'undefined' && blockers.length) { + for (const blocker of blockers) { + const nextLocation = parseLocation() + const isBlocked = await blocker.blockerFn({ + currentLocation, + nextLocation, + action: 'POP', + }) + if (isBlocked) { + ignoreNextPop = true + win.history.go(1) + history.notify({ type: 'POP' }) + return + } + } + } + } + + currentLocation = parseLocation() + history.notify({ type: 'POP' }) + } + + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (ignoreNextBeforeUnload) { + ignoreNextBeforeUnload = false + return + } + + let shouldBlock = false + + // If one blocker has a non-disabled beforeUnload, we should block + const blockers = _getBlockers() + if (typeof document !== 'undefined' && blockers.length) { + for (const blocker of blockers) { + const shouldHaveBeforeUnload = blocker.enableBeforeUnload ?? true + if (shouldHaveBeforeUnload === true) { + shouldBlock = true + break + } + + if ( + typeof shouldHaveBeforeUnload === 'function' && + shouldHaveBeforeUnload() === true + ) { + shouldBlock = true + break + } + } + } + + if (shouldBlock) { + e.preventDefault() + return (e.returnValue = '') + } + return } const history = createHistory({ @@ -301,16 +452,26 @@ export function createBrowserHistory(opts?: { getLength: () => win.history.length, pushState: (href, state) => queueHistoryAction('push', href, state), replaceState: (href, state) => queueHistoryAction('replace', href, state), - back: () => win.history.back(), - forward: () => win.history.forward(), + back: (ignoreBlocker) => { + if (ignoreBlocker) skipBlockerNextPop = true + ignoreNextBeforeUnload = true + return win.history.back() + }, + forward: (ignoreBlocker) => { + if (ignoreBlocker) skipBlockerNextPop = true + ignoreNextBeforeUnload = true + win.history.forward() + }, go: (n) => win.history.go(n), createHref: (href) => createHref(href), flush, destroy: () => { win.history.pushState = originalPushState win.history.replaceState = originalReplaceState - win.removeEventListener(pushStateEvent, onPushPop) - win.removeEventListener(popStateEvent, onPushPop) + win.removeEventListener(beforeUnloadEvent, onBeforeUnload, { + capture: true, + }) + win.removeEventListener(popStateEvent, onPushPopEvent) }, onBlocked: (onUpdate) => { // If a navigation is blocked, we need to rollback the location @@ -321,19 +482,21 @@ export function createBrowserHistory(opts?: { onUpdate() } }, + getBlockers: _getBlockers, + setBlockers: _setBlockers, }) - win.addEventListener(pushStateEvent, onPushPop) - win.addEventListener(popStateEvent, onPushPop) + win.addEventListener(beforeUnloadEvent, onBeforeUnload, { capture: true }) + win.addEventListener(popStateEvent, onPushPopEvent) win.history.pushState = function (...args: Array) { - const res = originalPushState.apply(win.history, args) + const res = originalPushState.apply(win.history, args as any) if (!history._ignoreSubscribers) onPushPop() return res } win.history.replaceState = function (...args: Array) { - const res = originalReplaceState.apply(win.history, args) + const res = originalReplaceState.apply(win.history, args as any) if (!history._ignoreSubscribers) onPushPop() return res } diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index ac2e0d268b..49d93bc593 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -301,6 +301,7 @@ export type { DefaultTransformerStringify, } from './transformer' +export type { UseBlockerOpts, ShouldBlockFn } from './useBlocker' export { useBlocker, Block } from './useBlocker' export { useNavigate, Navigate } from './useNavigate' diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index b22f61c355..43a6971e9a 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -1058,6 +1058,7 @@ export class Router< parseLocation = ( previousLocation?: ParsedLocation>, + locationToParse?: HistoryLocation, ): ParsedLocation> => { const parse = ({ pathname, @@ -1078,7 +1079,7 @@ export class Router< } } - const location = parse(this.history.location) + const location = parse(locationToParse ?? this.history.location) const { __tempLocation, __tempKey } = location.state diff --git a/packages/react-router/src/useBlocker.tsx b/packages/react-router/src/useBlocker.tsx index dec62d37a3..26e60040b0 100644 --- a/packages/react-router/src/useBlocker.tsx +++ b/packages/react-router/src/useBlocker.tsx @@ -1,92 +1,296 @@ import * as React from 'react' import { useRouter } from './useRouter' -import type { BlockerFn } from '@tanstack/history' -import type { ReactNode } from './route' +import type { + BlockerFnArgs, + HistoryAction, + HistoryLocation, +} from '@tanstack/history' +import type { AnyRoute } from './route' +import type { ParseRoute } from './routeInfo' +import type { AnyRouter, RegisteredRouter } from './router' -type BlockerResolver = { - status: 'idle' | 'blocked' - proceed: () => void - reset: () => void +interface ShouldBlockFnLocation< + out TRouteId, + out TFullPath, + out TAllParams, + out TFullSearchSchema, +> { + routeId: TRouteId + fullPath: TFullPath + pathname: string + params: TAllParams + search: TFullSearchSchema } -type BlockerOpts = { - blockerFn?: BlockerFn +type AnyShouldBlockFnLocation = ShouldBlockFnLocation +type MakeShouldBlockFnLocationUnion< + TRouter extends AnyRouter = RegisteredRouter, + TRoute extends AnyRoute = ParseRoute, +> = TRoute extends any + ? ShouldBlockFnLocation< + TRoute['id'], + TRoute['fullPath'], + TRoute['types']['allParams'], + TRoute['types']['fullSearchSchema'] + > + : never + +type BlockerResolver = + | { + status: 'blocked' + current: MakeShouldBlockFnLocationUnion + next: MakeShouldBlockFnLocationUnion + action: HistoryAction + proceed: () => void + reset: () => void + } + | { + status: 'idle' + current: undefined + next: undefined + action: undefined + proceed: undefined + reset: undefined + } + +type ShouldBlockFnArgs = { + current: MakeShouldBlockFnLocationUnion + next: MakeShouldBlockFnLocationUnion + action: HistoryAction +} + +export type ShouldBlockFn = ( + args: ShouldBlockFnArgs, +) => boolean | Promise +export type UseBlockerOpts< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = boolean, +> = { + shouldBlockFn: ShouldBlockFn + enableBeforeUnload?: boolean | (() => boolean) + disabled?: boolean + withResolver?: TWithResolver +} + +type LegacyBlockerFn = () => Promise | any +type LegacyBlockerOpts = { + blockerFn?: LegacyBlockerFn condition?: boolean | any } -export function useBlocker(blockerFnOrOpts?: BlockerOpts): BlockerResolver +function _resolveBlockerOpts( + opts?: UseBlockerOpts | LegacyBlockerOpts | LegacyBlockerFn, + condition?: boolean | any, +): UseBlockerOpts { + if (opts === undefined) { + return { + shouldBlockFn: () => true, + withResolver: false, + } + } + + if ('shouldBlockFn' in opts) { + return opts + } + + if (typeof opts === 'function') { + const shouldBlock = Boolean(condition ?? true) + + const _customBlockerFn = async () => { + if (shouldBlock) return await opts() + return false + } + + return { + shouldBlockFn: _customBlockerFn, + enableBeforeUnload: shouldBlock, + withResolver: false, + } + } + + const shouldBlock = Boolean(opts.condition ?? true) + const fn = opts.blockerFn + + const _customBlockerFn = async () => { + if (shouldBlock && fn !== undefined) { + return await fn() + } + return shouldBlock + } + + return { + shouldBlockFn: _customBlockerFn, + enableBeforeUnload: shouldBlock, + withResolver: fn === undefined, + } +} + +export function useBlocker< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = false, +>( + opts: UseBlockerOpts, +): TWithResolver extends true ? BlockerResolver : void + +/** + * @deprecated Use the shouldBlockFn property instead + */ +export function useBlocker(blockerFnOrOpts?: LegacyBlockerOpts): BlockerResolver /** - * @deprecated Use the BlockerOpts object syntax instead + * @deprecated Use the UseBlockerOpts object syntax instead */ export function useBlocker( - blockerFn?: BlockerFn, + blockerFn?: LegacyBlockerFn, condition?: boolean | any, ): BlockerResolver export function useBlocker( - blockerFnOrOpts?: BlockerFn | BlockerOpts, + opts?: UseBlockerOpts | LegacyBlockerOpts | LegacyBlockerFn, condition?: boolean | any, -): BlockerResolver { - const { blockerFn, blockerCondition } = blockerFnOrOpts - ? typeof blockerFnOrOpts === 'function' - ? { blockerFn: blockerFnOrOpts, blockerCondition: condition ?? true } - : { - blockerFn: blockerFnOrOpts.blockerFn, - blockerCondition: blockerFnOrOpts.condition ?? true, - } - : { blockerFn: undefined, blockerCondition: condition ?? true } - const { history } = useRouter() +): BlockerResolver | void { + const { + shouldBlockFn, + enableBeforeUnload = true, + disabled = false, + withResolver = false, + } = _resolveBlockerOpts(opts, condition) + + const router = useRouter() + const { history } = router const [resolver, setResolver] = React.useState({ status: 'idle', - proceed: () => {}, - reset: () => {}, + current: undefined, + next: undefined, + action: undefined, + proceed: undefined, + reset: undefined, }) React.useEffect(() => { - const blockerFnComposed = async () => { - // If a function is provided, it takes precedence over the promise blocker - if (blockerFn) { - return await blockerFn() + const blockerFnComposed = async (blockerFnArgs: BlockerFnArgs) => { + function getLocation( + location: HistoryLocation, + ): AnyShouldBlockFnLocation { + const parsedLocation = router.parseLocation(undefined, location) + const matchedRoutes = router.getMatchedRoutes(parsedLocation) + if (matchedRoutes.foundRoute === undefined) { + throw new Error(`No route found for location ${location.href}`) + } + return { + routeId: matchedRoutes.foundRoute.id, + fullPath: matchedRoutes.foundRoute.fullPath, + pathname: parsedLocation.pathname, + params: matchedRoutes.routeParams, + search: parsedLocation.search, + } + } + + const current = getLocation(blockerFnArgs.currentLocation) + const next = getLocation(blockerFnArgs.nextLocation) + + const shouldBlock = await shouldBlockFn({ + action: blockerFnArgs.action, + current, + next, + }) + if (!withResolver) { + return shouldBlock + } + + if (!shouldBlock) { + return false } const promise = new Promise((resolve) => { setResolver({ status: 'blocked', - proceed: () => resolve(true), - reset: () => resolve(false), + current, + next, + action: blockerFnArgs.action, + proceed: () => resolve(false), + reset: () => resolve(true), }) }) const canNavigateAsync = await promise - setResolver({ status: 'idle', - proceed: () => {}, - reset: () => {}, + current: undefined, + next: undefined, + action: undefined, + proceed: undefined, + reset: undefined, }) return canNavigateAsync } - return !blockerCondition ? undefined : history.block(blockerFnComposed) - }, [blockerFn, blockerCondition, history]) + return disabled + ? undefined + : history.block({ blockerFn: blockerFnComposed, enableBeforeUnload }) + }, [shouldBlockFn, enableBeforeUnload, disabled, withResolver, history]) return resolver } -export function Block({ blockerFn, condition, children }: PromptProps) { - const resolver = useBlocker({ blockerFn, condition }) +const _resolvePromptBlockerArgs = ( + props: PromptProps | LegacyPromptProps, +): UseBlockerOpts => { + if ('shouldBlockFn' in props) { + return { ...props } + } + + const shouldBlock = Boolean(props.condition ?? true) + const fn = props.blockerFn + + const _customBlockerFn = async () => { + if (shouldBlock && fn !== undefined) { + return await fn() + } + return shouldBlock + } + + return { + shouldBlockFn: _customBlockerFn, + enableBeforeUnload: shouldBlock, + withResolver: fn === undefined, + } +} + +export function Block< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = boolean, +>(opts: PromptProps): React.ReactNode + +/** + * @deprecated Use the UseBlockerOpts property instead + */ +export function Block(opts: LegacyPromptProps): React.ReactNode + +export function Block(opts: PromptProps | LegacyPromptProps): React.ReactNode { + const { children, ...rest } = opts + const args = _resolvePromptBlockerArgs(rest) + + const resolver = useBlocker(args) return children ? typeof children === 'function' - ? children(resolver) + ? children(resolver as any) : children : null } -export type PromptProps = { - blockerFn?: BlockerFn +type LegacyPromptProps = { + blockerFn?: LegacyBlockerFn condition?: boolean | any - children?: ReactNode | (({ proceed, reset }: BlockerResolver) => ReactNode) + children?: React.ReactNode | ((params: BlockerResolver) => React.ReactNode) +} + +type PromptProps< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = boolean, + TParams = TWithResolver extends true ? BlockerResolver : void, +> = UseBlockerOpts & { + children?: React.ReactNode | ((params: TParams) => React.ReactNode) } diff --git a/packages/react-router/tests/blocker.test.tsx b/packages/react-router/tests/blocker.test.tsx index c598e49402..d88c542b17 100644 --- a/packages/react-router/tests/blocker.test.tsx +++ b/packages/react-router/tests/blocker.test.tsx @@ -3,7 +3,6 @@ import '@testing-library/jest-dom/vitest' import { afterEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import combinate from 'combinate' - import { Link, RouterProvider, @@ -14,6 +13,7 @@ import { useBlocker, useNavigate, } from '../src' +import type { ShouldBlockFn } from '../src' afterEach(() => { window.history.replaceState(null, 'root', '/') @@ -22,18 +22,20 @@ afterEach(() => { }) interface BlockerTestOpts { - condition: boolean + blockerFn: ShouldBlockFn + disabled?: boolean ignoreBlocker?: boolean } -async function setup({ condition, ignoreBlocker }: BlockerTestOpts) { - const blockerFn = vi.fn() + +async function setup({ blockerFn, disabled, ignoreBlocker }: BlockerTestOpts) { + const _mockBlockerFn = vi.fn(blockerFn) const rootRoute = createRootRoute() const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: function Setup() { const navigate = useNavigate() - useBlocker({ condition, blockerFn }) + useBlocker({ disabled, shouldBlockFn: _mockBlockerFn }) return (

Index

@@ -98,7 +100,11 @@ async function setup({ condition, ignoreBlocker }: BlockerTestOpts) { const fooLink = await screen.findByRole('link', { name: 'link to foo' }) const button = await screen.findByRole('button', { name: 'button' }) - return { router, clickable: { postsLink, fooLink, button }, blockerFn } + return { + router, + clickable: { postsLink, fooLink, button }, + blockerFn: _mockBlockerFn, + } } const clickTarget = ['postsLink' as const, 'button' as const] @@ -106,15 +112,32 @@ const clickTarget = ['postsLink' as const, 'button' as const] describe('Blocker', () => { const doesNotBlockTextMatrix = combinate({ opts: [ - { condition: false, ignoreBlocker: undefined }, - { condition: false, ignoreBlocker: false }, - { condition: false, ignoreBlocker: true }, - { condition: true, ignoreBlocker: true }, + { + blockerFn: () => false, + disabled: false, + ignoreBlocker: undefined, + }, + { + blockerFn: async () => + await new Promise((resolve) => resolve(false)), + disabled: false, + ignoreBlocker: false, + }, + { + blockerFn: () => true, + disabled: true, + ignoreBlocker: false, + }, + { + blockerFn: () => true, + disabled: false, + ignoreBlocker: true, + }, ], clickTarget, }) test.each(doesNotBlockTextMatrix)( - 'does not block navigation with condition = $flags.condition, ignoreBlocker = $flags.ignoreBlocker, clickTarget = $clickTarget', + 'does not block navigation with blockerFn = $flags.blockerFn, ignoreBlocker = $flags.ignoreBlocker, clickTarget = $clickTarget', async ({ opts, clickTarget }) => { const { clickable, blockerFn } = await setup(opts) @@ -123,41 +146,50 @@ describe('Blocker', () => { await screen.findByRole('heading', { name: 'Posts' }), ).toBeInTheDocument() expect(window.location.pathname).toBe('/posts') - expect(blockerFn).not.toHaveBeenCalled() + if (opts.ignoreBlocker || opts.disabled) + expect(blockerFn).not.toHaveBeenCalled() }, ) const blocksTextMatrix = combinate({ opts: [ - { condition: true, ignoreBlocker: undefined }, - { condition: true, ignoreBlocker: false }, + { + blockerFn: () => true, + disabled: false, + ignoreBlocker: undefined, + }, + { + blockerFn: async () => + await new Promise((resolve) => resolve(true)), + disabled: false, + ignoreBlocker: false, + }, ], clickTarget, }) test.each(blocksTextMatrix)( - 'blocks navigation with condition = $flags.condition, ignoreBlocker = $flags.ignoreBlocker, clickTarget = $clickTarget', + 'blocks navigation with condition = $flags.blockerFn, ignoreBlocker = $flags.ignoreBlocker, clickTarget = $clickTarget', async ({ opts, clickTarget }) => { - const { clickable, blockerFn } = await setup(opts) + const { clickable } = await setup(opts) fireEvent.click(clickable[clickTarget]) await expect( screen.findByRole('header', { name: 'Posts' }), ).rejects.toThrow() expect(window.location.pathname).toBe('/') - expect(blockerFn).toHaveBeenCalledOnce() }, ) test('blocker function is only called once when navigating to a route that redirects', async () => { const { clickable, blockerFn } = await setup({ - condition: true, + blockerFn: () => false, ignoreBlocker: false, }) - blockerFn.mockImplementationOnce(() => true).mockImplementation(() => false) fireEvent.click(clickable.fooLink) expect( await screen.findByRole('heading', { name: 'Bar' }), ).toBeInTheDocument() expect(window.location.pathname).toBe('/bar') + expect(blockerFn).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/react-router/tests/useBlocker.test-d.tsx b/packages/react-router/tests/useBlocker.test-d.tsx new file mode 100644 index 0000000000..ed3149fe99 --- /dev/null +++ b/packages/react-router/tests/useBlocker.test-d.tsx @@ -0,0 +1,122 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useBlocker } from '../src' + +test('blocker without resolver', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useBlocker).returns.toBeVoid() +}) + +test('blocker with resolver', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useBlocker).returns.toBeObject() +}) + +test('shouldBlockFn has corrent action', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useBlocker) + .parameter(0) + .toHaveProperty('shouldBlockFn') + .parameter(0) + .toHaveProperty('action') + .toEqualTypeOf<'PUSH' | 'POP' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'>() + + expectTypeOf(useBlocker) + .parameter(0) + .toHaveProperty('shouldBlockFn') + .parameter(0) + .toHaveProperty('current') + .toHaveProperty('routeId') + .toEqualTypeOf<'__root__' | '/' | '/invoices' | '/invoices/'>() + + expectTypeOf(useBlocker) + .parameter(0) + .toHaveProperty('shouldBlockFn') + .parameter(0) + .toHaveProperty('next') + .toHaveProperty('routeId') + .toEqualTypeOf<'__root__' | '/' | '/invoices' | '/invoices/'>() +}) diff --git a/packages/react-router/tests/useBlocker.test.tsx b/packages/react-router/tests/useBlocker.test.tsx index fd3faf8477..fcd17a540d 100644 --- a/packages/react-router/tests/useBlocker.test.tsx +++ b/packages/react-router/tests/useBlocker.test.tsx @@ -1,42 +1,425 @@ +import React from 'react' import '@testing-library/jest-dom/vitest' -import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, test, vi } from 'vitest' -import { useBlocker } from '../src' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' -const block = vi.fn() -vi.mock('../src/useRouter', () => ({ - useRouter: () => ({ history: { block } }), -})) +import { z } from 'zod' +import { + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useBlocker, + useNavigate, +} from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) describe('useBlocker', () => { - beforeEach(() => { - block.mockClear() + test('does not block navigation when not enabled', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => false }) + + return ( + +

Index

+ + +
+ ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + +

Posts

+
+ ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Posts' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') + }) + + test('does not block navigation when disabled', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => true, disabled: true }) + + return ( + +

Index

+ + +
+ ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + +

Posts

+
+ ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Posts' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') }) - describe('condition', () => { - test('should not add the blocker if condition is false', () => { - renderHook(() => useBlocker({ condition: false })) - expect(block).not.toHaveBeenCalled() + test('blocks navigation when enabled', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => true }) + + return ( + +

Index

+ + +
+ ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, }) - test('should not add the blocker if condition is false (deprecated API)', () => { - renderHook(() => useBlocker(undefined, false)) - expect(block).not.toHaveBeenCalled() + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + +

Posts

+
+ ) + }, }) - test('should add the blocker if condition is true', () => { - renderHook(() => useBlocker({ condition: true })) - expect(block).toHaveBeenCalledOnce() + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), }) - test('should add the blocker if condition is true (deprecated API)', () => { - renderHook(() => useBlocker(undefined, true)) - expect(block).toHaveBeenCalledOnce() + render() + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + }) + + test('gives correct arguments to shouldBlockFn', async () => { + const rootRoute = createRootRoute() + + const shouldBlockFn = vi.fn().mockReturnValue(true) + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn }) + + return ( + +

Index

+ + +
+ ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, }) - test('should add the blocker if condition is not provided', () => { - renderHook(() => useBlocker()) - expect(block).toHaveBeenCalledOnce() + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + +

Posts

+
+ ) + }, }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + + expect(shouldBlockFn).toHaveBeenCalledWith({ + action: 'REPLACE', + current: { + routeId: indexRoute.id, + fullPath: indexRoute.fullPath, + pathname: '/', + params: {}, + search: {}, + }, + next: { + routeId: postsRoute.id, + fullPath: postsRoute.fullPath, + pathname: '/posts', + params: {}, + search: {}, + }, + }) + }) + + test('gives correct arguments to shouldBlockFn with path and search params', async () => { + const rootRoute = createRootRoute() + + const shouldBlockFn = vi.fn().mockReturnValue(true) + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn }) + + return ( + +

Index

+ + +
+ ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + validateSearch: z.object({ + param1: z.string().default('param1-default'), + param2: z.string().default('param2-default'), + }), + path: '/posts/$postId', + component: () => { + return ( + +

Posts

+
+ ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + + expect(shouldBlockFn).toHaveBeenCalledWith({ + action: 'PUSH', + current: { + routeId: indexRoute.id, + fullPath: indexRoute.fullPath, + pathname: '/', + params: {}, + search: {}, + }, + next: { + routeId: postsRoute.id, + fullPath: postsRoute.fullPath, + pathname: '/posts/10', + params: { postId: '10' }, + search: { param1: 'foo', param2: 'bar' }, + }, + }) + }) + + test('conditionally blocking navigation works', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ + shouldBlockFn: ({ next }) => { + if (next.fullPath === '/posts') { + return true + } + return false + }, + }) + + return ( + +

Index

+ + + +
+ ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + +

Posts

+
+ ) + }, + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices', + component: () => { + return ( + +

Invoices

+
+ ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute, invoicesRoute]), + }) + + type Router = typeof router + + render() + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + + const invoicesButton = await screen.findByRole('button', { + name: 'Invoices', + }) + + fireEvent.click(invoicesButton) + + expect( + await screen.findByRole('heading', { name: 'Invoices' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/invoices') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3b04499dc..9ee6cbf3af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2013,10 +2013,10 @@ importers: version: 18.3.1 html-webpack-plugin: specifier: ^5.6.3 - version: 5.6.3(@rspack/core@1.1.5(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) + version: 5.6.3(@rspack/core@1.1.5(@swc/helpers@0.5.15))(webpack@5.97.1) swc-loader: specifier: ^0.2.6 - version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) + version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1) typescript: specifier: ^5.7.2 version: 5.7.2 @@ -3243,7 +3243,7 @@ importers: version: 4.3.4(vite@6.0.3(@types/node@22.10.1)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)) html-webpack-plugin: specifier: ^5.6.0 - version: 5.6.3(@rspack/core@1.1.5(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) + version: 5.6.3(@rspack/core@1.1.5(@swc/helpers@0.5.15))(webpack@5.97.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -3252,7 +3252,7 @@ importers: version: 18.3.1(react@18.3.1) swc-loader: specifier: ^0.2.6 - version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) + version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1) typescript: specifier: ^5.7.2 version: 5.7.2 @@ -13601,17 +13601,17 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4))': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4))': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack-dev-server@5.1.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4))': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.1.0)(webpack@5.97.1)': dependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) @@ -15521,7 +15521,7 @@ snapshots: relateurl: 0.2.7 terser: 5.36.0 - html-webpack-plugin@5.6.3(@rspack/core@1.1.5(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)): + html-webpack-plugin@5.6.3(@rspack/core@1.1.5(@swc/helpers@0.5.15))(webpack@5.97.1): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -17607,7 +17607,7 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 - swc-loader@0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)): + swc-loader@0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1): dependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.15) '@swc/counter': 0.1.3 @@ -17677,26 +17677,26 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - terser-webpack-plugin@5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)): + terser-webpack-plugin@5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4) + webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0) optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.15) esbuild: 0.24.0 - terser-webpack-plugin@5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)): + terser-webpack-plugin@5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack@5.97.1): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0) + webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.15) esbuild: 0.24.0 @@ -18318,9 +18318,9 @@ snapshots: webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack-dev-server@5.1.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.97.1) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.97.1) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.1.0)(webpack@5.97.1) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.6 @@ -18334,7 +18334,7 @@ snapshots: optionalDependencies: webpack-dev-server: 5.1.0(webpack-cli@5.1.4)(webpack@5.97.1) - webpack-dev-middleware@7.4.2(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)): + webpack-dev-middleware@7.4.2(webpack@5.97.1): dependencies: colorette: 2.0.20 memfs: 4.14.1 @@ -18373,7 +18373,7 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) + webpack-dev-middleware: 7.4.2(webpack@5.97.1) ws: 8.18.0 optionalDependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4) @@ -18446,7 +18446,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack-cli@5.1.4)) + terser-webpack-plugin: 5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)(webpack@5.97.1) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: