From 39a879955896504e4f0a4e517981d38bdb2d8ff3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 3 Nov 2023 10:39:50 -0400 Subject: [PATCH] Stabilize and document useBlocker (#10991) --- .changeset/stabilize-use-blocker.md | 5 + .changeset/update-useprompt-args.md | 5 + docs/hooks/use-blocker.md | 136 ++++++++++++++++++ docs/hooks/use-prompt.md | 87 +++++++++++ examples/navigation-blocking/README.md | 2 +- examples/navigation-blocking/src/app.tsx | 2 +- packages/react-router-dom-v5-compat/index.ts | 2 +- .../__tests__/use-blocker-test.tsx | 2 +- packages/react-router-dom/index.tsx | 13 +- packages/react-router-native/index.tsx | 2 +- packages/react-router/index.ts | 2 +- 11 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 .changeset/stabilize-use-blocker.md create mode 100644 .changeset/update-useprompt-args.md create mode 100644 docs/hooks/use-blocker.md create mode 100644 docs/hooks/use-prompt.md diff --git a/.changeset/stabilize-use-blocker.md b/.changeset/stabilize-use-blocker.md new file mode 100644 index 0000000000..52189825bf --- /dev/null +++ b/.changeset/stabilize-use-blocker.md @@ -0,0 +1,5 @@ +--- +"react-router": minor +--- + +Remove the `unstable_` prefix from the [`useBlocker`](https://reactrouter.com/en/main/hooks/use-blocker) hook as it's been in use for enough time that we are confident in the API. We do not plan to remove the prefix from `unstable_usePrompt` due to differences in how browsers handle `window.confirm` that prevent React Router from guaranteeing consistent/correct behavior. diff --git a/.changeset/update-useprompt-args.md b/.changeset/update-useprompt-args.md new file mode 100644 index 0000000000..67c2383bcd --- /dev/null +++ b/.changeset/update-useprompt-args.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": minor +--- + +Allow `unstable_usePrompt` to accept a `BlockerFunction` in addition to a `boolean` diff --git a/docs/hooks/use-blocker.md b/docs/hooks/use-blocker.md new file mode 100644 index 0000000000..e3c7600ba8 --- /dev/null +++ b/docs/hooks/use-blocker.md @@ -0,0 +1,136 @@ +--- +title: useBlocker +--- + +# `useBlocker` + +
+ Type declaration + +```tsx +declare function useBlocker( + shouldBlock: boolean | BlockerFunction +): Blocker; + +type BlockerFunction = (args: { + currentLocation: Location; + nextLocation: Location; + historyAction: HistoryAction; +}) => boolean; + +type Blocker = + | { + state: "unblocked"; + reset: undefined; + proceed: undefined; + location: undefined; + } + | { + state: "blocked"; + reset(): void; + proceed(): void; + location: Location; + } + | { + state: "proceeding"; + reset: undefined; + proceed: undefined; + location: Location; + }; + +interface Location extends Path { + state: State; + key: string; +} + +interface Path { + pathname: string; + search: string; + hash: string; +} + +enum HistoryAction { + Pop = "POP", + Push = "PUSH", + Replace = "REPLACE", +} +``` + +
+ +The `useBlocker` hook allows you to prevent the user from navigating away from the current location, and present them with a custom UI to allow them to confirm the navigation. + + +This only works for client-side navigations within your React Router application and will not block document requests. To prevent document navigations you will need to add your own `beforeunload` event handler. + + + +Blocking a user from navigating is a bit of an anti-pattern, so please carefully consider any usage of this hook and use it sparingly. In the de-facto use case of preventing a user navigating away from a half-filled form, you might consider persisting unsaved state to `sessionStorage` and automatically re-filling it if they return instead of blocking them from navigating away. + + +```tsx +function ImportantForm() { + let [value, setValue] = React.useState(""); + + // Block navigating elsewhere when data has been entered into the input + let blocker = useBlocker( + ({ currentLocation, nextLocation }) => + value !== "" && + currentLocation.pathname !== nextLocation.pathname + ); + + return ( +
+ + + + {blocker.state === "blocked" ? ( +
+

Are you sure you want to leave?

+ + +
+ ) : null} +
+ ); +} +``` + +For a more complete example, please refer to the [example][example] in the repository. + +## Properties + +### `state` + +The current state of the blocker + +- `unblocked` - the blocker is idle and has not prevented any navigation +- `blocked` - the blocker has prevented a navigation +- `proceeding` - the blocker is proceeding through from a blocked navigation + +### `location` + +When in a `blocked` state, this represents the location to which we blocked a navigation. When in a `proceeding` state, this is the location being navigated to after a `blocker.proceed()` call. + +## Methods + +### `proceed()` + +When in a `blocked` state, you may call `blocker.proceed()` to proceed to the blocked location. + +### `reset()` + +When in a `blocked` state, you may call `blocker.reset()` to return the blocker back to an `unblocked` state and leave the user at the current location. + +[example]: https://github.com/remix-run/react-router/tree/main/examples/navigation-blocking diff --git a/docs/hooks/use-prompt.md b/docs/hooks/use-prompt.md new file mode 100644 index 0000000000..4a8972c06b --- /dev/null +++ b/docs/hooks/use-prompt.md @@ -0,0 +1,87 @@ +--- +title: unstable_usePrompt +--- + +# `unstable_usePrompt` + +
+ Type declaration + +```tsx +declare function unstable_usePrompt({ + when, + message, +}: { + when: boolean | BlockerFunction; + message: string; +}) { + +type BlockerFunction = (args: { + currentLocation: Location; + nextLocation: Location; + historyAction: HistoryAction; +}) => boolean; + +interface Location extends Path { + state: State; + key: string; +} + +interface Path { + pathname: string; + search: string; + hash: string; +} + +enum HistoryAction { + Pop = "POP", + Push = "PUSH", + Replace = "REPLACE", +} +``` + +
+ +The `unstable_usePrompt` hook allows you to prompt the user for confirmation via [`window.confirm`][window-confirm] prior to navigating away from the current location. + + +This only works for client-side navigations within your React Router application and will not block document requests. To prevent document navigations you will need to add your own `beforeunload` event handler. + + + +Blocking a user from navigating is a bit of an anti-pattern, so please carefully consider any usage of this hook and use it sparingly. In the de-facto use case of preventing a user navigating away from a half-filled form, you might consider persisting unsaved state to `sessionStorage` and automatically re-filling it if they return instead of blocking them from navigating away. + + + +We do not plan to remove the `unstable_` prefix from this hook because the behavior is non-deterministic across browsers when the prompt is open, so React Router cannot guarantee correct behavior in all scenarios. To avoid this non-determinism, we recommend using `useBlocker` instead which also gives you control over the confirmation UX. + + +```tsx +function ImportantForm() { + let [value, setValue] = React.useState(""); + + // Block navigating elsewhere when data has been entered into the input + unstable_usePrompt({ + message: "Are you sure?", + when: ({ currentLocation, nextLocation }) => + value !== "" && + currentLocation.pathname !== nextLocation.pathname, + }); + + return ( +
+ + +
+ ); +} +``` + +[window-confirm]: https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm diff --git a/examples/navigation-blocking/README.md b/examples/navigation-blocking/README.md index f2875a1c51..d344b4b152 100644 --- a/examples/navigation-blocking/README.md +++ b/examples/navigation-blocking/README.md @@ -6,7 +6,7 @@ order: 1 # Navigation Blocking -This example demonstrates using `unstable_useBlocker` to prevent navigating away from a page where you might lose user-entered form data. A potentially better UX for this is storing user-entered information in `sessionStorage` and pre-populating the form on return. +This example demonstrates using `useBlocker` to prevent navigating away from a page where you might lose user-entered form data. A potentially better UX for this is storing user-entered information in `sessionStorage` and pre-populating the form on return. ## Preview diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index d297391bea..e4ffef5436 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -13,7 +13,7 @@ import { Outlet, Route, RouterProvider, - unstable_useBlocker as useBlocker, + useBlocker, useLocation, } from "react-router-dom"; diff --git a/packages/react-router-dom-v5-compat/index.ts b/packages/react-router-dom-v5-compat/index.ts index c5b26e62f7..adac99a23e 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -151,7 +151,7 @@ export { renderMatches, resolvePath, unstable_HistoryRouter, - unstable_useBlocker, + useBlocker, unstable_usePrompt, useActionData, useAsyncError, diff --git a/packages/react-router-dom/__tests__/use-blocker-test.tsx b/packages/react-router-dom/__tests__/use-blocker-test.tsx index 2e9795040f..b95db73dc7 100644 --- a/packages/react-router-dom/__tests__/use-blocker-test.tsx +++ b/packages/react-router-dom/__tests__/use-blocker-test.tsx @@ -9,7 +9,7 @@ import { NavLink, Outlet, RouterProvider, - unstable_useBlocker as useBlocker, + useBlocker, useNavigate, } from "../index"; diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index d5b3933459..1031c5b65c 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -24,7 +24,7 @@ import { useNavigate, useNavigation, useResolvedPath, - unstable_useBlocker as useBlocker, + useBlocker, UNSAFE_DataRouterContext as DataRouterContext, UNSAFE_DataRouterStateContext as DataRouterStateContext, UNSAFE_NavigationContext as NavigationContext, @@ -48,6 +48,7 @@ import type { V7_FormMethod, RouterState, RouterSubscriber, + BlockerFunction, } from "@remix-run/router"; import { createRouter, @@ -168,7 +169,7 @@ export { useActionData, useAsyncError, useAsyncValue, - unstable_useBlocker, + useBlocker, useHref, useInRouterContext, useLoaderData, @@ -1810,7 +1811,13 @@ function usePageHide( * very incorrectly in some cases) across browsers if user click addition * back/forward navigations while the confirm is open. Use at your own risk. */ -function usePrompt({ when, message }: { when: boolean; message: string }) { +function usePrompt({ + when, + message, +}: { + when: boolean | BlockerFunction; + message: string; +}) { let blocker = useBlocker(when); React.useEffect(() => { diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index ffa14c77ba..bd4c64ced4 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -96,7 +96,7 @@ export { useActionData, useAsyncError, useAsyncValue, - unstable_useBlocker, + useBlocker, useHref, useInRouterContext, useLoaderData, diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 4ac70a3422..87f0836cdb 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -197,7 +197,7 @@ export { redirectDocument, renderMatches, resolvePath, - useBlocker as unstable_useBlocker, + useBlocker, useActionData, useAsyncError, useAsyncValue,