Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@remix-run/router: Add support for navigation blocking #9709

Merged
merged 54 commits into from Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
0cc6142
history: allow multiple listeners
chaance Dec 2, 2022
3c8606c
bump UMD filesize limit
chaance Dec 7, 2022
04a8c8f
feat(router): add support for history blocking APIs
chaance Dec 8, 2022
2497c02
Merge branch 'dev' into history-blocking
chaance Dec 8, 2022
cbad7dc
chore: add changeset
chaance Dec 8, 2022
5d98596
Tweak changeset
chaance Dec 8, 2022
8e0a8d0
revert unrelated history changes
chaance Dec 8, 2022
0eedecf
fix tests
chaance Dec 8, 2022
8c4ef44
add missing blockers obj
chaance Dec 8, 2022
82a8267
add missing functions for router
chaance Dec 8, 2022
226eb08
implement useBlocker
chaance Dec 9, 2022
0b24b3c
add useBlocker to example
chaance Dec 9, 2022
c5f0384
fix issues with POP blocking
chaance Dec 20, 2022
12d8860
don't recreate existing blockers
chaance Dec 21, 2022
5d04b58
add support for option
chaance Dec 21, 2022
4a5ce97
usePrompt implementation
chaance Dec 21, 2022
70c4e86
fix subtle bugs when navigating to the same route
chaance Dec 21, 2022
f2ff193
implement beforeunload handler
chaance Dec 21, 2022
6823f58
patch fix for async tests
chaance Dec 21, 2022
3545cdc
Merge branch 'dev' into history-blocking
chaance Dec 21, 2022
f56ab7d
Fix history index issue and wire up singleton blocking
brophdawg11 Dec 22, 2022
e34bde0
Merge branch 'dev' into history-blocking
chaance Dec 23, 2022
5a81d17
fix some blocking tests
chaance Jan 2, 2023
934692f
Merge branch 'dev' into history-blocking
chaance Jan 2, 2023
7285473
Add beforeUnload flag to useBlocker
brophdawg11 Jan 9, 2023
8cfc7d3
Add navigation blocking example
brophdawg11 Jan 9, 2023
d2f05a1
Updates
brophdawg11 Jan 10, 2023
12f6daa
Remove manual back button
brophdawg11 Jan 10, 2023
bb1bb0a
update router blocking tests
chaance Jan 11, 2023
50d7c80
add missing method in server
chaance Jan 11, 2023
139974d
revert changes in data router example
chaance Jan 11, 2023
e6cbdbb
update hook name in logs
chaance Jan 11, 2023
a943da9
update tests
chaance Jan 11, 2023
65bba99
add use-blocker tests
chaance Jan 13, 2023
23d3aac
Merge branch 'history-blocking' of github.com:remix-run/react-router …
chaance Jan 13, 2023
2859b13
Merge branch 'dev' into history-blocking
chaance Jan 13, 2023
ba5c76d
update tests
chaance Jan 13, 2023
6fba7e5
bump umd bundle size limit
chaance Jan 13, 2023
f4d6b00
update blocker and shouldBlock function interface
chaance Jan 13, 2023
e25325e
update example
chaance Jan 13, 2023
27568f7
update example and mark exports as unstable
chaance Jan 13, 2023
fa00b37
add changeset
chaance Jan 13, 2023
546e3ac
update changeset
chaance Jan 13, 2023
76e7fde
update readme
chaance Jan 13, 2023
2334a5b
relax reliance on boolean type for blocker function
chaance Jan 13, 2023
b507993
update tests
chaance Jan 13, 2023
e1426d0
Revert to prior usePrompt example
brophdawg11 Jan 13, 2023
16e464f
Move useBlocker to react-router, remove getBlockerState
brophdawg11 Jan 13, 2023
b9739ba
Minor updates
brophdawg11 Jan 13, 2023
28da8cb
update build script
chaance Jan 13, 2023
b29b5e0
Bump bundle
brophdawg11 Jan 13, 2023
887314a
export from react-router-native
chaance Jan 13, 2023
6f15186
Merge branch 'history-blocking' of github.com:remix-run/react-router …
chaance Jan 13, 2023
7749172
rm prompt from example
chaance Jan 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-phones-impress.md
@@ -0,0 +1,5 @@
---
"@remix-run/router": minor
---

Added support for navigation blocking APIs
3 changes: 2 additions & 1 deletion .gitignore
Expand Up @@ -22,4 +22,5 @@ node_modules/
/packages/react-router-dom-v5-compat/react-router-dom

.eslintcache
/.env
/.env
/NOTES.md
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed somewhere to keep notes because this was getting confusing 😅

29 changes: 29 additions & 0 deletions examples/data-router/src/routes.tsx
Expand Up @@ -17,6 +17,8 @@ import {
useRouteError,
json,
useActionData,
useBlocker,
usePrompt,
} from "react-router-dom";

import type { Todos } from "./todos";
Expand Down Expand Up @@ -92,10 +94,37 @@ export async function homeLoader(): Promise<HomeLoaderData> {

export function Home() {
let data = useLoaderData() as HomeLoaderData;
let [shouldBlockNavigation, setShouldBlockNavigation] = React.useState(false);

let blocker = useBlocker(shouldBlockNavigation);
// usePrompt(
// shouldBlockNavigation ? "Are you *really* sure you want to leave?" : null,
// { beforeUnload: true }
// );

return (
<>
<h2>Home</h2>
<p>Last loaded at: {data.date}</p>
<div>
<label>
<input
type="checkbox"
checked={shouldBlockNavigation}
onChange={(e) => setShouldBlockNavigation(e.target.checked)}
/>
Block navigation
</label>
{blocker.state === "blocked" ? (
<div>
<p>Navigation is blocked.</p>
<button onClick={blocker.proceed}>Proceed</button>
<button onClick={blocker.reset}>Reset</button>
</div>
) : blocker.state === "proceeding" ? (
<p>Proceeding...</p>
) : null}
</div>
</>
);
}
Expand Down
90 changes: 90 additions & 0 deletions packages/react-router-dom/index.tsx
Expand Up @@ -25,6 +25,7 @@ import {
UNSAFE_enhanceManualRouteObjects as enhanceManualRouteObjects,
} from "react-router";
import type {
BlockerFunction,
BrowserHistory,
Fetcher,
FormEncType,
Expand Down Expand Up @@ -976,6 +977,95 @@ export function useFormAction(
return createPath(path);
}

let blockerKey = "blocker-singleton";
chaance marked this conversation as resolved.
Show resolved Hide resolved

export function useBlocker(
shouldBlock: boolean | (() => boolean) | BlockerFunction
) {
let { router } = useDataRouterContext(DataRouterHook.UseFetcher);
chaance marked this conversation as resolved.
Show resolved Hide resolved

let blockerFunction = React.useCallback<BlockerFunction>(() => {
return typeof shouldBlock === "function"
? shouldBlock() === true
: shouldBlock === true;
}, [shouldBlock]);

let blocker = router.getBlocker(blockerKey, blockerFunction);

// Cleanup on unmount
React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]);

return blocker;
}

export function usePrompt(
message: string | null | false,
opts?: { beforeUnload: boolean }
) {
let { beforeUnload } = opts ? opts : { beforeUnload: false };
let blockerFunction = React.useCallback<BlockerFunction>(() => {
if (!message) {
return false;
}

// Here be dragons! This is not bulletproof (at least at the moment). If
// you click the back button again while the global window.confirm prompt
// is open, it returns `false` (telling us to "block") but then _also_
// processes the back button click!

// So, consider:
chaance marked this conversation as resolved.
Show resolved Hide resolved
// - you have a stack of [/a, /b, /c] and you usePrompt() on /c
// - user clicks back button trying to POP /c -> /b
// - prompt shows up (URL shows /b but UI shows /c)
// - user clicks back button _again_ (POP /b -> /a)
// - this seems to queue up internally
// - we get a `false` back from window.confirm() (block!)
// - so we undo the _original_ POP /c -> /b and call history.go(1) to
// instead POP forward /b -> /c and we also tell our router to ignore
// the next history update
// - and then it seems that the queued history trumps our history revert,
// and so we receive the popstate event for the POP /b -> /a and then
// we ignore it thinking it was our revert of POP /b -> /c
//
// I think the solution here is to track more thn a boolean
// ignoreNextHistoryUpdate and instead track the key we're reverting from
// and the delta and compare that to any incoming popstate events.
return !window.confirm(message);
}, [message]);

let blocker = useBlocker(blockerFunction);

let prevState = React.useRef(blocker.state);
React.useEffect(() => {
if (blocker.state === "blocked") {
Copy link

@develra develra Dec 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this effect do?

blocker.reset();
}
prevState.current = blocker.state;
}, [blocker]);

// User is leaving the domain or refreshing the page. Here we have to rely on
// the `beforeunload` which is opt-in. Modern browsers will not display the
// custom message but will still block navigation when evt.returnValue is
// assigned.
React.useEffect(() => {
if (!beforeUnload) return;
let handleBeforeUnload = (evt: BeforeUnloadEvent) => {
if (blockerFunction()) {
evt.returnValue = message;
return message;
}
};
window.addEventListener("beforeunload", handleBeforeUnload, {
capture: true,
});
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload, {
capture: true,
});
};
}, [blocker, message, beforeUnload, blockerFunction]);
}

function createFetcherForm(fetcherKey: string, routeId: string) {
let FetcherForm = React.forwardRef<HTMLFormElement, FormProps>(
(props, ref) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/react-router-dom/server.tsx
Expand Up @@ -267,6 +267,7 @@ export function createStaticRouter(
preventScrollReset: false,
revalidation: "idle" as RevalidationState,
fetchers: new Map(),
blockers: new Map(),
};
},
get routes() {
Expand Down Expand Up @@ -301,6 +302,12 @@ export function createStaticRouter(
dispose() {
throw msg("dispose");
},
getBlocker() {
throw msg("getBlocker");
},
deleteBlocker() {
throw msg("deleteBlocker");
},
_internalFetchControllers: new Map(),
_internalActiveDeferreds: new Map(),
};
Expand Down
1 change: 1 addition & 0 deletions packages/router/__tests__/TestSequences/GoBack.ts
Expand Up @@ -31,6 +31,7 @@ export default async function GoBack(history: History) {
});
expect(spy).toHaveBeenCalledWith({
action: "POP",
delta: expect.any(Number),
location: {
hash: "",
key: expect.any(String),
Expand Down
2 changes: 2 additions & 0 deletions packages/router/__tests__/TestSequences/GoForward.ts
Expand Up @@ -31,6 +31,7 @@ export default async function GoForward(history: History) {
});
expect(spy).toHaveBeenCalledWith({
action: "POP",
delta: expect.any(Number),
location: {
hash: "",
key: expect.any(String),
Expand Down Expand Up @@ -58,6 +59,7 @@ export default async function GoForward(history: History) {
});
expect(spy).toHaveBeenCalledWith({
action: "POP",
delta: expect.any(Number),
location: {
hash: "",
key: expect.any(String),
Expand Down