diff --git a/.changeset/stabilize-use-blocker.md b/.changeset/stabilize-use-blocker.md new file mode 100644 index 00000000000..e13e70ba9e4 --- /dev/null +++ b/.changeset/stabilize-use-blocker.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": 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/docs/hooks/use-blocker.md b/docs/hooks/use-blocker.md new file mode 100644 index 00000000000..c1f18bf807a --- /dev/null +++ b/docs/hooks/use-blocker.md @@ -0,0 +1,82 @@ +--- +title: useBlocker +--- + +# `useBlocker` + +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() { + const [value, setValue] = React.useState(""); + + // Block navigating elsewhere when data has been entered into the input + const 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 00000000000..47b1a3a3f29 --- /dev/null +++ b/docs/hooks/use-prompt.md @@ -0,0 +1,49 @@ +--- +title: unstable_usePrompt +--- + +# `unstable_usePrompt` + +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() { + const [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/integration/form-test.ts b/integration/form-test.ts index f2331ad2987..b2c5ead7a2b 100644 --- a/integration/form-test.ts +++ b/integration/form-test.ts @@ -921,7 +921,7 @@ test.describe("Forms", () => { await app.goto("/projects/blarg"); let html = await app.getHtml(); let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); + expect(el.attr("action")).toMatch("/projects/blarg"); }); test("no action resolves to URL including search params", async ({ @@ -931,7 +931,7 @@ test.describe("Forms", () => { await app.goto("/projects/blarg?foo=bar"); let html = await app.getHtml(); let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects?foo=bar"); + expect(el.attr("action")).toMatch("/projects/blarg?foo=bar"); }); test("absolute action resolves relative to the root route", async ({ diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index b0e13f3e1b0..340ef14cf7b 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -29,7 +29,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "2.2.0", - "@remix-run/router": "1.11.0", + "@remix-run/router": "1.12.0-pre.0", "@remix-run/server-runtime": "2.2.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/index.tsx b/packages/remix-react/index.tsx index a5ac3ef8cae..10531970fe9 100644 --- a/packages/remix-react/index.tsx +++ b/packages/remix-react/index.tsx @@ -48,7 +48,7 @@ export { useRouteError, useSearchParams, useSubmit, - unstable_useBlocker, + useBlocker, unstable_usePrompt, unstable_useViewTransitionState, } from "react-router-dom"; diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index cdaa1d6be7d..e74f9014341 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,9 +16,9 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "1.11.0", + "@remix-run/router": "1.12.0-pre.0", "@remix-run/server-runtime": "2.2.0", - "react-router-dom": "6.18.0" + "react-router-dom": "6.19.0-pre.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.17.0", diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 99709a09431..981100608cf 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "1.11.0", + "@remix-run/router": "1.12.0-pre.0", "@types/cookie": "^0.5.3", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.5.0", diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index 0cb369be94a..7dbec765e78 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "2.2.0", "@remix-run/react": "2.2.0", - "@remix-run/router": "1.11.0", - "react-router-dom": "6.18.0" + "@remix-run/router": "1.12.0-pre.0", + "react-router-dom": "6.19.0-pre.0" }, "devDependencies": { "@types/node": "^18.17.1", diff --git a/yarn.lock b/yarn.lock index 54ce28dcc5c..79e0d85149b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2319,10 +2319,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@1.11.0": - version "1.11.0" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.11.0.tgz#e0e45ac3fff9d8a126916f166809825537e9f955" - integrity sha512-BHdhcWgeiudl91HvVa2wxqZjSHbheSgIiDvxrF1VjFzBzpTtuDPkOdOi3Iqvc08kXtFkLjhbS+ML9aM8mJS+wQ== +"@remix-run/router@1.12.0-pre.0": + version "1.12.0-pre.0" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.12.0-pre.0.tgz#a7a3ed1941016f574147a97d64d3c687bf343113" + integrity sha512-+bBn9KqD2AC0pttSGydVFOZSsT0NqQ1+rGFwMTx9dRANk6oGxrPbKTDxLLikocscGzSL5przvcK4Uxfq8yU7BQ== "@remix-run/web-blob@^3.1.0": version "3.1.0" @@ -3417,9 +3417,9 @@ acorn-jsx@^5.0.0, acorn-jsx@^5.3.2: integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== acorn@^8.0.0, acorn@^8.8.0, acorn@^8.8.1: - version "8.11.0" - resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.0.tgz#04306e13732231c995ac4363f331ee09db278d82" - integrity sha512-hNiSyky+cuYVALBrsjB7f9gMN9P4u09JyAiMNMLaVfsmkDJuH84M1T/0pfDX/OJfGWcobd2A7ecXYzygn8wibA== + version "8.11.2" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz#ca0d78b51895be5390a5903c5b3bdcdaf78ae40b" + integrity sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w== agent-base@6: version "6.0.2" @@ -10918,9 +10918,9 @@ pumpify@^1.3.3: pump "^2.0.0" punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" - integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== + version "2.3.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== pure-rand@^6.0.0: version "6.0.2" @@ -11028,20 +11028,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@6.18.0: - version "6.18.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.18.0.tgz#0a50c167209d6e7bd2ed9de200a6579ea4fb1dca" - integrity sha512-Ubrue4+Ercc/BoDkFQfc6og5zRQ4A8YxSO3Knsne+eRbZ+IepAsK249XBH/XaFuOYOYr3L3r13CXTLvYt5JDjw== +react-router-dom@6.19.0-pre.0: + version "6.19.0-pre.0" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.19.0-pre.0.tgz#99d561cf50babea4c8c65317950884bb1db76395" + integrity sha512-elYzIXUmv92//Stn0IK8EYQixv1zeuUWw/HZM9LTQ4aGdNeBTqpTEq/K66KJbmfklzgVpg29Nr515onohZA1dA== dependencies: - "@remix-run/router" "1.11.0" - react-router "6.18.0" + "@remix-run/router" "1.12.0-pre.0" + react-router "6.19.0-pre.0" -react-router@6.18.0: - version "6.18.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-6.18.0.tgz#32e2bedc318e095a48763b5ed7758e54034cd36a" - integrity sha512-vk2y7Dsy8wI02eRRaRmOs9g2o+aE72YCx5q9VasT1N9v+lrdB79tIqrjMfByHiY5+6aYkH2rUa5X839nwWGPDg== +react-router@6.19.0-pre.0: + version "6.19.0-pre.0" + resolved "https://registry.npmjs.org/react-router/-/react-router-6.19.0-pre.0.tgz#bebd659375430700b894e6007311bd1ff4bd84c5" + integrity sha512-qyPRUTh6jnZHu9t0UtdLdcVeKCWVmJbIZo0YgZb4ETxygqe/43WAPHsJ8TjA6/b6A+SeYhDZqeVu/U/lpBdUsA== dependencies: - "@remix-run/router" "1.11.0" + "@remix-run/router" "1.12.0-pre.0" react@^18.2.0: version "18.2.0"