From 0cc61428535546a18c0a66fd75a9d9fee020cac7 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 2 Dec 2022 09:16:52 -0800 Subject: [PATCH 01/47] history: allow multiple listeners --- packages/router/history.ts | 78 +++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/packages/router/history.ts b/packages/router/history.ts index 56779c8f9d..020e50943c 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -90,6 +90,17 @@ export interface Listener { (update: Update): void; } +/** + * A change to the current location that was blocked. May be retried + * after obtaining user confirmation. + */ +export interface Transition extends Update { + /** + * Retries the update to the current location. + */ + retry(): void; +} + /** * Describes a location that is the destination of some navigation, either via * `history.push` or `history.replace`. May be either a URL or the pieces of a @@ -171,6 +182,12 @@ export interface History { listen(listener: Listener): () => void; } +interface Events { + length: number; + push(fn: F): () => void; + call(arg: any): void; +} + type HistoryState = { usr: any; key?: string; @@ -227,7 +244,7 @@ export function createMemoryHistory( initialIndex == null ? entries.length - 1 : initialIndex ); let action = Action.Pop; - let listener: Listener | null = null; + let listeners = createEvents(); function clampIndex(n: number): number { return Math.min(Math.max(n, 0), entries.length - 1); @@ -281,30 +298,25 @@ export function createMemoryHistory( let nextLocation = createMemoryLocation(to, state); index += 1; entries.splice(index, entries.length, nextLocation); - if (v5Compat && listener) { - listener({ action, location: nextLocation }); + if (v5Compat) { + listeners.call({ action, location: nextLocation }); } }, replace(to, state) { action = Action.Replace; let nextLocation = createMemoryLocation(to, state); entries[index] = nextLocation; - if (v5Compat && listener) { - listener({ action, location: nextLocation }); + if (v5Compat) { + listeners.call({ action, location: nextLocation }); } }, go(delta) { action = Action.Pop; index = clampIndex(index + delta); - if (listener) { - listener({ action, location: getCurrentLocation() }); - } + listeners.call({ action, location: getCurrentLocation() }); }, - listen(fn: Listener) { - listener = fn; - return () => { - listener = null; - }; + listen(fn) { + return listeners.push(fn); }, }; @@ -464,6 +476,24 @@ function warning(cond: any, message: string) { } } +function createEvents(): Events { + let handlers: F[] = []; + return { + get length() { + return handlers.length; + }, + push(fn: F) { + handlers.push(fn); + return function () { + handlers = handlers.filter((handler) => handler !== fn); + }; + }, + call(arg) { + handlers.forEach((fn) => fn && fn(arg)); + }, + }; +} + function createKey() { return Math.random().toString(36).substr(2, 8); } @@ -574,13 +604,11 @@ function getUrlBasedHistory( let { window = document.defaultView!, v5Compat = false } = options; let globalHistory = window.history; let action = Action.Pop; - let listener: Listener | null = null; + let listeners = createEvents(); function handlePop() { action = Action.Pop; - if (listener) { - listener({ action, location: history.location }); - } + listeners.call({ action, location: history.location }); } function push(to: To, state?: any) { @@ -600,8 +628,8 @@ function getUrlBasedHistory( window.location.assign(url); } - if (v5Compat && listener) { - listener({ action, location: history.location }); + if (v5Compat) { + listeners.call({ action, location: history.location }); } } @@ -614,8 +642,8 @@ function getUrlBasedHistory( let url = history.createHref(location); globalHistory.replaceState(historyState, "", url); - if (v5Compat && listener) { - listener({ action, location: history.location }); + if (v5Compat) { + listeners.call({ action, location: history.location }); } } @@ -627,15 +655,11 @@ function getUrlBasedHistory( return getLocation(window, globalHistory); }, listen(fn: Listener) { - if (listener) { - throw new Error("A history only accepts one active listener"); - } window.addEventListener(PopStateEventType, handlePop); - listener = fn; - + let removeListener = listeners.push(fn); return () => { window.removeEventListener(PopStateEventType, handlePop); - listener = null; + removeListener(); }; }, createHref(to) { From 3c8606ca8f0209787d0a7f8b2b927db26d9513f4 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 7 Dec 2022 10:53:11 -0800 Subject: [PATCH 02/47] bump UMD filesize limit --- .gitignore | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 509667a660..1387c7fdff 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ node_modules/ /packages/react-router-dom-v5-compat/react-router-dom .eslintcache -/.env \ No newline at end of file +/.env +/NOTES.md diff --git a/package.json b/package.json index 9838ae7e99..f88cb69834 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "36 kB" + "none": "38 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "12.5 kB" From 04a8c8fb2ce1bff2a3c71884d1d76e3f3d2d9aa1 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 8 Dec 2022 12:13:41 -0800 Subject: [PATCH 03/47] feat(router): add support for history blocking APIs --- packages/router/__tests__/blocking-test.ts | 322 +++++++++++++++++++++ packages/router/history.ts | 60 +++- packages/router/router.ts | 183 +++++++++++- packages/router/utils.ts | 2 +- 4 files changed, 554 insertions(+), 13 deletions(-) create mode 100644 packages/router/__tests__/blocking-test.ts diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/blocking-test.ts new file mode 100644 index 0000000000..aa93372b37 --- /dev/null +++ b/packages/router/__tests__/blocking-test.ts @@ -0,0 +1,322 @@ +import type { Router } from "../index"; +import { createMemoryHistory, createRouter } from "../index"; + +describe("blocking", () => { + let router: Router; + it("creates a blocker", () => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries: ["/"], + initialIndex: 0, + }), + routes: [{ path: "/" }, { path: "/about" }], + }); + router.initialize(); + + let fn = () => true; + router.createBlocker("KEY", fn); + let blocker = router.getBlocker("KEY"); + expect(blocker).toEqual({ + fn, + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("deletes a blocker", () => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries: ["/"], + initialIndex: 0, + }), + routes: [{ path: "/" }, { path: "/about" }], + }); + router.initialize(); + + router.createBlocker("KEY", () => true); + router.deleteBlocker("KEY"); + let blocker = router.getBlocker("KEY"); + expect(blocker).toBeUndefined(); + }); + + describe("on history push", () => { + let initialEntries = ["/", "/about"]; + let initialIndex = 0; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes: [{ path: "/" }, { path: "/about" }], + }); + router.initialize(); + }); + + describe("blocker returns false", () => { + it("sets blocker state to 'unblocked'", async () => { + let blocker = router.createBlocker("KEY", () => false); + await router.navigate("/about"); + expect(blocker.state).toEqual("unblocked"); + }); + + it("navigates", async () => { + router.createBlocker("KEY", () => false); + await router.navigate("/about"); + expect(router.state.location.pathname).toBe("/about"); + }); + }); + + describe("blocker returns true", () => { + it("set blocker state to 'blocked'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about"); + expect(blocker.state).toEqual("blocked"); + }); + + it("does not navigate", async () => { + router.createBlocker("KEY", () => true); + await router.navigate("/about"); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + }); + }); + + describe("proceeds from blocked state", () => { + it("sets blocker state to 'proceeding'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about"); + expect(blocker.state).toEqual("blocked"); + + await blocker.proceed?.(); + expect(blocker.state).toEqual("proceeding"); + }); + + it("proceeds with blocked navigation", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about"); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + + blocker.proceed?.(); + expect(router.state.location.pathname).toEqual("/about"); + }); + }); + + describe("resets from blocked state", () => { + it("sets blocker state to 'unblocked'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about"); + expect(blocker.state).toEqual("blocked"); + + blocker.reset?.(); + expect(blocker.state).toEqual("unblocked"); + }); + + it("does not navigate", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about"); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + + blocker.reset?.(); + expect(router.state.location.pathname).toEqual("/"); + }); + }); + }); + + describe("on history replace", () => { + let initialEntries = ["/", "/about"]; + let initialIndex = 0; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes: [{ path: "/" }, { path: "/about" }], + }); + router.initialize(); + }); + + describe("blocker returns false", () => { + it("sets blocker state to 'unblocked'", async () => { + let blocker = router.createBlocker("KEY", () => false); + await router.navigate("/about", { replace: true }); + expect(blocker.state).toEqual("unblocked"); + }); + + it("navigates", async () => { + router.createBlocker("KEY", () => false); + await router.navigate("/about", { replace: true }); + expect(router.state.location.pathname).toBe("/about"); + }); + }); + + describe("blocker returns true", () => { + it("set blocker state to 'blocked'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about", { replace: true }); + expect(blocker.state).toEqual("blocked"); + }); + + it("does not navigate", async () => { + router.createBlocker("KEY", () => true); + await router.navigate("/about", { replace: true }); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + }); + }); + + describe("proceeds from blocked state", () => { + it("sets blocker state to 'proceeding'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about", { replace: true }); + expect(blocker.state).toEqual("blocked"); + + await blocker.proceed?.(); + expect(blocker.state).toEqual("proceeding"); + }); + + it("proceeds with blocked navigation", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about", { replace: true }); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + + blocker.proceed?.(); + expect(router.state.location.pathname).toEqual("/about"); + }); + }); + + describe("resets from blocked state", () => { + it("sets blocker state to 'unblocked'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about", { replace: true }); + expect(blocker.state).toEqual("blocked"); + + blocker.reset?.(); + expect(blocker.state).toEqual("unblocked"); + }); + + it("does not navigate", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate("/about", { replace: true }); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + + blocker.reset?.(); + expect(router.state.location.pathname).toEqual("/"); + }); + }); + }); + + describe("on history pop", () => { + let initialEntries = ["/", "/about", "/contact", "/help"]; + let initialIndex = 1; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes: [ + { path: "/" }, + { path: "/about" }, + { path: "/contact" }, + { path: "/help" }, + ], + }); + router.initialize(); + }); + + describe("blocker returns false", () => { + it("set blocker state to unblocked", async () => { + let blocker = router.createBlocker("KEY", () => false); + await router.navigate(-1); + expect(blocker.state).toEqual("unblocked"); + }); + + it("should navigate", async () => { + router.createBlocker("KEY", () => false); + await router.navigate(-1); + expect(router.state.location.pathname).toEqual( + initialEntries[initialIndex - 1] + ); + }); + }); + + describe("blocker returns true", () => { + it("set blocker state", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate(-1); + expect(blocker.state).toEqual("blocked"); + }); + + it("does not navigate", async () => { + router.createBlocker("KEY", () => true); + await router.navigate(-1); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + }); + }); + + describe("proceeds from blocked state", () => { + it("sets blocker state to 'proceeding'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate(-1); + expect(blocker.state).toEqual("blocked"); + + await blocker.proceed?.(); + expect(blocker.state).toEqual("proceeding"); + }); + + it("proceeds with blocked navigation", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate(-1); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + + await blocker.proceed?.(); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex - 1] + ); + }); + }); + + describe("resets from blocked state", () => { + it.todo("patches the history stack"); + + it("sets blocker state to 'unblocked'", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate(-1); + expect(blocker.state).toEqual("blocked"); + + blocker.reset?.(); + expect(blocker.state).toEqual("unblocked"); + }); + + it("does not navigate", async () => { + let blocker = router.createBlocker("KEY", () => true); + await router.navigate(-1); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + + blocker.reset?.(); + expect(router.state.location.pathname).toEqual( + initialEntries[initialIndex] + ); + }); + }); + }); +}); diff --git a/packages/router/history.ts b/packages/router/history.ts index 020e50943c..cc0ed5138f 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -81,13 +81,15 @@ export interface Update { * The new location. */ location: Location; + + delta?: number; } /** * A function that receives notifications about location changes. */ export interface Listener { - (update: Update): void; + (update: Update): void | Promise; } /** @@ -179,7 +181,7 @@ export interface History { * @param listener - A function that will be called when the location changes * @returns unlisten - A function that may be used to stop listening */ - listen(listener: Listener): () => void; + listen(listener: Listener): (() => void) | (() => Promise); } interface Events { @@ -191,6 +193,7 @@ interface Events { type HistoryState = { usr: any; key?: string; + idx: number; }; const PopStateEventType = "popstate"; @@ -311,9 +314,11 @@ export function createMemoryHistory( } }, go(delta) { - action = Action.Pop; - index = clampIndex(index + delta); - listeners.call({ action, location: getCurrentLocation() }); + action = Action.Replace; + let nextIndex = clampIndex(index + delta); + let nextLocation = entries[nextIndex]; + index = nextIndex; + listeners.call({ action, location: nextLocation }); }, listen(fn) { return listeners.push(fn); @@ -501,10 +506,11 @@ function createKey() { /** * For browser-based histories, we combine the state and key into an object */ -function getHistoryState(location: Location): HistoryState { +function getHistoryState(location: Location, index: number): HistoryState { return { usr: location.state, key: location.key, + idx: index, }; } @@ -606,9 +612,41 @@ function getUrlBasedHistory( let action = Action.Pop; let listeners = createEvents(); + let index = getIndex()!; + // Index should only be null when we initialize. If not, it's because the + // user called history.pushState or history.replaceState directly, in which + // case we should log a warning as it will result in bugs. + if (index == null) { + index = 0; + globalHistory.replaceState({ ...globalHistory.state, idx: index }, ""); + } + + function getIndex(): number { + let state = globalHistory.state || { idx: null }; + return state.idx; + } + function handlePop() { - action = Action.Pop; - listeners.call({ action, location: history.location }); + let nextAction = Action.Pop; + let nextIndex = getIndex(); + + if (nextIndex != null) { + let delta = index - nextIndex; + action = nextAction; + listeners.call({ action, location: history.location, delta }); + } else { + warning( + false, + // TODO: Write up a doc that explains our blocking strategy in detail + // and link to it here so people can understand better what is going on + // and how to avoid it. + `You are trying to block a POP navigation to a location that was not ` + + `created by @remix-run/router. The block will fail silently in ` + + `production, but in general you should do all navigation with the ` + + `router (instead of using window.history.pushState directly) ` + + `to avoid this situation.` + ); + } } function push(to: To, state?: any) { @@ -616,7 +654,8 @@ function getUrlBasedHistory( let location = createLocation(history.location, to, state); if (validateLocation) validateLocation(location, to); - let historyState = getHistoryState(location); + index = getIndex() + 1; + let historyState = getHistoryState(location, index); let url = history.createHref(location); // try...catch because iOS limits us to 100 pushState calls :/ @@ -638,7 +677,8 @@ function getUrlBasedHistory( let location = createLocation(history.location, to, state); if (validateLocation) validateLocation(location, to); - let historyState = getHistoryState(location); + index = getIndex(); + let historyState = getHistoryState(location, index); let url = history.createHref(location); globalHistory.replaceState(historyState, "", url); diff --git a/packages/router/router.ts b/packages/router/router.ts index 7d3b1d3f76..e9488049db 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -33,6 +33,7 @@ import { joinPaths, matchRoutes, resolveTo, + warning, } from "./utils"; //////////////////////////////////////////////////////////////////////////////// @@ -111,14 +112,14 @@ export interface Router { * Navigate forward/backward in the history stack * @param to Delta to move in the history stack */ - navigate(to: number): void; + navigate(to: number): Promise; /** * Navigate to the given path * @param to Path to navigate to * @param opts Navigation options (method, submission, etc.) */ - navigate(to: To, opts?: RouterNavigateOptions): void; + navigate(to: To, opts?: RouterNavigateOptions): Promise; /** * @internal @@ -191,6 +192,64 @@ export interface Router { */ dispose(): void; + /** + * Create a new navigation blocker + * + * @param key A unique identifier for the blocker + */ + createBlocker(key: string, fn: ShouldBlockFunction): Blocker; + + /** + * Get a navigation blocker + * + * @param key The identifier for the blocker + */ + getBlocker(key: string): Blocker | undefined; + + /** + * Delete a navigation blocker + * + * @param key The identifier for the blocker + */ + deleteBlocker(key: string): void; + + /** + * Update the state of a navigation blocker + * + * @param key The identifier for the blocker + * @param state The next state. Must be one of `"blocked"`, `"unblocked"`, or + * "proceeding" + * @param navigate The navigation function to call when the blocked navigation + * is allowed to proceed + */ + setBlockerState( + key: string, + state: "blocked", + navigate: () => Promise + ): Blocker | undefined; + + /** + * Update the state of a navigation blocker + * + * @param key The identifier for the blocker + * @param state The next state. Must be one of `"blocked"`, `"unblocked"`, or + * "proceeding" + */ + setBlockerState( + key: string, + state: "unblocked" | "proceeding" + ): Blocker | undefined; + + /** + * Update a navigation blocker's function. This is the function that is + * evaluated to determine whether or not to block a navigation event. + * + * @param key The identifier for the blocker + * @param fn The updated function to determine whether or not to block a + * navigation + */ + setBlockerFunction(key: string, fn: ShouldBlockFunction): Blocker | undefined; + /** * @internal * PRIVATE - DO NOT USE @@ -276,6 +335,11 @@ export interface RouterState { * Map of current fetchers */ fetchers: Map; + + /** + * Map of current blockers + */ + blockers: Map; } /** @@ -451,6 +515,18 @@ type FetcherStates = { export type Fetcher = FetcherStates[keyof FetcherStates]; +export type Blocker = + | { state: "blocked"; reset(): void; proceed(): Promise; fn(): boolean } + | { state: "unblocked"; reset: undefined; proceed: undefined; fn(): boolean } + | { + state: "proceeding"; + reset: undefined; + proceed: undefined; + fn(): boolean; + }; + +export type ShouldBlockFunction = () => boolean; + interface ShortCircuitable { /** * startNavigation does not need to complete the navigation because we @@ -619,44 +695,58 @@ export function createRouter(init: RouterInit): Router { actionData: (init.hydrationData && init.hydrationData.actionData) || null, errors: (init.hydrationData && init.hydrationData.errors) || initialErrors, fetchers: new Map(), + blockers: new Map(), }; // -- Stateful internal variables to manage navigations -- // Current navigation in progress (to be committed in completeNavigation) let pendingAction: HistoryAction = HistoryAction.Pop; + // Should the current navigation prevent the scroll reset if scroll cannot // be restored? let pendingPreventScrollReset = false; + // AbortController for the active navigation let pendingNavigationController: AbortController | null; + // We use this to avoid touching history in completeNavigation if a // revalidation is entirely uninterrupted let isUninterruptedRevalidation = false; + // Use this internal flag to force revalidation of all loaders: // - submissions (completed or interrupted) // - useRevalidate() // - X-Remix-Revalidate (from redirect) let isRevalidationRequired = false; + // Use this internal array to capture routes that require revalidation due // to a cancelled deferred on action submission let cancelledDeferredRoutes: string[] = []; + // Use this internal array to capture fetcher loads that were cancelled by an // action navigation and require revalidation let cancelledFetcherLoads: string[] = []; + // AbortControllers for any in-flight fetchers let fetchControllers = new Map(); + // Track loads based on the order in which they started let incrementingLoadId = 0; + // Track the outstanding pending navigation data load to be compared against // the globally incrementing load when a fetcher load lands after a completed // navigation let pendingNavigationLoadId = -1; + // Fetchers that triggered data reloads as a result of their actions let fetchReloadIds = new Map(); + // Fetchers that triggered redirect navigations from their actions let fetchRedirectIds = new Set(); + // Most recent href/match for fetcher.load calls for fetchers let fetchLoadMatches = new Map(); + // Store DeferredData instances for active route matches. When a // route loader returns defer() we stick one in here. Then, when a nested // promise resolves we update loaderData. If a new navigation starts we @@ -779,12 +869,96 @@ export function createRouter(init: RouterInit): Router { cancelledFetcherLoads = []; } + function createBlocker(key: string, fn: ShouldBlockFunction) { + let blocker: Blocker = { + state: "unblocked", + proceed: undefined, + reset: undefined, + fn, + }; + state.blockers.set(key, blocker); + return blocker; + } + + function getBlocker(key: string) { + return state.blockers.get(key); + } + + function deleteBlocker(key: string) { + state.blockers.delete(key); + } + + function setBlockerState( + key: string, + state: "blocked", + navigate: () => Promise + ): Blocker | undefined; + function setBlockerState( + key: string, + state: "unblocked" | "proceeding" + ): Blocker | undefined; + + function setBlockerState( + key: string, + nextState: Blocker["state"], + navigate?: () => Promise + ) { + let blocker = state.blockers.get(key); + if (!blocker) return; + invariant( + nextState === "proceeding" || + nextState === "blocked" || + nextState === "unblocked", + `Invalid blocker state: ${nextState}` + ); + + blocker.state = nextState; + if (nextState === "blocked") { + blocker.proceed = async () => { + invariant( + typeof navigate === "function", + "Cannot proceed without a navigate function" + ); + setBlockerState(key, "proceeding"); + await navigate(); + }; + blocker.reset = () => { + setBlockerState(key, "unblocked"); + }; + } else { + blocker.proceed = undefined; + blocker.reset = undefined; + } + return blocker; + } + + function setBlockerFunction(key: string, fn: Blocker["fn"]) { + let blocker = state.blockers.get(key); + if (!blocker) return; + if (typeof fn !== "function") { + warning( + false, + `A blocker function update was requested with a value that is not a function. This is not allowed, and the blocker's function will not be updated.` + ); + return; + } + blocker.fn = fn; + return blocker; + } + // Trigger a navigation event, which can either be a numerical POP or a PUSH // replace with an optional submission async function navigate( to: number | To, opts?: RouterNavigateOptions ): Promise { + for (let [key, blocker] of state.blockers) { + if (blocker.state !== "proceeding" && blocker.fn()) { + setBlockerState(key, "blocked", () => navigate(to, opts)); + return; + } + } + if (typeof to === "number") { init.history.go(to); return; @@ -1893,6 +2067,11 @@ export function createRouter(init: RouterInit): Router { getFetcher, deleteFetcher, dispose, + createBlocker, + getBlocker, + setBlockerState, + setBlockerFunction, + deleteBlocker, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, }; diff --git a/packages/router/utils.ts b/packages/router/utils.ts index eeb33573e5..bfc4b0a2f4 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -846,7 +846,7 @@ export function warning(cond: any, message: string): void { if (typeof console !== "undefined") console.warn(message); try { - // Welcome to debugging React Router! + // Welcome to debugging @remix-run/router! // // This error is thrown as a convenience so you can more easily // find the source for a warning that appears in the console by From cbad7dce46c64d2cecc9f35cf2031b219d0b57fc Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 8 Dec 2022 12:15:25 -0800 Subject: [PATCH 04/47] chore: add changeset --- .changeset/light-phones-impress.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/light-phones-impress.md diff --git a/.changeset/light-phones-impress.md b/.changeset/light-phones-impress.md new file mode 100644 index 0000000000..87c3c09ca0 --- /dev/null +++ b/.changeset/light-phones-impress.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": minor +--- + +Added support for history blocking APIs From 5d98596bcdd6d7d0f4163c3da6215b06c64c1677 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 8 Dec 2022 12:18:27 -0800 Subject: [PATCH 05/47] Tweak changeset --- .changeset/light-phones-impress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/light-phones-impress.md b/.changeset/light-phones-impress.md index 87c3c09ca0..2abce6d194 100644 --- a/.changeset/light-phones-impress.md +++ b/.changeset/light-phones-impress.md @@ -2,4 +2,4 @@ "@remix-run/router": minor --- -Added support for history blocking APIs +Added support for navigation blocking APIs From 8e0a8d03bcd18e6b20a3d24bf270a36ac88ea31d Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 8 Dec 2022 12:28:50 -0800 Subject: [PATCH 06/47] revert unrelated history changes --- packages/router/history.ts | 78 +++++++++++++++----------------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/packages/router/history.ts b/packages/router/history.ts index 8c50917c8d..ec1afcdba9 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -89,18 +89,7 @@ export interface Update { * A function that receives notifications about location changes. */ export interface Listener { - (update: Update): void | Promise; -} - -/** - * A change to the current location that was blocked. May be retried - * after obtaining user confirmation. - */ -export interface Transition extends Update { - /** - * Retries the update to the current location. - */ - retry(): void; + (update: Update): void; } /** @@ -181,7 +170,7 @@ export interface History { * @param listener - A function that will be called when the location changes * @returns unlisten - A function that may be used to stop listening */ - listen(listener: Listener): (() => void) | (() => Promise); + listen(listener: Listener): () => void; } interface Events { @@ -247,7 +236,7 @@ export function createMemoryHistory( initialIndex == null ? entries.length - 1 : initialIndex ); let action = Action.Pop; - let listeners = createEvents(); + let listener: Listener | null = null; function clampIndex(n: number): number { return Math.min(Math.max(n, 0), entries.length - 1); @@ -301,27 +290,32 @@ export function createMemoryHistory( let nextLocation = createMemoryLocation(to, state); index += 1; entries.splice(index, entries.length, nextLocation); - if (v5Compat) { - listeners.call({ action, location: nextLocation }); + if (v5Compat && listener) { + listener({ action, location: nextLocation }); } }, replace(to, state) { action = Action.Replace; let nextLocation = createMemoryLocation(to, state); entries[index] = nextLocation; - if (v5Compat) { - listeners.call({ action, location: nextLocation }); + if (v5Compat && listener) { + listener({ action, location: nextLocation }); } }, go(delta) { - action = Action.Replace; + action = Action.Pop; let nextIndex = clampIndex(index + delta); let nextLocation = entries[nextIndex]; index = nextIndex; - listeners.call({ action, location: nextLocation }); + if (listener) { + listener({ action, location: nextLocation }); + } }, - listen(fn) { - return listeners.push(fn); + listen(fn: Listener) { + listener = fn; + return () => { + listener = null; + }; }, }; @@ -495,24 +489,6 @@ function warning(cond: any, message: string) { } } -function createEvents(): Events { - let handlers: F[] = []; - return { - get length() { - return handlers.length; - }, - push(fn: F) { - handlers.push(fn); - return function () { - handlers = handlers.filter((handler) => handler !== fn); - }; - }, - call(arg) { - handlers.forEach((fn) => fn && fn(arg)); - }, - }; -} - function createKey() { return Math.random().toString(36).substr(2, 8); } @@ -628,7 +604,7 @@ function getUrlBasedHistory( let { window = document.defaultView!, v5Compat = false } = options; let globalHistory = window.history; let action = Action.Pop; - let listeners = createEvents(); + let listener: Listener | null = null; let index = getIndex()!; // Index should only be null when we initialize. If not, it's because the @@ -651,7 +627,9 @@ function getUrlBasedHistory( if (nextIndex != null) { let delta = index - nextIndex; action = nextAction; - listeners.call({ action, location: history.location, delta }); + if (listener) { + listener({ action, location: history.location, delta }); + } } else { warning( false, @@ -685,8 +663,8 @@ function getUrlBasedHistory( window.location.assign(url); } - if (v5Compat) { - listeners.call({ action, location: history.location }); + if (v5Compat && listener) { + listener({ action, location: history.location }); } } @@ -700,8 +678,8 @@ function getUrlBasedHistory( let url = history.createHref(location); globalHistory.replaceState(historyState, "", url); - if (v5Compat) { - listeners.call({ action, location: history.location }); + if (v5Compat && listener) { + listener({ action, location: history.location }); } } @@ -713,11 +691,15 @@ function getUrlBasedHistory( return getLocation(window, globalHistory); }, listen(fn: Listener) { + if (listener) { + throw new Error("A history only accepts one active listener"); + } window.addEventListener(PopStateEventType, handlePop); - let removeListener = listeners.push(fn); + listener = fn; + return () => { window.removeEventListener(PopStateEventType, handlePop); - removeListener(); + listener = null; }; }, createHref(to) { From 0eedecf042561e8d537b94b21a901bcb9a2b6316 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 8 Dec 2022 13:03:11 -0800 Subject: [PATCH 07/47] fix tests --- .../router/__tests__/TestSequences/GoBack.ts | 1 + .../__tests__/TestSequences/GoForward.ts | 2 ++ packages/router/__tests__/router-test.ts | 1 + packages/router/history.ts | 18 ++++++------------ 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/router/__tests__/TestSequences/GoBack.ts b/packages/router/__tests__/TestSequences/GoBack.ts index d7009441eb..03a6711818 100644 --- a/packages/router/__tests__/TestSequences/GoBack.ts +++ b/packages/router/__tests__/TestSequences/GoBack.ts @@ -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), diff --git a/packages/router/__tests__/TestSequences/GoForward.ts b/packages/router/__tests__/TestSequences/GoForward.ts index 5949524b26..ccc9f09c3a 100644 --- a/packages/router/__tests__/TestSequences/GoForward.ts +++ b/packages/router/__tests__/TestSequences/GoForward.ts @@ -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), @@ -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), diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index f8939b6378..fe385f981c 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -933,6 +933,7 @@ describe("a router", () => { restoreScrollPosition: null, revalidation: "idle", fetchers: new Map(), + blockers: new Map(), }); }); diff --git a/packages/router/history.ts b/packages/router/history.ts index ec1afcdba9..374870941f 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -82,7 +82,7 @@ export interface Update { */ location: Location; - delta?: number; + delta: number; } /** @@ -173,12 +173,6 @@ export interface History { listen(listener: Listener): () => void; } -interface Events { - length: number; - push(fn: F): () => void; - call(arg: any): void; -} - type HistoryState = { usr: any; key?: string; @@ -291,7 +285,7 @@ export function createMemoryHistory( index += 1; entries.splice(index, entries.length, nextLocation); if (v5Compat && listener) { - listener({ action, location: nextLocation }); + listener({ action, location: nextLocation, delta: 1 }); } }, replace(to, state) { @@ -299,7 +293,7 @@ export function createMemoryHistory( let nextLocation = createMemoryLocation(to, state); entries[index] = nextLocation; if (v5Compat && listener) { - listener({ action, location: nextLocation }); + listener({ action, location: nextLocation, delta: 0 }); } }, go(delta) { @@ -308,7 +302,7 @@ export function createMemoryHistory( let nextLocation = entries[nextIndex]; index = nextIndex; if (listener) { - listener({ action, location: nextLocation }); + listener({ action, location: nextLocation, delta }); } }, listen(fn: Listener) { @@ -664,7 +658,7 @@ function getUrlBasedHistory( } if (v5Compat && listener) { - listener({ action, location: history.location }); + listener({ action, location: history.location, delta: 1 }); } } @@ -679,7 +673,7 @@ function getUrlBasedHistory( globalHistory.replaceState(historyState, "", url); if (v5Compat && listener) { - listener({ action, location: history.location }); + listener({ action, location: history.location, delta: 0 }); } } From 8c4ef442a0e2d10c3ac419241ca99d9df820551d Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 8 Dec 2022 13:05:56 -0800 Subject: [PATCH 08/47] add missing blockers obj --- packages/react-router-dom/server.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index f0cfbff171..2ee74315aa 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -261,6 +261,7 @@ export function unstable_createStaticRouter( preventScrollReset: false, revalidation: "idle" as RevalidationState, fetchers: new Map(), + blockers: new Map(), }; }, get routes() { From 82a826776b736c7d2b89de08ae6fb4e09ac12b28 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 8 Dec 2022 13:16:37 -0800 Subject: [PATCH 09/47] add missing functions for router --- packages/react-router-dom/server.tsx | 16 ++++++++++++++++ packages/router/router.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 2ee74315aa..8033da0306 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -9,6 +9,7 @@ import type { import { IDLE_FETCHER, IDLE_NAVIGATION, + UNBLOCKED_BLOCKER, Action, invariant, isRouteErrorResponse, @@ -296,6 +297,21 @@ export function unstable_createStaticRouter( dispose() { throw msg("dispose"); }, + getBlocker() { + return UNBLOCKED_BLOCKER; + }, + deleteBlocker() { + throw msg("deleteBlocker"); + }, + createBlocker() { + throw msg("createBlocker"); + }, + setBlockerFunction() { + throw msg("setBlockerFunction"); + }, + setBlockerState() { + throw msg("setBlockerState"); + }, _internalFetchControllers: new Map(), _internalActiveDeferreds: new Map(), }; diff --git a/packages/router/router.ts b/packages/router/router.ts index 36de07b138..c50bca93d7 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -634,6 +634,13 @@ export const IDLE_FETCHER: FetcherStates["Idle"] = { formData: undefined, }; +export const UNBLOCKED_BLOCKER: Blocker & { state: "unblocked" } = { + state: "unblocked", + fn: () => false, + proceed: undefined, + reset: undefined, +}; + const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined" && From 226eb089de4acaf34d0ddf183f20ba8b96967719 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 9 Dec 2022 09:34:33 -0800 Subject: [PATCH 10/47] implement useBlocker --- packages/react-router-dom/index.tsx | 27 ++++++ packages/react-router-dom/server.tsx | 13 +-- packages/router/router.ts | 139 ++++++++++++++++----------- 3 files changed, 117 insertions(+), 62 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index a1bb9430ea..a6f74a00d3 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -26,6 +26,7 @@ import { } from "react-router"; import type { BrowserHistory, + Blocker, Fetcher, FormEncType, FormMethod, @@ -39,6 +40,7 @@ import { createRouter, createBrowserHistory, createHashHistory, + getInitialBlocker, invariant, joinPaths, ErrorResponse, @@ -962,6 +964,31 @@ export function useFormAction( return createPath(path); } +let blockerId = 0; + +export function useBlocker(shouldBlock: boolean | (() => boolean)) { + let [blockerKey] = React.useState(() => String(++blockerId)); + let { router } = useDataRouterContext(DataRouterHook.UseFetcher); + let [blocker, setBlocker] = React.useState(() => + getInitialBlocker(() => { + throw Error("Navigation should not occur during render."); + }) + ); + + React.useEffect(() => { + let blocker = router.createBlocker( + blockerKey, + typeof shouldBlock === "function" ? shouldBlock : () => !!shouldBlock + ); + setBlocker(blocker); + return () => { + router.deleteBlocker(blockerKey); + }; + }, [blockerKey, shouldBlock, router]); + + return blocker; +} + function createFetcherForm(fetcherKey: string, routeId: string) { let FetcherForm = React.forwardRef( (props, ref) => { diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 8033da0306..629713b493 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -9,11 +9,11 @@ import type { import { IDLE_FETCHER, IDLE_NAVIGATION, - UNBLOCKED_BLOCKER, Action, invariant, isRouteErrorResponse, UNSAFE_convertRoutesToDataRoutes as convertRoutesToDataRoutes, + getInitialBlocker, } from "@remix-run/router"; import type { DataRouteObject, @@ -298,16 +298,17 @@ export function unstable_createStaticRouter( throw msg("dispose"); }, getBlocker() { - return UNBLOCKED_BLOCKER; + return getInitialBlocker(() => { + throw msg("getBlocker"); + }); }, deleteBlocker() { throw msg("deleteBlocker"); }, createBlocker() { - throw msg("createBlocker"); - }, - setBlockerFunction() { - throw msg("setBlockerFunction"); + return getInitialBlocker(() => { + throw msg("createBlocker"); + }); }, setBlockerState() { throw msg("setBlockerState"); diff --git a/packages/router/router.ts b/packages/router/router.ts index c50bca93d7..028726d905 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -204,7 +204,7 @@ export interface Router { * * @param key The identifier for the blocker */ - getBlocker(key: string): Blocker | undefined; + getBlocker(key: string, fn?: ShouldBlockFunction): Blocker | undefined; /** * Delete a navigation blocker @@ -214,21 +214,25 @@ export interface Router { deleteBlocker(key: string): void; /** + * @internal + * PRIVATE - DO NOT USE + * * Update the state of a navigation blocker * * @param key The identifier for the blocker * @param state The next state. Must be one of `"blocked"`, `"unblocked"`, or * "proceeding" - * @param navigate The navigation function to call when the blocked navigation - * is allowed to proceed */ setBlockerState( key: string, state: "blocked", - navigate: () => Promise + opts: SetBlockerOpts ): Blocker | undefined; /** + * @internal + * PRIVATE - DO NOT USE + * * Update the state of a navigation blocker * * @param key The identifier for the blocker @@ -240,16 +244,6 @@ export interface Router { state: "unblocked" | "proceeding" ): Blocker | undefined; - /** - * Update a navigation blocker's function. This is the function that is - * evaluated to determine whether or not to block a navigation event. - * - * @param key The identifier for the blocker - * @param fn The updated function to determine whether or not to block a - * navigation - */ - setBlockerFunction(key: string, fn: ShouldBlockFunction): Blocker | undefined; - /** * @internal * PRIVATE - DO NOT USE @@ -267,6 +261,11 @@ export interface Router { _internalActiveDeferreds: Map; } +interface SetBlockerOpts { + onProceed?(): Promise; + onReset?(): void; +} + /** * State maintained internally by the router. During a navigation, all states * reflect the the "old" location unless otherwise noted. @@ -634,13 +633,6 @@ export const IDLE_FETCHER: FetcherStates["Idle"] = { formData: undefined, }; -export const UNBLOCKED_BLOCKER: Blocker & { state: "unblocked" } = { - state: "unblocked", - fn: () => false, - proceed: undefined, - reset: undefined, -}; - const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined" && @@ -778,8 +770,26 @@ export function createRouter(init: RouterInit): Router { // If history informs us of a POP navigation, start the navigation but do not update // state. We'll update our own state once the navigation completes unlistenHistory = init.history.listen( - ({ action: historyAction, location }) => - startNavigation(historyAction, location) + ({ action: historyAction, location, delta }) => { + for (let [key, blocker] of state.blockers) { + if (blocker.state !== "proceeding" && blocker.fn()) { + setBlockerState(key, "blocked", { + onProceed() { + return navigate(location, { + replace: historyAction === HistoryAction.Replace, + }); + }, + onReset() { + if (delta !== 0) { + init.history.go(delta); + } + }, + }); + return; + } + } + return startNavigation(historyAction, location); + } ); // Kick off initial data load if needed. Use Pop to avoid modifying history @@ -895,31 +905,37 @@ export function createRouter(init: RouterInit): Router { fn, }; state.blockers.set(key, blocker); + updateState({ blockers: state.blockers }); return blocker; } - function getBlocker(key: string) { - return state.blockers.get(key); + function getBlocker(key: string, fn?: ShouldBlockFunction) { + let blocker = state.blockers.get(key); + if (!blocker) return; + + if (fn) { + if (typeof fn === "function") { + blocker.fn = fn; + } else { + warning( + false, + `A blocker function update was requested with a value that is not a function. This is not allowed, and the blocker's function will not be updated.` + ); + } + } + + return blocker; } function deleteBlocker(key: string) { state.blockers.delete(key); + updateState({ blockers: state.blockers }); } - function setBlockerState( - key: string, - state: "blocked", - navigate: () => Promise - ): Blocker | undefined; - function setBlockerState( - key: string, - state: "unblocked" | "proceeding" - ): Blocker | undefined; - function setBlockerState( key: string, nextState: Blocker["state"], - navigate?: () => Promise + opts?: SetBlockerOpts ) { let blocker = state.blockers.get(key); if (!blocker) return; @@ -930,37 +946,27 @@ export function createRouter(init: RouterInit): Router { `Invalid blocker state: ${nextState}` ); + if (blocker.state === nextState) { + return blocker; + } + blocker.state = nextState; if (nextState === "blocked") { + let { onProceed, onReset } = opts || {}; blocker.proceed = async () => { - invariant( - typeof navigate === "function", - "Cannot proceed without a navigate function" - ); setBlockerState(key, "proceeding"); - await navigate(); + await onProceed?.(); }; blocker.reset = () => { setBlockerState(key, "unblocked"); + onReset?.(); }; } else { blocker.proceed = undefined; blocker.reset = undefined; } - return blocker; - } - function setBlockerFunction(key: string, fn: Blocker["fn"]) { - let blocker = state.blockers.get(key); - if (!blocker) return; - if (typeof fn !== "function") { - warning( - false, - `A blocker function update was requested with a value that is not a function. This is not allowed, and the blocker's function will not be updated.` - ); - return; - } - blocker.fn = fn; + updateState({ blockers: state.blockers }); return blocker; } @@ -972,7 +978,9 @@ export function createRouter(init: RouterInit): Router { ): Promise { for (let [key, blocker] of state.blockers) { if (blocker.state !== "proceeding" && blocker.fn()) { - setBlockerState(key, "blocked", () => navigate(to, opts)); + setBlockerState(key, "blocked", { + onProceed: () => navigate(to, opts), + }); return; } } @@ -1098,6 +1106,15 @@ export function createRouter(init: RouterInit): Router { return; } + // Short circuit if navigation is blocked + if ( + Array.from(state.blockers).some(([_, blocker]) => { + return blocker.state === "blocked"; + }) + ) { + return; + } + // Short circuit if it's only a hash change if (isHashChangeOnly(state.location, location)) { completeNavigation(location, { matches }); @@ -2100,7 +2117,6 @@ export function createRouter(init: RouterInit): Router { createBlocker, getBlocker, setBlockerState, - setBlockerFunction, deleteBlocker, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, @@ -3421,4 +3437,15 @@ function getTargetMatch( let pathMatches = getPathContributingMatches(matches); return pathMatches[pathMatches.length - 1]; } + +export function getInitialBlocker( + fn: ShouldBlockFunction +): Blocker & { state: "unblocked" } { + return { + state: "unblocked", + fn, + proceed: undefined, + reset: undefined, + }; +} //#endregion From 0b24b3c7fee20f7a2de77a19d0e4e36172af52cc Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 9 Dec 2022 09:34:43 -0800 Subject: [PATCH 11/47] add useBlocker to example --- examples/data-router/src/routes.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/data-router/src/routes.tsx b/examples/data-router/src/routes.tsx index ce428df118..b8e3cde5bf 100644 --- a/examples/data-router/src/routes.tsx +++ b/examples/data-router/src/routes.tsx @@ -17,6 +17,7 @@ import { useRouteError, json, useActionData, + useBlocker, } from "react-router-dom"; import type { Todos } from "./todos"; @@ -92,10 +93,31 @@ export async function homeLoader(): Promise { export function Home() { let data = useLoaderData() as HomeLoaderData; + let [shouldBlockNavigation, setShouldBlockNavigation] = React.useState(false); + let blocker = useBlocker(shouldBlockNavigation); return ( <>

Home

Last loaded at: {data.date}

+
+ + {blocker.state === "blocked" ? ( +
+

Navigation is blocked.

+ + +
+ ) : blocker.state === "proceeding" ? ( +

Proceeding...

+ ) : null} +
); } From c5f0384602317fe095ab94279003ed8e7e7aa44f Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Tue, 20 Dec 2022 10:58:32 -0800 Subject: [PATCH 12/47] fix issues with POP blocking --- packages/router/router.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 028726d905..0ef50ee9ce 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -772,17 +772,19 @@ export function createRouter(init: RouterInit): Router { unlistenHistory = init.history.listen( ({ action: historyAction, location, delta }) => { for (let [key, blocker] of state.blockers) { - if (blocker.state !== "proceeding" && blocker.fn()) { + if (blocker.state === "blocked") { + return; + } + + if (blocker.state === "unblocked" && blocker.fn()) { + init.history.go(delta); setBlockerState(key, "blocked", { - onProceed() { - return navigate(location, { - replace: historyAction === HistoryAction.Replace, - }); + async onProceed() { + init.history.go(delta * -1); }, onReset() { - if (delta !== 0) { - init.history.go(delta); - } + // noop, we've already blocked and setting state back to + // 'unblocked' in `setBlockerState` }, }); return; From 12d8860db17edb3a1523eff626ddfac12fb835c8 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Tue, 20 Dec 2022 16:47:22 -0800 Subject: [PATCH 13/47] don't recreate existing blockers --- packages/router/router.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 0ef50ee9ce..2faeabbac3 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -900,6 +900,10 @@ export function createRouter(init: RouterInit): Router { } function createBlocker(key: string, fn: ShouldBlockFunction) { + if (state.blockers.has(key)) { + return state.blockers.get(key)!; + } + let blocker: Blocker = { state: "unblocked", proceed: undefined, @@ -907,7 +911,6 @@ export function createRouter(init: RouterInit): Router { fn, }; state.blockers.set(key, blocker); - updateState({ blockers: state.blockers }); return blocker; } @@ -931,7 +934,6 @@ export function createRouter(init: RouterInit): Router { function deleteBlocker(key: string) { state.blockers.delete(key); - updateState({ blockers: state.blockers }); } function setBlockerState( @@ -940,7 +942,10 @@ export function createRouter(init: RouterInit): Router { opts?: SetBlockerOpts ) { let blocker = state.blockers.get(key); - if (!blocker) return; + if (!blocker) { + return; + } + invariant( nextState === "proceeding" || nextState === "blocked" || @@ -968,7 +973,8 @@ export function createRouter(init: RouterInit): Router { blocker.reset = undefined; } - updateState({ blockers: state.blockers }); + state.blockers.set(key, blocker); + updateState({ blockers: new Map(state.blockers) }); return blocker; } From 5d04b58147168acb974eeefc732077537b8a5729 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Tue, 20 Dec 2022 17:45:23 -0800 Subject: [PATCH 14/47] add support for option --- packages/react-router-dom/index.tsx | 31 ++++++-- packages/router/__tests__/blocking-test.ts | 90 +++++++++++++++------- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index a6f74a00d3..e51eda72ed 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -964,9 +964,16 @@ export function useFormAction( return createPath(path); } +export type ShouldBlockFunction = () => { + shouldBlock: boolean; + force?: boolean; +}; + let blockerId = 0; -export function useBlocker(shouldBlock: boolean | (() => boolean)) { +export function useBlocker( + shouldBlock: boolean | (() => boolean) | ShouldBlockFunction +) { let [blockerKey] = React.useState(() => String(++blockerId)); let { router } = useDataRouterContext(DataRouterHook.UseFetcher); let [blocker, setBlocker] = React.useState(() => @@ -975,16 +982,28 @@ export function useBlocker(shouldBlock: boolean | (() => boolean)) { }) ); + let fn: ShouldBlockFunction = React.useCallback(() => { + if (typeof shouldBlock === "function") { + let result = shouldBlock(); + if (result && typeof result === "object") { + return { + shouldBlock: !!result.shouldBlock, + force: !!result.force, + }; + } + return { shouldBlock: !!result }; + } else { + return { shouldBlock: !!shouldBlock }; + } + }, [shouldBlock]); + React.useEffect(() => { - let blocker = router.createBlocker( - blockerKey, - typeof shouldBlock === "function" ? shouldBlock : () => !!shouldBlock - ); + let blocker = router.createBlocker(blockerKey, fn); setBlocker(blocker); return () => { router.deleteBlocker(blockerKey); }; - }, [blockerKey, shouldBlock, router]); + }, [blockerKey, fn, router]); return blocker; } diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/blocking-test.ts index aa93372b37..8045536fac 100644 --- a/packages/router/__tests__/blocking-test.ts +++ b/packages/router/__tests__/blocking-test.ts @@ -13,7 +13,9 @@ describe("blocking", () => { }); router.initialize(); - let fn = () => true; + let fn = () => ({ + shouldBlock: true, + }); router.createBlocker("KEY", fn); let blocker = router.getBlocker("KEY"); expect(blocker).toEqual({ @@ -34,7 +36,7 @@ describe("blocking", () => { }); router.initialize(); - router.createBlocker("KEY", () => true); + router.createBlocker("KEY", () => ({ shouldBlock: true })); router.deleteBlocker("KEY"); let blocker = router.getBlocker("KEY"); expect(blocker).toBeUndefined(); @@ -56,13 +58,15 @@ describe("blocking", () => { describe("blocker returns false", () => { it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => false); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: false, + })); await router.navigate("/about"); expect(blocker.state).toEqual("unblocked"); }); it("navigates", async () => { - router.createBlocker("KEY", () => false); + router.createBlocker("KEY", () => ({ shouldBlock: false })); await router.navigate("/about"); expect(router.state.location.pathname).toBe("/about"); }); @@ -70,13 +74,15 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state to 'blocked'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about"); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => true); + router.createBlocker("KEY", () => ({ shouldBlock: true })); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -86,7 +92,9 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about"); expect(blocker.state).toEqual("blocked"); @@ -95,7 +103,9 @@ describe("blocking", () => { }); it("proceeds with blocked navigation", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -108,7 +118,9 @@ describe("blocking", () => { describe("resets from blocked state", () => { it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about"); expect(blocker.state).toEqual("blocked"); @@ -117,7 +129,9 @@ describe("blocking", () => { }); it("does not navigate", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -145,13 +159,15 @@ describe("blocking", () => { describe("blocker returns false", () => { it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => false); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: false, + })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("unblocked"); }); it("navigates", async () => { - router.createBlocker("KEY", () => false); + router.createBlocker("KEY", () => ({ shouldBlock: false })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe("/about"); }); @@ -159,13 +175,15 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state to 'blocked'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => true); + router.createBlocker("KEY", () => ({ shouldBlock: true })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -175,7 +193,9 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); @@ -184,7 +204,9 @@ describe("blocking", () => { }); it("proceeds with blocked navigation", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -197,7 +219,9 @@ describe("blocking", () => { describe("resets from blocked state", () => { it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); @@ -206,7 +230,9 @@ describe("blocking", () => { }); it("does not navigate", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -239,13 +265,15 @@ describe("blocking", () => { describe("blocker returns false", () => { it("set blocker state to unblocked", async () => { - let blocker = router.createBlocker("KEY", () => false); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: false, + })); await router.navigate(-1); expect(blocker.state).toEqual("unblocked"); }); it("should navigate", async () => { - router.createBlocker("KEY", () => false); + router.createBlocker("KEY", () => ({ shouldBlock: false })); await router.navigate(-1); expect(router.state.location.pathname).toEqual( initialEntries[initialIndex - 1] @@ -255,13 +283,15 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => true); + router.createBlocker("KEY", () => ({ shouldBlock: true })); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -271,7 +301,9 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); @@ -280,7 +312,9 @@ describe("blocking", () => { }); it("proceeds with blocked navigation", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -297,7 +331,9 @@ describe("blocking", () => { it.todo("patches the history stack"); it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); @@ -306,7 +342,9 @@ describe("blocking", () => { }); it("does not navigate", async () => { - let blocker = router.createBlocker("KEY", () => true); + let blocker = router.createBlocker("KEY", () => ({ + shouldBlock: true, + })); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] From 4a5ce9778b78a822ed85ae3ad6f1a5eaef193e17 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Tue, 20 Dec 2022 19:08:19 -0800 Subject: [PATCH 15/47] usePrompt implementation --- packages/react-router-dom/index.tsx | 67 +++++++++-- packages/router/__tests__/blocking-test.ts | 52 ++++----- packages/router/router.ts | 130 ++++++++++++++++----- 3 files changed, 184 insertions(+), 65 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index e51eda72ed..c13b39d487 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -964,15 +964,15 @@ export function useFormAction( return createPath(path); } -export type ShouldBlockFunction = () => { - shouldBlock: boolean; - force?: boolean; +export type BlockerFunction = () => { + shouldBlock(): boolean; + unstable_skipStateUpdateOnPopNavigation?: boolean; }; let blockerId = 0; export function useBlocker( - shouldBlock: boolean | (() => boolean) | ShouldBlockFunction + shouldBlock: boolean | (() => boolean) | BlockerFunction ) { let [blockerKey] = React.useState(() => String(++blockerId)); let { router } = useDataRouterContext(DataRouterHook.UseFetcher); @@ -982,18 +982,23 @@ export function useBlocker( }) ); - let fn: ShouldBlockFunction = React.useCallback(() => { + let fn: BlockerFunction = React.useCallback(() => { if (typeof shouldBlock === "function") { let result = shouldBlock(); if (result && typeof result === "object") { + let { shouldBlock } = result; return { - shouldBlock: !!result.shouldBlock, - force: !!result.force, + shouldBlock: + typeof shouldBlock === "function" + ? shouldBlock + : () => !!shouldBlock, + unstable_skipStateUpdateOnPopNavigation: + !!result.unstable_skipStateUpdateOnPopNavigation, }; } - return { shouldBlock: !!result }; + return { shouldBlock: () => !!result }; } else { - return { shouldBlock: !!shouldBlock }; + return { shouldBlock: () => !!shouldBlock }; } }, [shouldBlock]); @@ -1008,6 +1013,50 @@ export function useBlocker( return blocker; } +export function usePrompt( + message: string | null | false, + opts?: { beforeUnload: boolean } +) { + let blocker = useBlocker( + React.useCallback(() => { + let shouldPrompt = !!message; + if (!shouldPrompt) { + return { + shouldBlock: () => false, + unstable_skipStateUpdateOnPopNavigation: true, + }; + } + let shouldBlock = () => !window.confirm(message as string); + return { shouldBlock, unstable_skipStateUpdateOnPopNavigation: true }; + }, [message]) + ); + + let prevState = React.useRef(blocker.state); + React.useEffect(() => { + if (blocker.state === "blocked") { + blocker.reset(); + } + prevState.current = blocker.state; + }, [blocker]); + + // leaving the domain + // let { beforeUnload } = opts || {}; + // React.useEffect(() => { + // if (!beforeUnload) return; + // let handleBeforeUnload = (evt: BeforeUnloadEvent) => { + // if (blocker.fn()) { + // evt.preventDefault(); + // evt.returnValue = message; + // return (evt.returnValue = message); + // } + // }; + // window.addEventListener("beforeunload", handleBeforeUnload); + // return () => { + // window.removeEventListener("beforeunload", handleBeforeUnload); + // }; + // }, [blocker, message, beforeUnload]); +} + function createFetcherForm(fetcherKey: string, routeId: string) { let FetcherForm = React.forwardRef( (props, ref) => { diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/blocking-test.ts index 8045536fac..81fbcfddbc 100644 --- a/packages/router/__tests__/blocking-test.ts +++ b/packages/router/__tests__/blocking-test.ts @@ -14,7 +14,7 @@ describe("blocking", () => { router.initialize(); let fn = () => ({ - shouldBlock: true, + shouldBlock: () => true, }); router.createBlocker("KEY", fn); let blocker = router.getBlocker("KEY"); @@ -36,7 +36,7 @@ describe("blocking", () => { }); router.initialize(); - router.createBlocker("KEY", () => ({ shouldBlock: true })); + router.createBlocker("KEY", () => ({ shouldBlock: () => true })); router.deleteBlocker("KEY"); let blocker = router.getBlocker("KEY"); expect(blocker).toBeUndefined(); @@ -59,14 +59,14 @@ describe("blocking", () => { describe("blocker returns false", () => { it("sets blocker state to 'unblocked'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: false, + shouldBlock: () => false, })); await router.navigate("/about"); expect(blocker.state).toEqual("unblocked"); }); it("navigates", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: false })); + router.createBlocker("KEY", () => ({ shouldBlock: () => false })); await router.navigate("/about"); expect(router.state.location.pathname).toBe("/about"); }); @@ -75,14 +75,14 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state to 'blocked'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about"); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: true })); + router.createBlocker("KEY", () => ({ shouldBlock: () => true })); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -93,7 +93,7 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about"); expect(blocker.state).toEqual("blocked"); @@ -104,7 +104,7 @@ describe("blocking", () => { it("proceeds with blocked navigation", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about"); expect(router.state.location.pathname).toBe( @@ -119,7 +119,7 @@ describe("blocking", () => { describe("resets from blocked state", () => { it("sets blocker state to 'unblocked'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about"); expect(blocker.state).toEqual("blocked"); @@ -130,7 +130,7 @@ describe("blocking", () => { it("does not navigate", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about"); expect(router.state.location.pathname).toBe( @@ -160,14 +160,14 @@ describe("blocking", () => { describe("blocker returns false", () => { it("sets blocker state to 'unblocked'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: false, + shouldBlock: () => false, })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("unblocked"); }); it("navigates", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: false })); + router.createBlocker("KEY", () => ({ shouldBlock: () => false })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe("/about"); }); @@ -176,14 +176,14 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state to 'blocked'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: true })); + router.createBlocker("KEY", () => ({ shouldBlock: () => true })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -194,7 +194,7 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); @@ -205,7 +205,7 @@ describe("blocking", () => { it("proceeds with blocked navigation", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( @@ -220,7 +220,7 @@ describe("blocking", () => { describe("resets from blocked state", () => { it("sets blocker state to 'unblocked'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); @@ -231,7 +231,7 @@ describe("blocking", () => { it("does not navigate", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( @@ -266,14 +266,14 @@ describe("blocking", () => { describe("blocker returns false", () => { it("set blocker state to unblocked", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: false, + shouldBlock: () => false, })); await router.navigate(-1); expect(blocker.state).toEqual("unblocked"); }); it("should navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: false })); + router.createBlocker("KEY", () => ({ shouldBlock: () => false })); await router.navigate(-1); expect(router.state.location.pathname).toEqual( initialEntries[initialIndex - 1] @@ -284,14 +284,14 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: true })); + router.createBlocker("KEY", () => ({ shouldBlock: () => true })); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -302,7 +302,7 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); @@ -313,7 +313,7 @@ describe("blocking", () => { it("proceeds with blocked navigation", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate(-1); expect(router.state.location.pathname).toBe( @@ -332,7 +332,7 @@ describe("blocking", () => { it("sets blocker state to 'unblocked'", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); @@ -343,7 +343,7 @@ describe("blocking", () => { it("does not navigate", async () => { let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: true, + shouldBlock: () => true, })); await router.navigate(-1); expect(router.state.location.pathname).toBe( diff --git a/packages/router/router.ts b/packages/router/router.ts index 2faeabbac3..b54956e81b 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -197,14 +197,14 @@ export interface Router { * * @param key A unique identifier for the blocker */ - createBlocker(key: string, fn: ShouldBlockFunction): Blocker; + createBlocker(key: string, fn: BlockerFunction): Blocker; /** * Get a navigation blocker * * @param key The identifier for the blocker */ - getBlocker(key: string, fn?: ShouldBlockFunction): Blocker | undefined; + getBlocker(key: string, fn?: BlockerFunction): Blocker | undefined; /** * Delete a navigation blocker @@ -521,16 +521,29 @@ export type Fetcher = FetcherStates[keyof FetcherStates]; export type Blocker = - | { state: "blocked"; reset(): void; proceed(): Promise; fn(): boolean } - | { state: "unblocked"; reset: undefined; proceed: undefined; fn(): boolean } + | { + state: "blocked"; + reset(): void; + proceed(): Promise; + fn: BlockerFunction; + } + | { + state: "unblocked"; + reset: undefined; + proceed: undefined; + fn: BlockerFunction; + } | { state: "proceeding"; reset: undefined; proceed: undefined; - fn(): boolean; + fn: BlockerFunction; }; -export type ShouldBlockFunction = () => boolean; +export type BlockerFunction = () => { + shouldBlock(): boolean; + unstable_skipStateUpdateOnPopNavigation?: boolean; +}; interface ShortCircuitable { /** @@ -766,30 +779,78 @@ export function createRouter(init: RouterInit): Router { // Initialize the router, all side effects should be kicked off from here. // Implemented as a Fluent API for ease of: // let router = createRouter(init).initialize(); + + let blocked = new Map(); + function initialize() { // If history informs us of a POP navigation, start the navigation but do not update // state. We'll update our own state once the navigation completes unlistenHistory = init.history.listen( ({ action: historyAction, location, delta }) => { for (let [key, blocker] of state.blockers) { + // So this is a bit tricky to follow if navigation is blocked, so + // let's try to walk through what's happening line-by-line: + // + // If a POP navigation occurs while a navigation is blocked, we do + // nothing until the user either proceeds or resets. History will + // update our delta so that we can patch up the stack once navigation + // is unblocked. if (blocker.state === "blocked") { return; } - if (blocker.state === "unblocked" && blocker.fn()) { - init.history.go(delta); - setBlockerState(key, "blocked", { - async onProceed() { - init.history.go(delta * -1); - }, - onReset() { - // noop, we've already blocked and setting state back to - // 'unblocked' in `setBlockerState` - }, - }); - return; + // If we are in an unblocked state, it's either because: + // 1. Navigation was never blocked + // 2. Navigation was blocked but we never updated the router state. + // When a blocker is registered with a prompt (window.confirm) we + // never actually update the blocker state in response to POP + // navigations -- we either immediately navigate when the user + // accepts or revert the URL if they don't. + if (blocker.state === "unblocked") { + let { + shouldBlock, + unstable_skipStateUpdateOnPopNavigation: skipStateUpdate, + } = blocker.fn(); + + // If we've already blocked this navigation attempt but our router + // state was never updated, we do nothing until the user either + // proceeds or resets. We don't need to evaluate our shouldBlock + // function again (if we do, window.confirm could trigger a second + // popup). + if (blocked.get(key)) { + blocked.delete(key); + return; + } + + // At this point we are unblocked and we need to evaluate whether or + // not this navigation should be blocked... + if (shouldBlock()) { + // We can revert the URL with history.go(delta), but that will + // trigger our listener again so we need to mark this blocker's + // navigation as blocked so we don't here the next time around. + blocked.set(key, true); + init.history.go(delta); + + // We only update router state if the blocker didn't opt out (as + // noted above, this is primarily for window.confirm cases and + // probably shouldn't be used in user code) + if (!skipStateUpdate) { + setBlockerState(key, "blocked", { + async onProceed() { + init.history.go(delta * -1); + }, + onReset() { + // noop, we've already blocked and state will be updated to + // `unblocked` + }, + }); + } + return; + } } } + + // No blockers so we are GOOD TO GO 🎉🚀 return startNavigation(historyAction, location); } ); @@ -899,7 +960,7 @@ export function createRouter(init: RouterInit): Router { cancelledFetcherLoads = []; } - function createBlocker(key: string, fn: ShouldBlockFunction) { + function createBlocker(key: string, fn: BlockerFunction) { if (state.blockers.has(key)) { return state.blockers.get(key)!; } @@ -914,7 +975,7 @@ export function createRouter(init: RouterInit): Router { return blocker; } - function getBlocker(key: string, fn?: ShouldBlockFunction) { + function getBlocker(key: string, fn?: BlockerFunction) { let blocker = state.blockers.get(key); if (!blocker) return; @@ -953,9 +1014,11 @@ export function createRouter(init: RouterInit): Router { `Invalid blocker state: ${nextState}` ); - if (blocker.state === nextState) { - return blocker; - } + // If the blocker state changes we need to update the router state to notify + // subscribers. If not we can skit the update, but we still need to assign + // new references if a blocker callback or proceed/reset function was + // passed. + let stateChanged = blocker.state !== nextState; blocker.state = nextState; if (nextState === "blocked") { @@ -974,7 +1037,9 @@ export function createRouter(init: RouterInit): Router { } state.blockers.set(key, blocker); - updateState({ blockers: new Map(state.blockers) }); + if (stateChanged) { + updateState({ blockers: new Map(state.blockers) }); + } return blocker; } @@ -985,11 +1050,16 @@ export function createRouter(init: RouterInit): Router { opts?: RouterNavigateOptions ): Promise { for (let [key, blocker] of state.blockers) { - if (blocker.state !== "proceeding" && blocker.fn()) { - setBlockerState(key, "blocked", { - onProceed: () => navigate(to, opts), - }); - return; + if (blocker.state === "blocked" || blocker.state === "unblocked") { + let { shouldBlock } = blocker.fn(); + if (shouldBlock()) { + setBlockerState(key, "blocked", { + onProceed: () => navigate(to, opts), + }); + return; + } else if (blocker.state === "blocked") { + return blocker.proceed(); + } } } @@ -3447,7 +3517,7 @@ function getTargetMatch( } export function getInitialBlocker( - fn: ShouldBlockFunction + fn: BlockerFunction ): Blocker & { state: "unblocked" } { return { state: "unblocked", From 70c4e862685e631b80acaa44ee6400c03838bc69 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 21 Dec 2022 13:56:11 -0800 Subject: [PATCH 16/47] fix subtle bugs when navigating to the same route --- packages/react-router-dom/index.tsx | 15 ++++++----- packages/router/router.ts | 41 ++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index c13b39d487..7816a7535b 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1017,17 +1017,22 @@ export function usePrompt( message: string | null | false, opts?: { beforeUnload: boolean } ) { + // let { beforeUnload } = opts ?? {}; let blocker = useBlocker( React.useCallback(() => { let shouldPrompt = !!message; + let unstable_skipStateUpdateOnPopNavigation = true; if (!shouldPrompt) { return { shouldBlock: () => false, - unstable_skipStateUpdateOnPopNavigation: true, + unstable_skipStateUpdateOnPopNavigation, }; } let shouldBlock = () => !window.confirm(message as string); - return { shouldBlock, unstable_skipStateUpdateOnPopNavigation: true }; + return { + shouldBlock, + unstable_skipStateUpdateOnPopNavigation, + }; }, [message]) ); @@ -1040,13 +1045,11 @@ export function usePrompt( }, [blocker]); // leaving the domain - // let { beforeUnload } = opts || {}; // React.useEffect(() => { // if (!beforeUnload) return; // let handleBeforeUnload = (evt: BeforeUnloadEvent) => { - // if (blocker.fn()) { - // evt.preventDefault(); - // evt.returnValue = message; + // let { shouldBlock } = blocker.fn(); + // if (shouldBlock()) { // return (evt.returnValue = message); // } // }; diff --git a/packages/router/router.ts b/packages/router/router.ts index b54956e81b..7e8a9a4668 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -788,30 +788,42 @@ export function createRouter(init: RouterInit): Router { unlistenHistory = init.history.listen( ({ action: historyAction, location, delta }) => { for (let [key, blocker] of state.blockers) { + let { + shouldBlock, + unstable_skipStateUpdateOnPopNavigation: skipStateUpdate, + } = blocker.fn(); + // So this is a bit tricky to follow if navigation is blocked, so // let's try to walk through what's happening line-by-line: // - // If a POP navigation occurs while a navigation is blocked, we do - // nothing until the user either proceeds or resets. History will - // update our delta so that we can patch up the stack once navigation - // is unblocked. + // If a POP navigation occurs while a navigation is blocked, one of + // two things is happening: + // 1. Navigation was blocked and our URL is out-of-sync with our + // router state. In this case we go back by the delta. This + // triggers our listener again and then... + // 2. Navigation state is still blocked, so we update our little + // state tracker and bail. The navigation blocker state is now + // blocked until the user proceeds or resets. if (blocker.state === "blocked") { + if (blocked.get(key)) { + blocked.delete(key); + } else { + blocked.set(key, true); + init.history.go(delta); + } return; } // If we are in an unblocked state, it's either because: // 1. Navigation was never blocked - // 2. Navigation was blocked but we never updated the router state. + // 2. Navigation was blocked but we haven't yet updated the router + // state. + // // When a blocker is registered with a prompt (window.confirm) we // never actually update the blocker state in response to POP // navigations -- we either immediately navigate when the user // accepts or revert the URL if they don't. if (blocker.state === "unblocked") { - let { - shouldBlock, - unstable_skipStateUpdateOnPopNavigation: skipStateUpdate, - } = blocker.fn(); - // If we've already blocked this navigation attempt but our router // state was never updated, we do nothing until the user either // proceeds or resets. We don't need to evaluate our shouldBlock @@ -922,6 +934,14 @@ export function createRouter(init: RouterInit): Router { } : {}; + let blockers = state.blockers; + for (let [key, blocker] of blockers) { + blocker.state = "unblocked"; + blocker.proceed = undefined; + blocker.reset = undefined; + state.blockers.set(key, blocker); + } + updateState({ // Clear existing actionData on any completed navigation beyond the original // action, unless we're currently finishing the loading/actionReload state. @@ -939,6 +959,7 @@ export function createRouter(init: RouterInit): Router { ? false : getSavedScrollPosition(location, newState.matches || state.matches), preventScrollReset: pendingPreventScrollReset, + blockers: new Map(state.blockers), }); if (isUninterruptedRevalidation) { From f2ff19394f120a5d2c06c077973f80220d687291 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 21 Dec 2022 14:01:18 -0800 Subject: [PATCH 17/47] implement beforeunload handler --- packages/react-router-dom/index.tsx | 37 +++++++++++++++++------------ 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 7816a7535b..63a3671155 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1017,7 +1017,7 @@ export function usePrompt( message: string | null | false, opts?: { beforeUnload: boolean } ) { - // let { beforeUnload } = opts ?? {}; + let { beforeUnload } = opts ?? {}; let blocker = useBlocker( React.useCallback(() => { let shouldPrompt = !!message; @@ -1044,20 +1044,27 @@ export function usePrompt( prevState.current = blocker.state; }, [blocker]); - // leaving the domain - // React.useEffect(() => { - // if (!beforeUnload) return; - // let handleBeforeUnload = (evt: BeforeUnloadEvent) => { - // let { shouldBlock } = blocker.fn(); - // if (shouldBlock()) { - // return (evt.returnValue = message); - // } - // }; - // window.addEventListener("beforeunload", handleBeforeUnload); - // return () => { - // window.removeEventListener("beforeunload", handleBeforeUnload); - // }; - // }, [blocker, message, beforeUnload]); + // 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) => { + let { shouldBlock } = blocker.fn(); + if (shouldBlock()) { + return (evt.returnValue = message); + } + }; + window.addEventListener("beforeunload", handleBeforeUnload, { + capture: true, + }); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload, { + capture: true, + }); + }; + }, [blocker, message, beforeUnload]); } function createFetcherForm(fetcherKey: string, routeId: string) { From 6823f58a8ef4e525b27a8b5a94caab869f50a4aa Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 21 Dec 2022 14:41:06 -0800 Subject: [PATCH 18/47] patch fix for async tests --- examples/data-router/src/routes.tsx | 9 ++++++- packages/router/__tests__/blocking-test.ts | 28 +++++++++++++++------- packages/router/router.ts | 14 +++++++++-- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/examples/data-router/src/routes.tsx b/examples/data-router/src/routes.tsx index b8e3cde5bf..a7240b9b7e 100644 --- a/examples/data-router/src/routes.tsx +++ b/examples/data-router/src/routes.tsx @@ -18,6 +18,7 @@ import { json, useActionData, useBlocker, + usePrompt, } from "react-router-dom"; import type { Todos } from "./todos"; @@ -94,7 +95,13 @@ export async function homeLoader(): Promise { export function Home() { let data = useLoaderData() as HomeLoaderData; let [shouldBlockNavigation, setShouldBlockNavigation] = React.useState(false); - let blocker = useBlocker(shouldBlockNavigation); + + let blocker = useBlocker(shouldBlockNavigation ? true : null); + // usePrompt( + // shouldBlockNavigation ? "Are you *really* sure you want to leave?" : null, + // { beforeUnload: true } + // ); + return ( <>

Home

diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/blocking-test.ts index 81fbcfddbc..bbd6b8ec13 100644 --- a/packages/router/__tests__/blocking-test.ts +++ b/packages/router/__tests__/blocking-test.ts @@ -1,3 +1,6 @@ +/* eslint-disable jest/no-focused-tests */ +/* eslint-disable jest/no-done-callback */ +/* eslint-disable jest/no-disabled-tests */ import type { Router } from "../index"; import { createMemoryHistory, createRouter } from "../index"; @@ -91,14 +94,17 @@ describe("blocking", () => { }); describe("proceeds from blocked state", () => { - it("sets blocker state to 'proceeding'", async () => { + it("sets blocker state to 'proceeding'", async (done) => { let blocker = router.createBlocker("KEY", () => ({ shouldBlock: () => true, })); await router.navigate("/about"); expect(blocker.state).toEqual("blocked"); - await blocker.proceed?.(); + blocker.proceed?.().then(() => { + expect(blocker.state).toEqual("unblocked"); + done(); + }); expect(blocker.state).toEqual("proceeding"); }); @@ -111,7 +117,7 @@ describe("blocking", () => { initialEntries[initialIndex] ); - blocker.proceed?.(); + await blocker.proceed?.(); expect(router.state.location.pathname).toEqual("/about"); }); }); @@ -192,14 +198,17 @@ describe("blocking", () => { }); describe("proceeds from blocked state", () => { - it("sets blocker state to 'proceeding'", async () => { + it("sets blocker state to 'proceeding'", async (done) => { let blocker = router.createBlocker("KEY", () => ({ shouldBlock: () => true, })); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); - await blocker.proceed?.(); + blocker.proceed?.().then(() => { + expect(blocker.state).toEqual("unblocked"); + done(); + }); expect(blocker.state).toEqual("proceeding"); }); @@ -212,7 +221,7 @@ describe("blocking", () => { initialEntries[initialIndex] ); - blocker.proceed?.(); + await blocker.proceed?.(); expect(router.state.location.pathname).toEqual("/about"); }); }); @@ -300,14 +309,17 @@ describe("blocking", () => { }); describe("proceeds from blocked state", () => { - it("sets blocker state to 'proceeding'", async () => { + it("sets blocker state to 'proceeding'", async (done) => { let blocker = router.createBlocker("KEY", () => ({ shouldBlock: () => true, })); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); - await blocker.proceed?.(); + blocker.proceed?.().then(() => { + expect(blocker.state).toEqual("unblocked"); + done(); + }); expect(blocker.state).toEqual("proceeding"); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 7e8a9a4668..c5676d6402 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -1075,11 +1075,17 @@ export function createRouter(init: RouterInit): Router { let { shouldBlock } = blocker.fn(); if (shouldBlock()) { setBlockerState(key, "blocked", { - onProceed: () => navigate(to, opts), + async onProceed() { + // TODO: Tests fail if we don't wait a tick. Unsure why since + // navigate has its own async work to complete before blocker + // state is set. Investigate. + await Promise.resolve(); + await navigate(to, opts); + }, }); return; } else if (blocker.state === "blocked") { - return blocker.proceed(); + return await blocker.proceed(); } } } @@ -3548,3 +3554,7 @@ export function getInitialBlocker( }; } //#endregion + +async function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} From f56ab7d72ef3884ab1413b46191b5618fc433848 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 22 Dec 2022 17:38:59 -0500 Subject: [PATCH 19/47] Fix history index issue and wire up singleton blocking --- examples/data-router/src/routes.tsx | 10 +- packages/react-router-dom/index.tsx | 107 ++++---- packages/react-router-dom/server.tsx | 13 +- packages/router/history.ts | 3 +- packages/router/router.ts | 376 +++++++++------------------ 5 files changed, 180 insertions(+), 329 deletions(-) diff --git a/examples/data-router/src/routes.tsx b/examples/data-router/src/routes.tsx index a7240b9b7e..0f5427ce47 100644 --- a/examples/data-router/src/routes.tsx +++ b/examples/data-router/src/routes.tsx @@ -96,11 +96,11 @@ export function Home() { let data = useLoaderData() as HomeLoaderData; let [shouldBlockNavigation, setShouldBlockNavigation] = React.useState(false); - let blocker = useBlocker(shouldBlockNavigation ? true : null); - // usePrompt( - // shouldBlockNavigation ? "Are you *really* sure you want to leave?" : null, - // { beforeUnload: true } - // ); + let blocker = useBlocker(shouldBlockNavigation); + // usePrompt( + // shouldBlockNavigation ? "Are you *really* sure you want to leave?" : null, + // { beforeUnload: true } + // ); return ( <> diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index a12abbf166..8799770fde 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -25,8 +25,8 @@ import { UNSAFE_enhanceManualRouteObjects as enhanceManualRouteObjects, } from "react-router"; import type { + BlockerFunction, BrowserHistory, - Blocker, Fetcher, FormEncType, FormMethod, @@ -40,7 +40,6 @@ import { createRouter, createBrowserHistory, createHashHistory, - getInitialBlocker, invariant, joinPaths, ErrorResponse, @@ -978,51 +977,23 @@ export function useFormAction( return createPath(path); } -export type BlockerFunction = () => { - shouldBlock(): boolean; - unstable_skipStateUpdateOnPopNavigation?: boolean; -}; - -let blockerId = 0; +let blockerKey = "blocker-singleton"; export function useBlocker( shouldBlock: boolean | (() => boolean) | BlockerFunction ) { - let [blockerKey] = React.useState(() => String(++blockerId)); let { router } = useDataRouterContext(DataRouterHook.UseFetcher); - let [blocker, setBlocker] = React.useState(() => - getInitialBlocker(() => { - throw Error("Navigation should not occur during render."); - }) - ); - let fn: BlockerFunction = React.useCallback(() => { - if (typeof shouldBlock === "function") { - let result = shouldBlock(); - if (result && typeof result === "object") { - let { shouldBlock } = result; - return { - shouldBlock: - typeof shouldBlock === "function" - ? shouldBlock - : () => !!shouldBlock, - unstable_skipStateUpdateOnPopNavigation: - !!result.unstable_skipStateUpdateOnPopNavigation, - }; - } - return { shouldBlock: () => !!result }; - } else { - return { shouldBlock: () => !!shouldBlock }; - } + let blockerFunction = React.useCallback(() => { + return typeof shouldBlock === "function" + ? shouldBlock() === true + : shouldBlock === true; }, [shouldBlock]); - React.useEffect(() => { - let blocker = router.createBlocker(blockerKey, fn); - setBlocker(blocker); - return () => { - router.deleteBlocker(blockerKey); - }; - }, [blockerKey, fn, router]); + let blocker = router.getBlocker(blockerKey, blockerFunction); + + // Cleanup on unmount + React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]); return blocker; } @@ -1031,24 +1002,38 @@ export function usePrompt( message: string | null | false, opts?: { beforeUnload: boolean } ) { - let { beforeUnload } = opts ?? {}; - let blocker = useBlocker( - React.useCallback(() => { - let shouldPrompt = !!message; - let unstable_skipStateUpdateOnPopNavigation = true; - if (!shouldPrompt) { - return { - shouldBlock: () => false, - unstable_skipStateUpdateOnPopNavigation, - }; - } - let shouldBlock = () => !window.confirm(message as string); - return { - shouldBlock, - unstable_skipStateUpdateOnPopNavigation, - }; - }, [message]) - ); + let { beforeUnload } = opts ? opts : { beforeUnload: false }; + let blockerFunction = React.useCallback(() => { + 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: + // - 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(() => { @@ -1065,9 +1050,9 @@ export function usePrompt( React.useEffect(() => { if (!beforeUnload) return; let handleBeforeUnload = (evt: BeforeUnloadEvent) => { - let { shouldBlock } = blocker.fn(); - if (shouldBlock()) { - return (evt.returnValue = message); + if (blockerFunction()) { + evt.returnValue = message; + return message; } }; window.addEventListener("beforeunload", handleBeforeUnload, { @@ -1078,7 +1063,7 @@ export function usePrompt( capture: true, }); }; - }, [blocker, message, beforeUnload]); + }, [blocker, message, beforeUnload, blockerFunction]); } function createFetcherForm(fetcherKey: string, routeId: string) { diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index d50f3248fe..61dfd9e781 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -13,7 +13,6 @@ import { invariant, isRouteErrorResponse, UNSAFE_convertRoutesToDataRoutes as convertRoutesToDataRoutes, - getInitialBlocker, } from "@remix-run/router"; import type { DataRouteObject, @@ -304,21 +303,11 @@ export function createStaticRouter( throw msg("dispose"); }, getBlocker() { - return getInitialBlocker(() => { - throw msg("getBlocker"); - }); + throw msg("getBlocker"); }, deleteBlocker() { throw msg("deleteBlocker"); }, - createBlocker() { - return getInitialBlocker(() => { - throw msg("createBlocker"); - }); - }, - setBlockerState() { - throw msg("setBlockerState"); - }, _internalFetchControllers: new Map(), _internalActiveDeferreds: new Map(), }; diff --git a/packages/router/history.ts b/packages/router/history.ts index 374870941f..c2c36a3565 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -619,9 +619,10 @@ function getUrlBasedHistory( let nextIndex = getIndex(); if (nextIndex != null) { - let delta = index - nextIndex; + let delta = nextIndex - index; action = nextAction; if (listener) { + index = nextIndex; listener({ action, location: history.location, delta }); } } else { diff --git a/packages/router/router.ts b/packages/router/router.ts index 4086082cc4..ef5cd9c1ab 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -192,19 +192,13 @@ export interface Router { */ dispose(): void; - /** - * Create a new navigation blocker - * - * @param key A unique identifier for the blocker - */ - createBlocker(key: string, fn: BlockerFunction): Blocker; - /** * Get a navigation blocker * * @param key The identifier for the blocker + * @param fn The blocker function implementation */ - getBlocker(key: string, fn?: BlockerFunction): Blocker | undefined; + getBlocker(key: string, fn: BlockerFunction): Blocker; /** * Delete a navigation blocker @@ -213,37 +207,6 @@ export interface Router { */ deleteBlocker(key: string): void; - /** - * @internal - * PRIVATE - DO NOT USE - * - * Update the state of a navigation blocker - * - * @param key The identifier for the blocker - * @param state The next state. Must be one of `"blocked"`, `"unblocked"`, or - * "proceeding" - */ - setBlockerState( - key: string, - state: "blocked", - opts: SetBlockerOpts - ): Blocker | undefined; - - /** - * @internal - * PRIVATE - DO NOT USE - * - * Update the state of a navigation blocker - * - * @param key The identifier for the blocker - * @param state The next state. Must be one of `"blocked"`, `"unblocked"`, or - * "proceeding" - */ - setBlockerState( - key: string, - state: "unblocked" | "proceeding" - ): Blocker | undefined; - /** * @internal * PRIVATE - DO NOT USE @@ -261,11 +224,6 @@ export interface Router { _internalActiveDeferreds: Map; } -interface SetBlockerOpts { - onProceed?(): Promise; - onReset?(): void; -} - /** * State maintained internally by the router. During a navigation, all states * reflect the the "old" location unless otherwise noted. @@ -527,26 +485,20 @@ export type Blocker = | { state: "blocked"; reset(): void; - proceed(): Promise; - fn: BlockerFunction; + proceed(): void; } | { state: "unblocked"; reset: undefined; proceed: undefined; - fn: BlockerFunction; } | { state: "proceeding"; reset: undefined; proceed: undefined; - fn: BlockerFunction; }; -export type BlockerFunction = () => { - shouldBlock(): boolean; - unstable_skipStateUpdateOnPopNavigation?: boolean; -}; +export type BlockerFunction = () => boolean; interface ShortCircuitable { /** @@ -649,6 +601,12 @@ export const IDLE_FETCHER: FetcherStates["Idle"] = { formData: undefined, }; +export const IDLE_BLOCKER: Blocker = { + state: "unblocked", + proceed: undefined, + reset: undefined, +}; + const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined" && @@ -782,93 +740,55 @@ export function createRouter(init: RouterInit): Router { // cancel active deferreds for eliminated routes. let activeDeferreds = new Map(); + // Store blocker functions in a separate Map outside of router state since + // we don't need to update UI state if they change + let blockerFunctions = new Map(); + + // Flag to ignore the next history update, so we can revert the URL change on + // a POP navigation that was blocked by the user without touching router state + let ignoreNextHistoryUpdate = false; + // Initialize the router, all side effects should be kicked off from here. // Implemented as a Fluent API for ease of: // let router = createRouter(init).initialize(); - - let blocked = new Map(); - function initialize() { // If history informs us of a POP navigation, start the navigation but do not update // state. We'll update our own state once the navigation completes unlistenHistory = init.history.listen( ({ action: historyAction, location, delta }) => { - for (let [key, blocker] of state.blockers) { - let { - shouldBlock, - unstable_skipStateUpdateOnPopNavigation: skipStateUpdate, - } = blocker.fn(); - - // So this is a bit tricky to follow if navigation is blocked, so - // let's try to walk through what's happening line-by-line: - // - // If a POP navigation occurs while a navigation is blocked, one of - // two things is happening: - // 1. Navigation was blocked and our URL is out-of-sync with our - // router state. In this case we go back by the delta. This - // triggers our listener again and then... - // 2. Navigation state is still blocked, so we update our little - // state tracker and bail. The navigation blocker state is now - // blocked until the user proceeds or resets. - if (blocker.state === "blocked") { - if (blocked.get(key)) { - blocked.delete(key); - } else { - blocked.set(key, true); - init.history.go(delta); - } - return; - } - - // If we are in an unblocked state, it's either because: - // 1. Navigation was never blocked - // 2. Navigation was blocked but we haven't yet updated the router - // state. - // - // When a blocker is registered with a prompt (window.confirm) we - // never actually update the blocker state in response to POP - // navigations -- we either immediately navigate when the user - // accepts or revert the URL if they don't. - if (blocker.state === "unblocked") { - // If we've already blocked this navigation attempt but our router - // state was never updated, we do nothing until the user either - // proceeds or resets. We don't need to evaluate our shouldBlock - // function again (if we do, window.confirm could trigger a second - // popup). - if (blocked.get(key)) { - blocked.delete(key); - return; - } - - // At this point we are unblocked and we need to evaluate whether or - // not this navigation should be blocked... - if (shouldBlock()) { - // We can revert the URL with history.go(delta), but that will - // trigger our listener again so we need to mark this blocker's - // navigation as blocked so we don't here the next time around. - blocked.set(key, true); - init.history.go(delta); + // Ignore this event if it was just us resetting the URL from a + // blocked POP navigation + if (ignoreNextHistoryUpdate) { + ignoreNextHistoryUpdate = false; + return; + } - // We only update router state if the blocker didn't opt out (as - // noted above, this is primarily for window.confirm cases and - // probably shouldn't be used in user code) - if (!skipStateUpdate) { - setBlockerState(key, "blocked", { - async onProceed() { - init.history.go(delta * -1); - }, - onReset() { - // noop, we've already blocked and state will be updated to - // `unblocked` - }, - }); - } - return; - } - } + let blockerKey = shouldBlockNavigation(); + if (blockerKey) { + // Restore the URL to match the current UI, but don't update router state + ignoreNextHistoryUpdate = true; + init.history.go(delta * -1); + + // Put the blocker into a blocked state + updateBlocker(blockerKey, { + state: "blocked", + proceed() { + updateBlocker(blockerKey!, { + state: "proceeding", + proceed: undefined, + reset: undefined, + }); + // Re-do the same POP navigation we just blocked + init.history.go(delta); + }, + reset() { + deleteBlocker(blockerKey!); + updateState({ blockers: new Map(router.state.blockers) }); + }, + }); + return; } - // No blockers so we are GOOD TO GO 🎉🚀 return startNavigation(historyAction, location); } ); @@ -955,12 +875,10 @@ export function createRouter(init: RouterInit): Router { ) : state.loaderData; - let blockers = state.blockers; - for (let [key, blocker] of blockers) { - blocker.state = "unblocked"; - blocker.proceed = undefined; - blocker.reset = undefined; - state.blockers.set(key, blocker); + // Onl a successful navigation we can assume we got through all blockers + // so twe can start fresh + for (let [key] of blockerFunctions) { + deleteBlocker(key); } updateState({ @@ -999,113 +917,32 @@ export function createRouter(init: RouterInit): Router { cancelledFetcherLoads = []; } - function createBlocker(key: string, fn: BlockerFunction) { - if (state.blockers.has(key)) { - return state.blockers.get(key)!; - } - - let blocker: Blocker = { - state: "unblocked", - proceed: undefined, - reset: undefined, - fn, - }; - state.blockers.set(key, blocker); - return blocker; - } - - function getBlocker(key: string, fn?: BlockerFunction) { - let blocker = state.blockers.get(key); - if (!blocker) return; - - if (fn) { - if (typeof fn === "function") { - blocker.fn = fn; - } else { - warning( - false, - `A blocker function update was requested with a value that is not a function. This is not allowed, and the blocker's function will not be updated.` - ); - } - } - - return blocker; - } - - function deleteBlocker(key: string) { - state.blockers.delete(key); - } - - function setBlockerState( - key: string, - nextState: Blocker["state"], - opts?: SetBlockerOpts - ) { - let blocker = state.blockers.get(key); - if (!blocker) { - return; - } - - invariant( - nextState === "proceeding" || - nextState === "blocked" || - nextState === "unblocked", - `Invalid blocker state: ${nextState}` - ); - - // If the blocker state changes we need to update the router state to notify - // subscribers. If not we can skit the update, but we still need to assign - // new references if a blocker callback or proceed/reset function was - // passed. - let stateChanged = blocker.state !== nextState; - - blocker.state = nextState; - if (nextState === "blocked") { - let { onProceed, onReset } = opts || {}; - blocker.proceed = async () => { - setBlockerState(key, "proceeding"); - await onProceed?.(); - }; - blocker.reset = () => { - setBlockerState(key, "unblocked"); - onReset?.(); - }; - } else { - blocker.proceed = undefined; - blocker.reset = undefined; - } - - state.blockers.set(key, blocker); - if (stateChanged) { - updateState({ blockers: new Map(state.blockers) }); - } - return blocker; - } - // Trigger a navigation event, which can either be a numerical POP or a PUSH // replace with an optional submission async function navigate( to: number | To, opts?: RouterNavigateOptions ): Promise { - for (let [key, blocker] of state.blockers) { - if (blocker.state === "blocked" || blocker.state === "unblocked") { - let { shouldBlock } = blocker.fn(); - if (shouldBlock()) { - setBlockerState(key, "blocked", { - async onProceed() { - // TODO: Tests fail if we don't wait a tick. Unsure why since - // navigate has its own async work to complete before blocker - // state is set. Investigate. - await Promise.resolve(); - await navigate(to, opts); - }, + let blockerKey = shouldBlockNavigation(); + if (blockerKey) { + // Put the blocker into a blocked state + updateBlocker(blockerKey, { + state: "blocked", + proceed() { + updateBlocker(blockerKey!, { + state: "proceeding", + proceed: undefined, + reset: undefined, }); - return; - } else if (blocker.state === "blocked") { - return await blocker.proceed(); - } - } + // Send the same navigation through + navigate(to, opts); + }, + reset() { + deleteBlocker(blockerKey!); + updateState({ blockers: new Map(state.blockers) }); + }, + }); + return; } if (typeof to === "number") { @@ -2190,6 +2027,61 @@ export function createRouter(init: RouterInit): Router { return yeetedKeys.length > 0; } + function getBlocker(key: string, fn: BlockerFunction) { + let blocker: Blocker = state.blockers.get(key) || IDLE_BLOCKER; + + if (blockerFunctions.get(key) !== fn) { + blockerFunctions.set(key, fn); + } + + return blocker; + } + + function deleteBlocker(key: string) { + state.blockers.delete(key); + blockerFunctions.delete(key); + } + + // Utility function to update blockers, ensuring valid state transitions + function updateBlocker(key: string, newBlocker: Blocker) { + let blocker = state.blockers.get(key) || IDLE_BLOCKER; + + invariant( + (blocker.state === "unblocked" && newBlocker.state === "blocked") || + (blocker.state === "blocked" && newBlocker.state === "blocked") || + (blocker.state === "blocked" && newBlocker.state === "proceeding") || + (blocker.state === "blocked" && newBlocker.state === "unblocked") || + (blocker.state === "proceeding" && newBlocker.state === "unblocked"), + `Invalid blocker state transition: ${blocker.state} -> ${newBlocker.state}` + ); + + state.blockers.set(key, newBlocker); + updateState({ blockers: new Map(state.blockers) }); + } + + function shouldBlockNavigation(): string | undefined { + if (blockerFunctions.size === 0) { + return; + } + + // We only allow a single blocker at the moment. This will need to be + // updated if we enhance to support multiple blockers in the future + let [key, blockerFunction] = Array.from(blockerFunctions.entries())[0]; + let blocker = state.blockers.get(key); + + if (blocker && blocker.state === "proceeding") { + // If the blocker is currently proceeding, we don't need to re-check + // it and can let this navigation continue + return; + } + + // At this point, we know we're unblocked/blocked so we need to check the + // user-provided blocker function + if (blockerFunction()) { + return key; + } + } + function cancelActiveDeferreds( predicate?: (routeId: string) => boolean ): string[] { @@ -2289,9 +2181,7 @@ export function createRouter(init: RouterInit): Router { getFetcher, deleteFetcher, dispose, - createBlocker, getBlocker, - setBlockerState, deleteBlocker, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, @@ -3657,18 +3547,4 @@ function getTargetMatch( return pathMatches[pathMatches.length - 1]; } -export function getInitialBlocker( - fn: BlockerFunction -): Blocker & { state: "unblocked" } { - return { - state: "unblocked", - fn, - proceed: undefined, - reset: undefined, - }; -} //#endregion - -async function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} From 5a81d17fe4a54b112cd485205ea889dae54de8ca Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Mon, 2 Jan 2023 12:12:14 -0800 Subject: [PATCH 20/47] fix some blocking tests --- packages/router/__tests__/blocking-test.ts | 149 +++++++-------------- 1 file changed, 52 insertions(+), 97 deletions(-) diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/blocking-test.ts index bbd6b8ec13..5fb34d1acb 100644 --- a/packages/router/__tests__/blocking-test.ts +++ b/packages/router/__tests__/blocking-test.ts @@ -1,6 +1,6 @@ /* eslint-disable jest/no-focused-tests */ /* eslint-disable jest/no-done-callback */ -/* eslint-disable jest/no-disabled-tests */ + import type { Router } from "../index"; import { createMemoryHistory, createRouter } from "../index"; @@ -16,13 +16,9 @@ describe("blocking", () => { }); router.initialize(); - let fn = () => ({ - shouldBlock: () => true, - }); - router.createBlocker("KEY", fn); - let blocker = router.getBlocker("KEY"); - expect(blocker).toEqual({ - fn, + let fn = () => true; + router.getBlocker("KEY", fn); + expect(router.state.blockers.get("KEY")).toEqual({ state: "unblocked", proceed: undefined, reset: undefined, @@ -38,11 +34,9 @@ describe("blocking", () => { routes: [{ path: "/" }, { path: "/about" }], }); router.initialize(); - - router.createBlocker("KEY", () => ({ shouldBlock: () => true })); + router.getBlocker("KEY", () => true); router.deleteBlocker("KEY"); - let blocker = router.getBlocker("KEY"); - expect(blocker).toBeUndefined(); + expect(router.state.blockers.get("KEY")).toBeUndefined(); }); describe("on history push", () => { @@ -60,32 +54,28 @@ describe("blocking", () => { }); describe("blocker returns false", () => { - it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => false, - })); + it("removes blocker after navigation", async () => { + router.getBlocker("KEY", () => false); await router.navigate("/about"); - expect(blocker.state).toEqual("unblocked"); + expect(router.state.blockers.get("KEY")).toBeUndefined(); }); it("navigates", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: () => false })); + router.getBlocker("KEY", () => false); await router.navigate("/about"); expect(router.state.location.pathname).toBe("/about"); }); }); describe("blocker returns true", () => { - it("set blocker state to 'blocked'", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + it("set blocker state to 'blocked' after navigation", async () => { + router.getBlocker("KEY", () => true); await router.navigate("/about"); - expect(blocker.state).toEqual("blocked"); + expect(router.state.blockers.get("KEY")?.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: () => true })); + router.getBlocker("KEY", () => true); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -94,50 +84,45 @@ describe("blocking", () => { }); describe("proceeds from blocked state", () => { - it("sets blocker state to 'proceeding'", async (done) => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + // TODO: Unsure why this is failing + it.skip("sets blocker state to 'proceeding'", async (done) => { + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about"); - expect(blocker.state).toEqual("blocked"); - blocker.proceed?.().then(() => { - expect(blocker.state).toEqual("unblocked"); - done(); - }); - expect(blocker.state).toEqual("proceeding"); + expect(router.state.blockers.get("KEY")?.state).toEqual("blocked"); + + blocker = router.getBlocker("KEY"); + blocker.proceed?.(); + + expect(router.state.blockers.get("KEY")?.state).toEqual("proceeding"); }); - it("proceeds with blocked navigation", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + it.skip("proceeds with blocked navigation", async () => { + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] ); - await blocker.proceed?.(); + // TODO: Proceed is sync and doesn't wait for the transition to + // complete, so I'm not sure how to test this. + blocker.proceed?.(); expect(router.state.location.pathname).toEqual("/about"); }); }); describe("resets from blocked state", () => { - it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + it.only("sets blocker state to 'unblocked'", async () => { + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about"); - expect(blocker.state).toEqual("blocked"); + expect(router.state.blockers.get("KEY")?.state).toEqual("blocked"); - blocker.reset?.(); - expect(blocker.state).toEqual("unblocked"); + router.state.blockers.get("KEY")?.reset?.(); + expect(router.state.blockers.get("KEY")?.state).toEqual("unblocked"); }); it("does not navigate", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -165,15 +150,13 @@ describe("blocking", () => { describe("blocker returns false", () => { it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => false, - })); + let blocker = router.getBlocker("KEY", () => false); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("unblocked"); }); it("navigates", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: () => false })); + router.getBlocker("KEY", () => false); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe("/about"); }); @@ -181,15 +164,13 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state to 'blocked'", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: () => true })); + router.getBlocker("KEY", () => true); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -199,23 +180,16 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async (done) => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); - blocker.proceed?.().then(() => { - expect(blocker.state).toEqual("unblocked"); - done(); - }); + blocker.proceed?.(); expect(blocker.state).toEqual("proceeding"); }); it("proceeds with blocked navigation", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -228,9 +202,7 @@ describe("blocking", () => { describe("resets from blocked state", () => { it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about", { replace: true }); expect(blocker.state).toEqual("blocked"); @@ -239,9 +211,7 @@ describe("blocking", () => { }); it("does not navigate", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -274,15 +244,13 @@ describe("blocking", () => { describe("blocker returns false", () => { it("set blocker state to unblocked", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => false, - })); + let blocker = router.getBlocker("KEY", () => false); await router.navigate(-1); expect(blocker.state).toEqual("unblocked"); }); it("should navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: () => false })); + router.getBlocker("KEY", () => false); await router.navigate(-1); expect(router.state.location.pathname).toEqual( initialEntries[initialIndex - 1] @@ -292,15 +260,13 @@ describe("blocking", () => { describe("blocker returns true", () => { it("set blocker state", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); }); it("does not navigate", async () => { - router.createBlocker("KEY", () => ({ shouldBlock: () => true })); + router.getBlocker("KEY", () => true); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -310,23 +276,16 @@ describe("blocking", () => { describe("proceeds from blocked state", () => { it("sets blocker state to 'proceeding'", async (done) => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); - blocker.proceed?.().then(() => { - expect(blocker.state).toEqual("unblocked"); - done(); - }); + blocker.proceed?.(); expect(blocker.state).toEqual("proceeding"); }); it("proceeds with blocked navigation", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] @@ -343,9 +302,7 @@ describe("blocking", () => { it.todo("patches the history stack"); it("sets blocker state to 'unblocked'", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate(-1); expect(blocker.state).toEqual("blocked"); @@ -354,9 +311,7 @@ describe("blocking", () => { }); it("does not navigate", async () => { - let blocker = router.createBlocker("KEY", () => ({ - shouldBlock: () => true, - })); + let blocker = router.getBlocker("KEY", () => true); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] From 7285473c9421bf8ffbf7bf8cabd8102959fc682c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 9 Jan 2023 11:42:42 -0500 Subject: [PATCH 21/47] Add beforeUnload flag to useBlocker --- packages/react-router-dom/index.tsx | 43 ++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 8799770fde..7df3a672e3 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -25,6 +25,7 @@ import { UNSAFE_enhanceManualRouteObjects as enhanceManualRouteObjects, } from "react-router"; import type { + Blocker, BlockerFunction, BrowserHistory, Fetcher, @@ -63,6 +64,7 @@ import { //////////////////////////////////////////////////////////////////////////////// export type { + Blocker, FormEncType, FormMethod, GetScrollRestorationKeyFunction, @@ -980,7 +982,10 @@ export function useFormAction( let blockerKey = "blocker-singleton"; export function useBlocker( - shouldBlock: boolean | (() => boolean) | BlockerFunction + shouldBlock: boolean | (() => boolean) | BlockerFunction, + { beforeUnload }: { beforeUnload?: boolean | string } = { + beforeUnload: false, + } ) { let { router } = useDataRouterContext(DataRouterHook.UseFetcher); @@ -995,6 +1000,42 @@ export function useBlocker( // Cleanup on unmount React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]); + // 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()) { + // If we were previously blocked on an internal navigation - reset + // that once we show this prompt since proceed() is now stale and + // points to the previously blocked location). + // TODO: We could probably override proceed with a `window.location.href` + // assignment function using some local state... + if (blocker.state === "blocked") { + blocker.reset(); + } + // TODO: allow customization for legacy browsers? + let message = "Are you sure you want to leave?"; + evt.preventDefault(); // TODO: Is this still needed for older browsers? + evt.returnValue = message; + return message; + } + }; + window.addEventListener("beforeunload", handleBeforeUnload, { + capture: true, + }); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload, { + capture: true, + }); + }; + }, [blocker, beforeUnload, blockerFunction]); + return blocker; } From 8cfc7d3c4d66490fd0dd07aa7ce15ca4d155dfed Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 9 Jan 2023 11:42:58 -0500 Subject: [PATCH 22/47] Add navigation blocking example --- examples/navigation-blocking/.gitignore | 5 + examples/navigation-blocking/.stackblitzrc | 4 + examples/navigation-blocking/README.md | 15 + examples/navigation-blocking/index.html | 12 + .../navigation-blocking/package-lock.json | 2454 +++++++++++++++++ examples/navigation-blocking/package.json | 23 + examples/navigation-blocking/src/app.tsx | 145 + examples/navigation-blocking/src/main.tsx | 9 + .../navigation-blocking/src/vite-env.d.ts | 1 + examples/navigation-blocking/tsconfig.json | 21 + examples/navigation-blocking/vite.config.ts | 36 + 11 files changed, 2725 insertions(+) create mode 100644 examples/navigation-blocking/.gitignore create mode 100644 examples/navigation-blocking/.stackblitzrc create mode 100644 examples/navigation-blocking/README.md create mode 100644 examples/navigation-blocking/index.html create mode 100644 examples/navigation-blocking/package-lock.json create mode 100644 examples/navigation-blocking/package.json create mode 100644 examples/navigation-blocking/src/app.tsx create mode 100644 examples/navigation-blocking/src/main.tsx create mode 100644 examples/navigation-blocking/src/vite-env.d.ts create mode 100644 examples/navigation-blocking/tsconfig.json create mode 100644 examples/navigation-blocking/vite.config.ts diff --git a/examples/navigation-blocking/.gitignore b/examples/navigation-blocking/.gitignore new file mode 100644 index 0000000000..d451ff16c1 --- /dev/null +++ b/examples/navigation-blocking/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/navigation-blocking/.stackblitzrc b/examples/navigation-blocking/.stackblitzrc new file mode 100644 index 0000000000..d98146f4d0 --- /dev/null +++ b/examples/navigation-blocking/.stackblitzrc @@ -0,0 +1,4 @@ +{ + "installDependencies": true, + "startCommand": "npm run dev" +} diff --git a/examples/navigation-blocking/README.md b/examples/navigation-blocking/README.md new file mode 100644 index 0000000000..7cfccd32b3 --- /dev/null +++ b/examples/navigation-blocking/README.md @@ -0,0 +1,15 @@ +--- +title: Navigation Blocking +toc: false +order: 1 +--- + +# Navigation Blocking + +This example demonstrates using `useBlocker` to prevent navigating away from a page where you might lose user-entered form data. A potential better UX for this is storing user-entered information in `sessionStorage` and pre-populating the form on return. + +## Preview + +Open this example on [StackBlitz](https://stackblitz.com): + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router/tree/main/examples/navigation-blocking?file=src/App.tsx) diff --git a/examples/navigation-blocking/index.html b/examples/navigation-blocking/index.html new file mode 100644 index 0000000000..a8e66e86e0 --- /dev/null +++ b/examples/navigation-blocking/index.html @@ -0,0 +1,12 @@ + + + + + + React Router - Navigation Blocking + + +
+ + + diff --git a/examples/navigation-blocking/package-lock.json b/examples/navigation-blocking/package-lock.json new file mode 100644 index 0000000000..ec038ec93f --- /dev/null +++ b/examples/navigation-blocking/package-lock.json @@ -0,0 +1,2454 @@ +{ + "name": "navigtion-blocking", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "data-router", + "dependencies": { + "react": "18.1.0", + "react-dom": "18.1.0", + "react-router-dom": "^6.4.0" + }, + "devDependencies": { + "@rollup/plugin-replace": "4.0.0", + "@types/node": "17.0.32", + "@types/react": "18.0.9", + "@types/react-dom": "18.0.3", + "@vitejs/plugin-react": "1.3.2", + "typescript": "4.6.4", + "vite": "2.9.9" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.10.tgz", + "integrity": "sha512-GZt/TCsG70Ms19gfZO1tM4CVnXsPgEPBCpJu+Qz3L0LUDsY5nZqFZglIoPC1kIYOtNBZlrnFT+klg12vFGZXrw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.10.tgz", + "integrity": "sha512-liKoppandF3ZcBnIYFjfSDHZLKdLHGJRkoWtG8zQyGJBQfIYobpnVGI5+pLBNtS6psFLDzyq8+h5HiVljW9PNA==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.10", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.10", + "@babel/types": "^7.17.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.10.tgz", + "integrity": "sha512-46MJZZo9y3o4kmhBVc7zW7i8dtR1oIK/sdO5NcfcZRhTGYi+KKJRtHNgsU6c4VUcJmUNV/LQdebD/9Dlv4K+Tg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.17.10", + "@jridgewell/gen-mapping": "^0.1.0", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.10.tgz", + "integrity": "sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-validator-option": "^7.16.7", + "browserslist": "^4.20.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", + "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", + "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.3", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", + "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", + "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "dev": true, + "dependencies": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.10.tgz", + "integrity": "sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", + "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", + "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", + "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.16.7.tgz", + "integrity": "sha512-oe5VuWs7J9ilH3BCCApGoYjHoSO48vkjX2CbA5bFVhIuO2HKxA3vyF7rleA4o6/4rTDbk6r8hBW7Ul8E+UZrpA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.16.7.tgz", + "integrity": "sha512-rONFiQz9vgbsnaMtQlZCjIRwhJvlrPET8TabIUK2hzlXw9B9s2Ieaxte1SCOOXMbWRHodbKixNf3BLcWVOQ8Bw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.10.tgz", + "integrity": "sha512-VmbrTHQteIdUUQNTb+zE12SHS/xQVIShmBPhlNP12hD5poF2pbITW1Z4172d03HegaQWhLffdkRJYtAzp0AGcw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.10", + "@babel/types": "^7.17.10", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.10.tgz", + "integrity": "sha512-9O26jG0mBYfGkUYCYZRnBwbVLd1UZOICEr2Em6InB6jVfsAv1GKgwXHmrSg+WFWDmeKTA6vyTZiN8tCSM5Oo3A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz", + "integrity": "sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.1.tgz", + "integrity": "sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz", + "integrity": "sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz", + "integrity": "sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.0.tgz", + "integrity": "sha512-SCR1cxRSMNKjaVYptCzBApPDqGwa3FGdjVHc+rOToocNPHQdIYLZBfv/3f+KvYuXDkUGVIW9IAzmPNZDRL1I4A==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-4.0.0.tgz", + "integrity": "sha512-+rumQFiaNac9y64OHtkHGmdjm7us9bo1PlbgQfdihQtuNxzjpaB064HbRnewUOggLQxVCCyINfStkgmBeQpv1g==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", + "integrity": "sha512-eAIcfAvhf/BkHcf4pkLJ7ECpBAhh9kcxRBpip9cTiO+hf+aJrsxYxBeS6OXvOd9WqNAJmavXVpZvY1rBjNsXmw==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz", + "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.3.tgz", + "integrity": "sha512-1RRW9kst+67gveJRYPxGmVy8eVJ05O43hg77G2j5m76/RFJtMbcfAs2viQ2UNsvvDg8F7OfQZx8qQcl6ymygaQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-1.3.2.tgz", + "integrity": "sha512-aurBNmMo0kz1O4qRoY+FM4epSA39y3ShWGuqfLRA/3z0oEJAdtoSfgA3aO98/PCCHAqMaduLxIxErWrVKIFzXA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.17.10", + "@babel/plugin-transform-react-jsx": "^7.17.3", + "@babel/plugin-transform-react-jsx-development": "^7.16.7", + "@babel/plugin-transform-react-jsx-self": "^7.16.7", + "@babel/plugin-transform-react-jsx-source": "^7.16.7", + "@rollup/pluginutils": "^4.2.1", + "react-refresh": "^0.13.0", + "resolve": "^1.22.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/browserslist": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", + "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001332", + "electron-to-chromium": "^1.4.118", + "escalade": "^3.1.1", + "node-releases": "^2.0.3", + "picocolors": "^1.0.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001339", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz", + "integrity": "sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.1" + } + }, + "node_modules/csstype": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", + "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.137", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.137.tgz", + "integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.38.tgz", + "integrity": "sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "esbuild-android-64": "0.14.38", + "esbuild-android-arm64": "0.14.38", + "esbuild-darwin-64": "0.14.38", + "esbuild-darwin-arm64": "0.14.38", + "esbuild-freebsd-64": "0.14.38", + "esbuild-freebsd-arm64": "0.14.38", + "esbuild-linux-32": "0.14.38", + "esbuild-linux-64": "0.14.38", + "esbuild-linux-arm": "0.14.38", + "esbuild-linux-arm64": "0.14.38", + "esbuild-linux-mips64le": "0.14.38", + "esbuild-linux-ppc64le": "0.14.38", + "esbuild-linux-riscv64": "0.14.38", + "esbuild-linux-s390x": "0.14.38", + "esbuild-netbsd-64": "0.14.38", + "esbuild-openbsd-64": "0.14.38", + "esbuild-sunos-64": "0.14.38", + "esbuild-windows-32": "0.14.38", + "esbuild-windows-64": "0.14.38", + "esbuild-windows-arm64": "0.14.38" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz", + "integrity": "sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz", + "integrity": "sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz", + "integrity": "sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz", + "integrity": "sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz", + "integrity": "sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz", + "integrity": "sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz", + "integrity": "sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz", + "integrity": "sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz", + "integrity": "sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz", + "integrity": "sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz", + "integrity": "sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz", + "integrity": "sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz", + "integrity": "sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz", + "integrity": "sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz", + "integrity": "sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz", + "integrity": "sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz", + "integrity": "sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz", + "integrity": "sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz", + "integrity": "sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz", + "integrity": "sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "dependencies": { + "sourcemap-codec": "^1.4.4" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.4.tgz", + "integrity": "sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==", + "dev": true + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz", + "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.3", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", + "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", + "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.22.0" + }, + "peerDependencies": { + "react": "^18.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", + "integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.0.tgz", + "integrity": "sha512-B+5bEXFlgR1XUdHYR6P94g299SjrfCBMmEDJNcFbpAyRH1j1748yt9NdDhW3++nw1lk3zQJ6aOO66zUx3KlTZg==", + "dependencies": { + "@remix-run/router": "1.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.0.tgz", + "integrity": "sha512-4Aw1xmXKeleYYQ3x0Lcl2undHR6yMjXZjd9DKZd53SGOYqirrUThyUb0wwAX5VZAyvSuzjNJmZlJ3rR9+/vzqg==", + "dependencies": { + "react-router": "6.4.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.72.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.72.1.tgz", + "integrity": "sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/scheduler": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", + "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/vite": { + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.9.tgz", + "integrity": "sha512-ffaam+NgHfbEmfw/Vuh6BHKKlI/XIAhxE5QSS7gFLIngxg171mg1P3a4LSRME0z2ZU1ScxoKzphkipcYwSD5Ew==", + "dev": true, + "dependencies": { + "esbuild": "^0.14.27", + "postcss": "^8.4.13", + "resolve": "^1.22.0", + "rollup": "^2.59.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": ">=12.2.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "less": "*", + "sass": "*", + "stylus": "*" + }, + "peerDependenciesMeta": { + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + } + } + } + }, + "dependencies": { + "@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/compat-data": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.10.tgz", + "integrity": "sha512-GZt/TCsG70Ms19gfZO1tM4CVnXsPgEPBCpJu+Qz3L0LUDsY5nZqFZglIoPC1kIYOtNBZlrnFT+klg12vFGZXrw==", + "dev": true + }, + "@babel/core": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.10.tgz", + "integrity": "sha512-liKoppandF3ZcBnIYFjfSDHZLKdLHGJRkoWtG8zQyGJBQfIYobpnVGI5+pLBNtS6psFLDzyq8+h5HiVljW9PNA==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.10", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.10", + "@babel/types": "^7.17.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/generator": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.10.tgz", + "integrity": "sha512-46MJZZo9y3o4kmhBVc7zW7i8dtR1oIK/sdO5NcfcZRhTGYi+KKJRtHNgsU6c4VUcJmUNV/LQdebD/9Dlv4K+Tg==", + "dev": true, + "requires": { + "@babel/types": "^7.17.10", + "@jridgewell/gen-mapping": "^0.1.0", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.10.tgz", + "integrity": "sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-validator-option": "^7.16.7", + "browserslist": "^4.20.2", + "semver": "^6.3.0" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-function-name": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", + "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "dev": true, + "requires": { + "@babel/template": "^7.16.7", + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-transforms": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", + "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.3", + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true + }, + "@babel/helper-simple-access": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", + "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "dev": true, + "requires": { + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "dev": true + }, + "@babel/helpers": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", + "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "dev": true, + "requires": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0" + } + }, + "@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.10.tgz", + "integrity": "sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ==", + "dev": true + }, + "@babel/plugin-syntax-jsx": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", + "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", + "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.16.7", + "@babel/types": "^7.17.0" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", + "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.16.7.tgz", + "integrity": "sha512-oe5VuWs7J9ilH3BCCApGoYjHoSO48vkjX2CbA5bFVhIuO2HKxA3vyF7rleA4o6/4rTDbk6r8hBW7Ul8E+UZrpA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.16.7.tgz", + "integrity": "sha512-rONFiQz9vgbsnaMtQlZCjIRwhJvlrPET8TabIUK2hzlXw9B9s2Ieaxte1SCOOXMbWRHodbKixNf3BLcWVOQ8Bw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/traverse": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.10.tgz", + "integrity": "sha512-VmbrTHQteIdUUQNTb+zE12SHS/xQVIShmBPhlNP12hD5poF2pbITW1Z4172d03HegaQWhLffdkRJYtAzp0AGcw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.10", + "@babel/types": "^7.17.10", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.10.tgz", + "integrity": "sha512-9O26jG0mBYfGkUYCYZRnBwbVLd1UZOICEr2Em6InB6jVfsAv1GKgwXHmrSg+WFWDmeKTA6vyTZiN8tCSM5Oo3A==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz", + "integrity": "sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.1.tgz", + "integrity": "sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz", + "integrity": "sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz", + "integrity": "sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@remix-run/router": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.0.tgz", + "integrity": "sha512-SCR1cxRSMNKjaVYptCzBApPDqGwa3FGdjVHc+rOToocNPHQdIYLZBfv/3f+KvYuXDkUGVIW9IAzmPNZDRL1I4A==" + }, + "@rollup/plugin-replace": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-4.0.0.tgz", + "integrity": "sha512-+rumQFiaNac9y64OHtkHGmdjm7us9bo1PlbgQfdihQtuNxzjpaB064HbRnewUOggLQxVCCyINfStkgmBeQpv1g==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + } + }, + "@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "requires": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + } + }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "@types/node": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", + "integrity": "sha512-eAIcfAvhf/BkHcf4pkLJ7ECpBAhh9kcxRBpip9cTiO+hf+aJrsxYxBeS6OXvOd9WqNAJmavXVpZvY1rBjNsXmw==", + "dev": true + }, + "@types/prop-types": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", + "dev": true + }, + "@types/react": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz", + "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-dom": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.3.tgz", + "integrity": "sha512-1RRW9kst+67gveJRYPxGmVy8eVJ05O43hg77G2j5m76/RFJtMbcfAs2viQ2UNsvvDg8F7OfQZx8qQcl6ymygaQ==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", + "dev": true + }, + "@vitejs/plugin-react": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-1.3.2.tgz", + "integrity": "sha512-aurBNmMo0kz1O4qRoY+FM4epSA39y3ShWGuqfLRA/3z0oEJAdtoSfgA3aO98/PCCHAqMaduLxIxErWrVKIFzXA==", + "dev": true, + "requires": { + "@babel/core": "^7.17.10", + "@babel/plugin-transform-react-jsx": "^7.17.3", + "@babel/plugin-transform-react-jsx-development": "^7.16.7", + "@babel/plugin-transform-react-jsx-self": "^7.16.7", + "@babel/plugin-transform-react-jsx-source": "^7.16.7", + "@rollup/pluginutils": "^4.2.1", + "react-refresh": "^0.13.0", + "resolve": "^1.22.0" + }, + "dependencies": { + "@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "requires": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + } + } + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "browserslist": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", + "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001332", + "electron-to-chromium": "^1.4.118", + "escalade": "^3.1.1", + "node-releases": "^2.0.3", + "picocolors": "^1.0.0" + } + }, + "caniuse-lite": { + "version": "1.0.30001339", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz", + "integrity": "sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "csstype": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", + "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "electron-to-chromium": { + "version": "1.4.137", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.137.tgz", + "integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", + "dev": true + }, + "esbuild": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.38.tgz", + "integrity": "sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==", + "dev": true, + "requires": { + "esbuild-android-64": "0.14.38", + "esbuild-android-arm64": "0.14.38", + "esbuild-darwin-64": "0.14.38", + "esbuild-darwin-arm64": "0.14.38", + "esbuild-freebsd-64": "0.14.38", + "esbuild-freebsd-arm64": "0.14.38", + "esbuild-linux-32": "0.14.38", + "esbuild-linux-64": "0.14.38", + "esbuild-linux-arm": "0.14.38", + "esbuild-linux-arm64": "0.14.38", + "esbuild-linux-mips64le": "0.14.38", + "esbuild-linux-ppc64le": "0.14.38", + "esbuild-linux-riscv64": "0.14.38", + "esbuild-linux-s390x": "0.14.38", + "esbuild-netbsd-64": "0.14.38", + "esbuild-openbsd-64": "0.14.38", + "esbuild-sunos-64": "0.14.38", + "esbuild-windows-32": "0.14.38", + "esbuild-windows-64": "0.14.38", + "esbuild-windows-arm64": "0.14.38" + } + }, + "esbuild-android-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz", + "integrity": "sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==", + "dev": true, + "optional": true + }, + "esbuild-android-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz", + "integrity": "sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==", + "dev": true, + "optional": true + }, + "esbuild-darwin-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz", + "integrity": "sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==", + "dev": true, + "optional": true + }, + "esbuild-darwin-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz", + "integrity": "sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz", + "integrity": "sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz", + "integrity": "sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz", + "integrity": "sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==", + "dev": true, + "optional": true + }, + "esbuild-linux-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz", + "integrity": "sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz", + "integrity": "sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz", + "integrity": "sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==", + "dev": true, + "optional": true + }, + "esbuild-linux-mips64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz", + "integrity": "sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-ppc64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz", + "integrity": "sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==", + "dev": true, + "optional": true + }, + "esbuild-linux-riscv64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz", + "integrity": "sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-s390x": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz", + "integrity": "sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==", + "dev": true, + "optional": true + }, + "esbuild-netbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz", + "integrity": "sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==", + "dev": true, + "optional": true + }, + "esbuild-openbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz", + "integrity": "sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==", + "dev": true, + "optional": true + }, + "esbuild-sunos-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz", + "integrity": "sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==", + "dev": true, + "optional": true + }, + "esbuild-windows-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz", + "integrity": "sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==", + "dev": true, + "optional": true + }, + "esbuild-windows-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz", + "integrity": "sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==", + "dev": true, + "optional": true + }, + "esbuild-windows-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz", + "integrity": "sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==", + "dev": true, + "optional": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "dev": true + }, + "node-releases": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.4.tgz", + "integrity": "sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true + }, + "postcss": { + "version": "8.4.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz", + "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==", + "dev": true, + "requires": { + "nanoid": "^3.3.3", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "react": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", + "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "react-dom": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", + "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.22.0" + } + }, + "react-refresh": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", + "integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==", + "dev": true + }, + "react-router": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.0.tgz", + "integrity": "sha512-B+5bEXFlgR1XUdHYR6P94g299SjrfCBMmEDJNcFbpAyRH1j1748yt9NdDhW3++nw1lk3zQJ6aOO66zUx3KlTZg==", + "requires": { + "@remix-run/router": "1.0.0" + } + }, + "react-router-dom": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.0.tgz", + "integrity": "sha512-4Aw1xmXKeleYYQ3x0Lcl2undHR6yMjXZjd9DKZd53SGOYqirrUThyUb0wwAX5VZAyvSuzjNJmZlJ3rR9+/vzqg==", + "requires": { + "react-router": "6.4.0" + } + }, + "resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "requires": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "rollup": { + "version": "2.72.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.72.1.tgz", + "integrity": "sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "scheduler": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", + "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "dev": true + }, + "vite": { + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.9.tgz", + "integrity": "sha512-ffaam+NgHfbEmfw/Vuh6BHKKlI/XIAhxE5QSS7gFLIngxg171mg1P3a4LSRME0z2ZU1ScxoKzphkipcYwSD5Ew==", + "dev": true, + "requires": { + "esbuild": "^0.14.27", + "fsevents": "~2.3.2", + "postcss": "^8.4.13", + "resolve": "^1.22.0", + "rollup": "^2.59.0" + } + } + } +} diff --git a/examples/navigation-blocking/package.json b/examples/navigation-blocking/package.json new file mode 100644 index 0000000000..d828fa3f2a --- /dev/null +++ b/examples/navigation-blocking/package.json @@ -0,0 +1,23 @@ +{ + "name": "navigation-blocking", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "react": "18.1.0", + "react-dom": "18.1.0", + "react-router-dom": "^6.4.0" + }, + "devDependencies": { + "@rollup/plugin-replace": "4.0.0", + "@types/node": "17.0.32", + "@types/react": "18.0.9", + "@types/react-dom": "18.0.3", + "@vitejs/plugin-react": "1.3.2", + "typescript": "4.6.4", + "vite": "2.9.9" + } +} diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx new file mode 100644 index 0000000000..ff8a3d7a3b --- /dev/null +++ b/examples/navigation-blocking/src/app.tsx @@ -0,0 +1,145 @@ +import React from "react"; +import { Blocker, useLocation } from "react-router-dom"; +import { + createBrowserRouter, + createRoutesFromElements, + Form, + json, + Link, + Outlet, + Route, + RouterProvider, + useBlocker, +} from "react-router-dom"; + +let router = createBrowserRouter( + createRoutesFromElements( + }> + Index} /> + One} /> + Two} /> + json({ ok: true })} + element={ + <> +

Three

+ + + } + /> + Four} /> + Five} /> + + ) +); + +if (import.meta.hot) { + import.meta.hot.dispose(() => router.dispose()); +} + +export default function App() { + return ; +} + +function Layout() { + let [historyIndex, setHistoryIndex] = React.useState( + window.history.state?.idx + ); + let location = useLocation(); + + // Expose the underlying history index in the UI for debugging + React.useEffect(() => { + setHistoryIndex(window.history.state?.idx); + }, [location]); + + // Give us meaningful document titles for popping back/forward more than 1 entry + React.useEffect(() => { + document.title = location.pathname; + }, [location]); + + return ( + <> +

Navigation Blocking Example

+ +

+ Current location (index): {location.pathname} ({historyIndex}) +

+ + + ); +} + +function ImportantForm() { + let [value, setValue] = React.useState(""); + let isBlocked = value !== ""; + + let [beforeUnload, setBeforeUnload] = React.useState(false); + let blocker = useBlocker(() => isBlocked, { beforeUnload }); + + // Reset the blocker if the user cleans the form + React.useEffect(() => { + if (blocker.state === "blocked" && !isBlocked) { + blocker.reset(); + } + }, [blocker, isBlocked]); + + // Display our confirmation UI + const blockerUI: Record = { + unblocked:

Blocker is currently unblocked

, + blocked: ( + <> +

Blocked the last navigation

+ + + + ), + proceeding: ( +

Proceeding through blocked navigation

+ ), + }; + + return ( + <> + + +

+ Is the form dirty?{" "} + {isBlocked ? ( + Yes + ) : ( + No + )} +

+ +
+ + +
+ + {blockerUI[blocker.state]} + + ); +} diff --git a/examples/navigation-blocking/src/main.tsx b/examples/navigation-blocking/src/main.tsx new file mode 100644 index 0000000000..32a669c16c --- /dev/null +++ b/examples/navigation-blocking/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./app"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); diff --git a/examples/navigation-blocking/src/vite-env.d.ts b/examples/navigation-blocking/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/navigation-blocking/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/navigation-blocking/tsconfig.json b/examples/navigation-blocking/tsconfig.json new file mode 100644 index 0000000000..8bdaabfe5d --- /dev/null +++ b/examples/navigation-blocking/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "importsNotUsedAsValues": "error" + }, + "include": ["./src"] +} diff --git a/examples/navigation-blocking/vite.config.ts b/examples/navigation-blocking/vite.config.ts new file mode 100644 index 0000000000..b77eb48a30 --- /dev/null +++ b/examples/navigation-blocking/vite.config.ts @@ -0,0 +1,36 @@ +import * as path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import rollupReplace from "@rollup/plugin-replace"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(true), + "process.env.NODE_ENV": JSON.stringify("development"), + }, + }), + react(), + ], + resolve: process.env.USE_SOURCE + ? { + alias: { + "@remix-run/router": path.resolve( + __dirname, + "../../packages/router/index.ts" + ), + "react-router": path.resolve( + __dirname, + "../../packages/react-router/index.ts" + ), + "react-router-dom": path.resolve( + __dirname, + "../../packages/react-router-dom/index.tsx" + ), + }, + } + : {}, +}); From d2f05a1fcf26da2a590f9d4fcd1f5a9448d57436 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 10 Jan 2023 17:35:46 -0500 Subject: [PATCH 23/47] Updates --- examples/navigation-blocking/src/app.tsx | 20 ++-- packages/react-router-dom/index.tsx | 126 ++--------------------- packages/router/router.ts | 96 +++++++++++------ 3 files changed, 80 insertions(+), 162 deletions(-) diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index ff8a3d7a3b..1288655100 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Blocker, useLocation } from "react-router-dom"; +import { Blocker, useNavigate } from "react-router-dom"; import { createBrowserRouter, createRoutesFromElements, @@ -10,6 +10,7 @@ import { Route, RouterProvider, useBlocker, + useLocation, } from "react-router-dom"; let router = createBrowserRouter( @@ -47,6 +48,7 @@ function Layout() { window.history.state?.idx ); let location = useLocation(); + let navigate = useNavigate(); // Expose the underlying history index in the UI for debugging React.useEffect(() => { @@ -69,6 +71,7 @@ function Layout() { Four   Five   External link to Remix Docs   +   

Current location (index): {location.pathname} ({historyIndex}) @@ -81,9 +84,9 @@ function Layout() { function ImportantForm() { let [value, setValue] = React.useState(""); let isBlocked = value !== ""; - - let [beforeUnload, setBeforeUnload] = React.useState(false); - let blocker = useBlocker(() => isBlocked, { beforeUnload }); + let blocker = useBlocker(isBlocked); + router.getBlocker("a", () => false); + router.getBlocker("b", () => false); // Reset the blocker if the user cleans the form React.useEffect(() => { @@ -109,15 +112,6 @@ function ImportantForm() { return ( <> - -

Is the form dirty?{" "} {isBlocked ? ( diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 7df3a672e3..5517974cdb 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -979,134 +979,30 @@ export function useFormAction( return createPath(path); } +// useBlocker() is a singleton for now since we don't have any compelling use +// cases for multi-blocker yet let blockerKey = "blocker-singleton"; -export function useBlocker( - shouldBlock: boolean | (() => boolean) | BlockerFunction, - { beforeUnload }: { beforeUnload?: boolean | string } = { - beforeUnload: false, - } -) { +export function useBlocker(shouldBlock: boolean | BlockerFunction) { let { router } = useDataRouterContext(DataRouterHook.UseFetcher); - let blockerFunction = React.useCallback(() => { - return typeof shouldBlock === "function" - ? shouldBlock() === true - : shouldBlock === true; - }, [shouldBlock]); + let blockerFunction = React.useCallback( + (location, action) => { + return typeof shouldBlock === "function" + ? shouldBlock(location, action) === true + : shouldBlock === true; + }, + [shouldBlock] + ); let blocker = router.getBlocker(blockerKey, blockerFunction); // Cleanup on unmount React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]); - // 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()) { - // If we were previously blocked on an internal navigation - reset - // that once we show this prompt since proceed() is now stale and - // points to the previously blocked location). - // TODO: We could probably override proceed with a `window.location.href` - // assignment function using some local state... - if (blocker.state === "blocked") { - blocker.reset(); - } - // TODO: allow customization for legacy browsers? - let message = "Are you sure you want to leave?"; - evt.preventDefault(); // TODO: Is this still needed for older browsers? - evt.returnValue = message; - return message; - } - }; - window.addEventListener("beforeunload", handleBeforeUnload, { - capture: true, - }); - return () => { - window.removeEventListener("beforeunload", handleBeforeUnload, { - capture: true, - }); - }; - }, [blocker, beforeUnload, blockerFunction]); - return blocker; } -export function usePrompt( - message: string | null | false, - opts?: { beforeUnload: boolean } -) { - let { beforeUnload } = opts ? opts : { beforeUnload: false }; - let blockerFunction = React.useCallback(() => { - 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: - // - 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") { - 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( (props, ref) => { diff --git a/packages/router/router.ts b/packages/router/router.ts index d9a97d0a2a..852230b2bf 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -193,16 +193,20 @@ export interface Router { dispose(): void; /** - * Get a navigation blocker + * @internal + * PRIVATE - DO NOT USE * + * Get a navigation blocker * @param key The identifier for the blocker * @param fn The blocker function implementation */ getBlocker(key: string, fn: BlockerFunction): Blocker; /** - * Delete a navigation blocker + * @internal + * PRIVATE - DO NOT USE * + * Delete a navigation blocker * @param key The identifier for the blocker */ deleteBlocker(key: string): void; @@ -498,7 +502,10 @@ export type Blocker = proceed: undefined; }; -export type BlockerFunction = () => boolean; +export type BlockerFunction = ( + location: Location, + action: HistoryAction +) => boolean; interface ShortCircuitable { /** @@ -740,6 +747,10 @@ export function createRouter(init: RouterInit): Router { // cancel active deferreds for eliminated routes. let activeDeferreds = new Map(); + // We ony support a single active blocker at the moment since we don't have + // any compelling use cases for multi-blocker yet + let activeBlocker: string | null = null; + // Store blocker functions in a separate Map outside of router state since // we don't need to update UI state if they change let blockerFunctions = new Map(); @@ -763,7 +774,7 @@ export function createRouter(init: RouterInit): Router { return; } - let blockerKey = shouldBlockNavigation(); + let blockerKey = shouldBlockNavigation(historyAction, location); if (blockerKey) { // Restore the URL to match the current UI, but don't update router state ignoreNextHistoryUpdate = true; @@ -809,6 +820,7 @@ export function createRouter(init: RouterInit): Router { subscribers.clear(); pendingNavigationController && pendingNavigationController.abort(); state.fetchers.forEach((_, key) => deleteFetcher(key)); + state.blockers.forEach((_, key) => deleteBlocker(key)); } // Subscribe to state updates for the router @@ -873,8 +885,8 @@ export function createRouter(init: RouterInit): Router { ) : state.loaderData; - // Onl a successful navigation we can assume we got through all blockers - // so twe can start fresh + // On a successful navigation we can assume we got through all blockers + // so we can start fresh for (let [key] of blockerFunctions) { deleteBlocker(key); } @@ -921,28 +933,6 @@ export function createRouter(init: RouterInit): Router { to: number | To, opts?: RouterNavigateOptions ): Promise { - let blockerKey = shouldBlockNavigation(); - if (blockerKey) { - // Put the blocker into a blocked state - updateBlocker(blockerKey, { - state: "blocked", - proceed() { - updateBlocker(blockerKey!, { - state: "proceeding", - proceed: undefined, - reset: undefined, - }); - // Send the same navigation through - navigate(to, opts); - }, - reset() { - deleteBlocker(blockerKey!); - updateState({ blockers: new Map(state.blockers) }); - }, - }); - return; - } - if (typeof to === "number") { init.history.go(to); return; @@ -987,6 +977,28 @@ export function createRouter(init: RouterInit): Router { ? opts.preventScrollReset === true : undefined; + let blockerKey = shouldBlockNavigation(historyAction, location); + if (blockerKey) { + // Put the blocker into a blocked state + updateBlocker(blockerKey, { + state: "blocked", + proceed() { + updateBlocker(blockerKey!, { + state: "proceeding", + proceed: undefined, + reset: undefined, + }); + // Send the same navigation through + navigate(to, opts); + }, + reset() { + deleteBlocker(blockerKey!); + updateState({ blockers: new Map(state.blockers) }); + }, + }); + return; + } + return await startNavigation(historyAction, location, { submission, // Send through the formData serialization error if we have one so we can @@ -2061,6 +2073,12 @@ export function createRouter(init: RouterInit): Router { if (blockerFunctions.get(key) !== fn) { blockerFunctions.set(key, fn); + if (activeBlocker == null) { + // This is now the active blocker + activeBlocker = key; + } else if (key !== activeBlocker) { + warning(false, "A router only supports one blocker at a time"); + } } return blocker; @@ -2069,6 +2087,9 @@ export function createRouter(init: RouterInit): Router { function deleteBlocker(key: string) { state.blockers.delete(key); blockerFunctions.delete(key); + if (activeBlocker === key) { + activeBlocker = null; + } } // Utility function to update blockers, ensuring valid state transitions @@ -2088,15 +2109,22 @@ export function createRouter(init: RouterInit): Router { updateState({ blockers: new Map(state.blockers) }); } - function shouldBlockNavigation(): string | undefined { - if (blockerFunctions.size === 0) { + function shouldBlockNavigation( + historyAction: HistoryAction, + location: Location + ): string | undefined { + if (activeBlocker == null) { return; } // We only allow a single blocker at the moment. This will need to be // updated if we enhance to support multiple blockers in the future - let [key, blockerFunction] = Array.from(blockerFunctions.entries())[0]; - let blocker = state.blockers.get(key); + let blockerFunction = blockerFunctions.get(activeBlocker); + invariant( + blockerFunction, + "Could not find a function for the active blocker" + ); + let blocker = state.blockers.get(activeBlocker); if (blocker && blocker.state === "proceeding") { // If the blocker is currently proceeding, we don't need to re-check @@ -2106,8 +2134,8 @@ export function createRouter(init: RouterInit): Router { // At this point, we know we're unblocked/blocked so we need to check the // user-provided blocker function - if (blockerFunction()) { - return key; + if (blockerFunction(location, historyAction)) { + return activeBlocker; } } From 12f6daac878f45236a93f358e04602ed17f01739 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 10 Jan 2023 17:45:10 -0500 Subject: [PATCH 24/47] Remove manual back button --- examples/navigation-blocking/src/app.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index 1288655100..180a7e2770 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Blocker, useNavigate } from "react-router-dom"; +import type { Blocker } from "react-router-dom"; import { createBrowserRouter, createRoutesFromElements, @@ -48,7 +48,6 @@ function Layout() { window.history.state?.idx ); let location = useLocation(); - let navigate = useNavigate(); // Expose the underlying history index in the UI for debugging React.useEffect(() => { @@ -71,7 +70,6 @@ function Layout() { Four   Five   External link to Remix Docs   -   

Current location (index): {location.pathname} ({historyIndex}) From bb1bb0a7d8dbfce68a559f1be3448330a8a0c055 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 11 Jan 2023 13:51:22 -0800 Subject: [PATCH 25/47] update router blocking tests --- package.json | 1 + packages/router/__tests__/blocking-test.ts | 426 ++++++++++++++------- packages/router/router.ts | 53 ++- 3 files changed, 317 insertions(+), 163 deletions(-) diff --git a/package.json b/package.json index 53ba67abbc..87fe3cc9c9 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "release": "changeset publish", "size": "filesize", "test": "jest", + "test:inspect": "node --inspect-brk ./node_modules/.bin/jest", "changeset": "changeset", "version": "changeset version", "postversion": "node scripts/postversion.mjs", diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/blocking-test.ts index 5fb34d1acb..6d3768d10a 100644 --- a/packages/router/__tests__/blocking-test.ts +++ b/packages/router/__tests__/blocking-test.ts @@ -1,44 +1,40 @@ /* eslint-disable jest/no-focused-tests */ -/* eslint-disable jest/no-done-callback */ import type { Router } from "../index"; import { createMemoryHistory, createRouter } from "../index"; +const LOADER_LATENCY_MS = 100; +const routes = [ + { path: "/" }, + { + path: "/about", + loader: () => sleep(LOADER_LATENCY_MS), + }, + { path: "/contact" }, + { path: "/help" }, +]; + describe("blocking", () => { let router: Router; - it("creates a blocker", () => { + it("initializes an 'unblocked' blocker", () => { router = createRouter({ history: createMemoryHistory({ initialEntries: ["/"], initialIndex: 0, }), - routes: [{ path: "/" }, { path: "/about" }], + routes, }); router.initialize(); let fn = () => true; router.getBlocker("KEY", fn); - expect(router.state.blockers.get("KEY")).toEqual({ + expect(router.getBlocker("KEY", fn)).toEqual({ state: "unblocked", proceed: undefined, reset: undefined, }); }); - it("deletes a blocker", () => { - router = createRouter({ - history: createMemoryHistory({ - initialEntries: ["/"], - initialIndex: 0, - }), - routes: [{ path: "/" }, { path: "/about" }], - }); - router.initialize(); - router.getBlocker("KEY", () => true); - router.deleteBlocker("KEY"); - expect(router.state.blockers.get("KEY")).toBeUndefined(); - }); - describe("on history push", () => { let initialEntries = ["/", "/about"]; let initialIndex = 0; @@ -48,88 +44,128 @@ describe("blocking", () => { initialEntries, initialIndex, }), - routes: [{ path: "/" }, { path: "/about" }], + routes, }); router.initialize(); }); describe("blocker returns false", () => { - it("removes blocker after navigation", async () => { - router.getBlocker("KEY", () => false); + let fn = () => false; + it("navigates", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about"); - expect(router.state.blockers.get("KEY")).toBeUndefined(); + expect(router.state.location.pathname).toBe("/about"); }); - it("navigates", async () => { - router.getBlocker("KEY", () => false); + it("gets an 'unblocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about"); - expect(router.state.location.pathname).toBe("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); }); }); describe("blocker returns true", () => { - it("set blocker state to 'blocked' after navigation", async () => { - router.getBlocker("KEY", () => true); - await router.navigate("/about"); - expect(router.state.blockers.get("KEY")?.state).toEqual("blocked"); - }); + let fn = () => true; it("does not navigate", async () => { - router.getBlocker("KEY", () => true); + router.getBlocker("KEY", fn); await router.navigate("/about"); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] ); }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); }); describe("proceeds from blocked state", () => { - // TODO: Unsure why this is failing - it.skip("sets blocker state to 'proceeding'", async (done) => { - let blocker = router.getBlocker("KEY", () => true); + let fn = () => true; + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about"); - - expect(router.state.blockers.get("KEY")?.state).toEqual("blocked"); - - blocker = router.getBlocker("KEY"); - blocker.proceed?.(); - - expect(router.state.blockers.get("KEY")?.state).toEqual("proceeding"); + router.getBlocker("KEY", fn).proceed?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + }); }); - it.skip("proceeds with blocked navigation", async () => { - let blocker = router.getBlocker("KEY", () => true); + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about"); - expect(router.state.location.pathname).toBe( - initialEntries[initialIndex] - ); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); - // TODO: Proceed is sync and doesn't wait for the transition to - // complete, so I'm not sure how to test this. - blocker.proceed?.(); + it("navigates after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); expect(router.state.location.pathname).toEqual("/about"); }); }); describe("resets from blocked state", () => { - it.only("sets blocker state to 'unblocked'", async () => { - let blocker = router.getBlocker("KEY", () => true); + let fn = () => true; + it("gets an 'unblocked' blocker after resetting navigation", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about"); - expect(router.state.blockers.get("KEY")?.state).toEqual("blocked"); - - router.state.blockers.get("KEY")?.reset?.(); - expect(router.state.blockers.get("KEY")?.state).toEqual("unblocked"); + router.getBlocker("KEY", fn).reset?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); }); - it("does not navigate", async () => { - let blocker = router.getBlocker("KEY", () => true); + it("stays at current location after resetting", async () => { + router.getBlocker("KEY", fn); + let pathnameBeforeNavigation = router.state.location.pathname; await router.navigate("/about"); - expect(router.state.location.pathname).toBe( - initialEntries[initialIndex] - ); + router.getBlocker("KEY", fn).reset?.(); - blocker.reset?.(); - expect(router.state.location.pathname).toEqual("/"); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe(pathnameBeforeNavigation); }); }); }); @@ -143,82 +179,137 @@ describe("blocking", () => { initialEntries, initialIndex, }), - routes: [{ path: "/" }, { path: "/about" }], + routes, }); router.initialize(); }); describe("blocker returns false", () => { - it("sets blocker state to 'unblocked'", async () => { - let blocker = router.getBlocker("KEY", () => false); + let fn = () => false; + it("navigates", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about", { replace: true }); - expect(blocker.state).toEqual("unblocked"); + expect(router.state.location.pathname).toBe("/about"); }); - it("navigates", async () => { - router.getBlocker("KEY", () => false); + it("gets an 'unblocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about", { replace: true }); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about", { replace: true }); - expect(router.state.location.pathname).toBe("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); }); }); describe("blocker returns true", () => { - it("set blocker state to 'blocked'", async () => { - let blocker = router.getBlocker("KEY", () => true); - await router.navigate("/about", { replace: true }); - expect(blocker.state).toEqual("blocked"); - }); + let fn = () => true; it("does not navigate", async () => { - router.getBlocker("KEY", () => true); + router.getBlocker("KEY", fn); await router.navigate("/about", { replace: true }); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] ); }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about", { replace: true }); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); }); describe("proceeds from blocked state", () => { - it("sets blocker state to 'proceeding'", async (done) => { - let blocker = router.getBlocker("KEY", () => true); + let fn = () => true; + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about", { replace: true }); - expect(blocker.state).toEqual("blocked"); - - blocker.proceed?.(); - expect(blocker.state).toEqual("proceeding"); + router.getBlocker("KEY", fn).proceed?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + }); }); - it("proceeds with blocked navigation", async () => { - let blocker = router.getBlocker("KEY", () => true); + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about", { replace: true }); - expect(router.state.location.pathname).toBe( - initialEntries[initialIndex] - ); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); - await blocker.proceed?.(); + it("navigates after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); expect(router.state.location.pathname).toEqual("/about"); }); + + it("replaces the current history entry after proceeding completes", async () => { + router.getBlocker("KEY", fn); + let historyLengthBeforeNavigation = window.history.length; + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(window.history.length).toEqual(historyLengthBeforeNavigation); + }); }); describe("resets from blocked state", () => { - it("sets blocker state to 'unblocked'", async () => { - let blocker = router.getBlocker("KEY", () => true); + let fn = () => true; + it("gets an 'unblocked' blocker after resetting navigation", async () => { + router.getBlocker("KEY", fn); await router.navigate("/about", { replace: true }); - expect(blocker.state).toEqual("blocked"); - - blocker.reset?.(); - expect(blocker.state).toEqual("unblocked"); + router.getBlocker("KEY", fn).reset?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); }); - it("does not navigate", async () => { - let blocker = router.getBlocker("KEY", () => true); + it("stays at current location after resetting", async () => { + router.getBlocker("KEY", fn); + let pathnameBeforeNavigation = router.state.location.pathname; await router.navigate("/about", { replace: true }); - expect(router.state.location.pathname).toBe( - initialEntries[initialIndex] - ); + router.getBlocker("KEY", fn).reset?.(); - blocker.reset?.(); - expect(router.state.location.pathname).toEqual("/"); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe(pathnameBeforeNavigation); }); }); }); @@ -232,25 +323,21 @@ describe("blocking", () => { initialEntries, initialIndex, }), - routes: [ - { path: "/" }, - { path: "/about" }, - { path: "/contact" }, - { path: "/help" }, - ], + routes, }); router.initialize(); }); describe("blocker returns false", () => { + let fn = () => false; it("set blocker state to unblocked", async () => { - let blocker = router.getBlocker("KEY", () => false); + router.getBlocker("KEY", fn); await router.navigate(-1); - expect(blocker.state).toEqual("unblocked"); + expect(router.getBlockerState("KEY")).toEqual("unblocked"); }); it("should navigate", async () => { - router.getBlocker("KEY", () => false); + router.getBlocker("KEY", fn); await router.navigate(-1); expect(router.state.location.pathname).toEqual( initialEntries[initialIndex - 1] @@ -259,69 +346,114 @@ describe("blocking", () => { }); describe("blocker returns true", () => { - it("set blocker state", async () => { - let blocker = router.getBlocker("KEY", () => true); - await router.navigate(-1); - expect(blocker.state).toEqual("blocked"); - }); + let fn = () => true; it("does not navigate", async () => { - router.getBlocker("KEY", () => true); + router.getBlocker("KEY", fn); await router.navigate(-1); expect(router.state.location.pathname).toBe( initialEntries[initialIndex] ); }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate(-1); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); }); describe("proceeds from blocked state", () => { - it("sets blocker state to 'proceeding'", async (done) => { - let blocker = router.getBlocker("KEY", () => true); - await router.navigate(-1); - expect(blocker.state).toEqual("blocked"); + let fn = () => true; + + // we want to navigate so that `/about` is the previous entry in the + // stack here since it has a loader that won't resolve immediately + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes, + }); + router.initialize(); + }); - blocker.proceed?.(); - expect(blocker.state).toEqual("proceeding"); + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + router.getBlocker("KEY", fn).proceed?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + }); }); - it("proceeds with blocked navigation", async () => { - let blocker = router.getBlocker("KEY", () => true); + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); await router.navigate(-1); - expect(router.state.location.pathname).toBe( - initialEntries[initialIndex] - ); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); - await blocker.proceed?.(); - expect(router.state.location.pathname).toBe( - initialEntries[initialIndex - 1] - ); + it("navigates after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toEqual("/about"); }); }); describe("resets from blocked state", () => { - it.todo("patches the history stack"); - - it("sets blocker state to 'unblocked'", async () => { - let blocker = router.getBlocker("KEY", () => true); + let fn = () => true; + it("gets an 'unblocked' blocker after resetting navigation", async () => { + router.getBlocker("KEY", fn); await router.navigate(-1); - expect(blocker.state).toEqual("blocked"); - - blocker.reset?.(); - expect(blocker.state).toEqual("unblocked"); + router.getBlocker("KEY", fn).reset?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); }); - it("does not navigate", async () => { - let blocker = router.getBlocker("KEY", () => true); + it("stays at current location after resetting", async () => { + router.getBlocker("KEY", fn); + let pathnameBeforeNavigation = router.state.location.pathname; await router.navigate(-1); - expect(router.state.location.pathname).toBe( - initialEntries[initialIndex] - ); + router.getBlocker("KEY", fn).reset?.(); - blocker.reset?.(); - expect(router.state.location.pathname).toEqual( - initialEntries[initialIndex] - ); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe(pathnameBeforeNavigation); }); }); }); }); + +function sleep(n: number = 500) { + return new Promise((r) => setTimeout(r, n)); +} diff --git a/packages/router/router.ts b/packages/router/router.ts index 852230b2bf..dd61f1f43c 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -202,6 +202,15 @@ export interface Router { */ getBlocker(key: string, fn: BlockerFunction): Blocker; + /** + * @internal + * PRIVATE - DO NOT USE + * + * Get the state of a navigation blocker + * @param key The identifier for the blocker + */ + getBlockerState(key: string): BlockerState; + /** * @internal * PRIVATE - DO NOT USE @@ -485,22 +494,27 @@ type FetcherStates = { export type Fetcher = FetcherStates[keyof FetcherStates]; -export type Blocker = - | { - state: "blocked"; - reset(): void; - proceed(): void; - } - | { - state: "unblocked"; - reset: undefined; - proceed: undefined; - } - | { - state: "proceeding"; - reset: undefined; - proceed: undefined; - }; +export type BlockerBlocked = { + state: "blocked"; + reset(): void; + proceed(): void; +}; + +export type BlockerUnblocked = { + state: "unblocked"; + reset: undefined; + proceed: undefined; +}; + +export type BlockerProceeding = { + state: "proceeding"; + reset: undefined; + proceed: undefined; +}; + +export type Blocker = BlockerUnblocked | BlockerBlocked | BlockerProceeding; + +export type BlockerState = Blocker["state"]; export type BlockerFunction = ( location: Location, @@ -2084,6 +2098,12 @@ export function createRouter(init: RouterInit): Router { return blocker; } + function getBlockerState(key: string): BlockerState { + let blocker = state.blockers.get(key); + if (!blocker) return "unblocked"; + return blocker.state; + } + function deleteBlocker(key: string) { state.blockers.delete(key); blockerFunctions.delete(key); @@ -2239,6 +2259,7 @@ export function createRouter(init: RouterInit): Router { deleteFetcher, dispose, getBlocker, + getBlockerState, deleteBlocker, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, From 50d7c80aaffce548defb71e76c352529d1bb46af Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 11 Jan 2023 13:55:39 -0800 Subject: [PATCH 26/47] add missing method in server --- packages/react-router-dom/server.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 61dfd9e781..463783fee1 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -305,6 +305,9 @@ export function createStaticRouter( getBlocker() { throw msg("getBlocker"); }, + getBlockerState() { + throw msg("getBlockerState"); + }, deleteBlocker() { throw msg("deleteBlocker"); }, From 139974d6f2e3c2660351f74c4d8e984cbf029bd6 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 11 Jan 2023 13:57:36 -0800 Subject: [PATCH 27/47] revert changes in data router example --- examples/data-router/src/routes.tsx | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/examples/data-router/src/routes.tsx b/examples/data-router/src/routes.tsx index 0f5427ce47..ce428df118 100644 --- a/examples/data-router/src/routes.tsx +++ b/examples/data-router/src/routes.tsx @@ -17,8 +17,6 @@ import { useRouteError, json, useActionData, - useBlocker, - usePrompt, } from "react-router-dom"; import type { Todos } from "./todos"; @@ -94,37 +92,10 @@ export async function homeLoader(): Promise { 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 ( <>

Home

Last loaded at: {data.date}

-
- - {blocker.state === "blocked" ? ( -
-

Navigation is blocked.

- - -
- ) : blocker.state === "proceeding" ? ( -

Proceeding...

- ) : null} -
); } From e6cbdbb4bb9f03a528fa6831ac62bdc6f3fe8e50 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 11 Jan 2023 13:59:24 -0800 Subject: [PATCH 28/47] update hook name in logs --- packages/react-router-dom/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 5517974cdb..1b4b6f0d91 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -717,6 +717,7 @@ enum DataRouterHook { UseScrollRestoration = "useScrollRestoration", UseSubmitImpl = "useSubmitImpl", UseFetcher = "useFetcher", + UseBlocker = "useBlocker", } enum DataRouterStateHook { @@ -984,7 +985,7 @@ export function useFormAction( let blockerKey = "blocker-singleton"; export function useBlocker(shouldBlock: boolean | BlockerFunction) { - let { router } = useDataRouterContext(DataRouterHook.UseFetcher); + let { router } = useDataRouterContext(DataRouterHook.UseBlocker); let blockerFunction = React.useCallback( (location, action) => { From a943da98ad0beb4cd72da4cd4f020846e549c61b Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Wed, 11 Jan 2023 14:55:53 -0800 Subject: [PATCH 29/47] update tests --- packages/router/__tests__/blocking-test.ts | 34 +++++++++++++++------- packages/router/__tests__/router-test.ts | 1 + 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/blocking-test.ts index 6d3768d10a..0325acccb6 100644 --- a/packages/router/__tests__/blocking-test.ts +++ b/packages/router/__tests__/blocking-test.ts @@ -140,7 +140,7 @@ describe("blocking", () => { await router.navigate("/about"); router.getBlocker("KEY", fn).proceed?.(); await sleep(LOADER_LATENCY_MS); - expect(router.state.location.pathname).toEqual("/about"); + expect(router.state.location.pathname).toBe("/about"); }); }); @@ -275,7 +275,7 @@ describe("blocking", () => { await router.navigate("/about", { replace: true }); router.getBlocker("KEY", fn).proceed?.(); await sleep(LOADER_LATENCY_MS); - expect(router.state.location.pathname).toEqual("/about"); + expect(router.state.location.pathname).toBe("/about"); }); it("replaces the current history entry after proceeding completes", async () => { @@ -284,7 +284,7 @@ describe("blocking", () => { await router.navigate("/about", { replace: true }); router.getBlocker("KEY", fn).proceed?.(); await sleep(LOADER_LATENCY_MS); - expect(window.history.length).toEqual(historyLengthBeforeNavigation); + expect(window.history.length).toBe(historyLengthBeforeNavigation); }); }); @@ -330,18 +330,32 @@ describe("blocking", () => { describe("blocker returns false", () => { let fn = () => false; - it("set blocker state to unblocked", async () => { + it("navigates", async () => { router.getBlocker("KEY", fn); await router.navigate(-1); - expect(router.getBlockerState("KEY")).toEqual("unblocked"); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex - 1] + ); }); - it("should navigate", async () => { + it("gets an 'unblocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate(-1); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { router.getBlocker("KEY", fn); await router.navigate(-1); - expect(router.state.location.pathname).toEqual( - initialEntries[initialIndex - 1] - ); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); }); }); @@ -423,7 +437,7 @@ describe("blocking", () => { await router.navigate(-1); router.getBlocker("KEY", fn).proceed?.(); await sleep(LOADER_LATENCY_MS); - expect(router.state.location.pathname).toEqual("/about"); + expect(router.state.location.pathname).toBe("/about"); }); }); diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index fc544f5595..a87c1fbf69 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -922,6 +922,7 @@ describe("a router", () => { restoreScrollPosition: null, revalidation: "idle", fetchers: new Map(), + blockers: new Map(), }); }); From 65bba99acd27defa7ca1ed76a38227e0236e1f10 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 12 Jan 2023 18:07:45 -0800 Subject: [PATCH 30/47] add use-blocker tests --- .../navigation-blocking/package-lock.json | 4 +- .../__tests__/use-blocker-test.tsx | 983 ++++++++++++++++++ ...ng-test.ts => navigation-blocking-test.ts} | 4 +- 3 files changed, 986 insertions(+), 5 deletions(-) create mode 100644 packages/react-router-dom/__tests__/use-blocker-test.tsx rename packages/router/__tests__/{blocking-test.ts => navigation-blocking-test.ts} (99%) diff --git a/examples/navigation-blocking/package-lock.json b/examples/navigation-blocking/package-lock.json index ec038ec93f..daba437c98 100644 --- a/examples/navigation-blocking/package-lock.json +++ b/examples/navigation-blocking/package-lock.json @@ -1,10 +1,10 @@ { - "name": "navigtion-blocking", + "name": "navigation-blocking", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "data-router", + "name": "navigation-blocking", "dependencies": { "react": "18.1.0", "react-dom": "18.1.0", diff --git a/packages/react-router-dom/__tests__/use-blocker-test.tsx b/packages/react-router-dom/__tests__/use-blocker-test.tsx new file mode 100644 index 0000000000..84e25b5c2f --- /dev/null +++ b/packages/react-router-dom/__tests__/use-blocker-test.tsx @@ -0,0 +1,983 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import type { Blocker, RouteObject } from "../index"; +import { + createMemoryRouter, + json, + NavLink, + Outlet, + RouterProvider, + useBlocker, + useNavigate, +} from "../index"; + +type Router = ReturnType; + +const LOADER_LATENCY_MS = 100; + +async function slowLoader() { + await sleep(LOADER_LATENCY_MS); + return json(null); +} + +describe("navigation blocking with useBlocker", () => { + let node: HTMLDivElement; + let router: Router; + let blocker: Blocker | null = null; + let root: ReactDOM.Root; + + beforeEach(() => { + node = document.createElement("div"); + document.body.appendChild(node); + }); + + afterEach(() => { + document.body.removeChild(node); + node = null!; + }); + + it("initializes an 'unblocked' blocker", async () => { + let initialEntries = ["/"]; + let routes: RouteObject[] = [ + { + path: "/", + element: React.createElement(() => { + let b = useBlocker(false); + blocker = b; + return null; + }), + }, + ]; + router = createMemoryRouter(routes, { initialEntries }); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + act(() => { + root.unmount(); + }); + }); + + describe("on navigation", () => { + describe("blocker returns false", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(false); + blocker = b; + return ( +
+ Home + About + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("navigates", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("About"); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + blocker = b; + return ( +
+ Home + About + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("does not navigate", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).not.toEqual("About"); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + }); + + describe("exiting from blocked state", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + blocker = b; + return ( +
+ Home + About + {b.state === "blocked" && ( +
+ + +
+ )} + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='proceed']")); + }); + expect(blocker).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("About"); + }); + + it("gets an 'unblocked' blocker after resetting navigation", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='reset']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='reset']")); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("Home"); + }); + }); + }); + + describe("on navigation", () => { + describe("blocker returns false", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(false); + blocker = b; + return ( +
+ + Home + + + About + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("navigates", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("About"); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + blocker = b; + return ( +
+ + Home + + + About + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("does not navigate", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).not.toEqual("About"); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + }); + + describe("exiting from blocked state", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + blocker = b; + return ( +
+ + Home + + + About + + {b.state === "blocked" && ( +
+ + +
+ )} + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='proceed']")); + }); + expect(blocker).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("About"); + }); + + it("gets an 'unblocked' blocker after resetting navigation", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='reset']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='reset']")); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("Home"); + }); + }); + }); + + describe("on POP navigation", () => { + describe("blocker returns false", () => { + beforeEach(() => { + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(false); + let navigate = useNavigate(); + blocker = b; + return ( +
+ + Home + + + About + + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + { + path: "/contact", + element:

Contact

, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("navigates", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("About"); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + beforeEach(() => { + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + let navigate = useNavigate(); + blocker = b; + return ( +
+ + Home + + + About + + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + { + path: "/contact", + element:

Contact

, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("does not navigate", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).not.toEqual("About"); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + }); + }); + }); + + describe("exiting from blocked state", () => { + beforeEach(() => { + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + let navigate = useNavigate(); + blocker = b; + return ( +
+ + Home + + + About + + + {b.state === "blocked" && ( +
+ + +
+ )} + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + { + path: "/contact", + element:

Contact

, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + act(() => { + click(node.querySelector("[data-action='proceed']")); + }); + expect(blocker).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("About"); + }); + + it("gets an 'unblocked' blocker after resetting navigation", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + act(() => { + click(node.querySelector("[data-action='reset']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + await act(async () => { + click(node.querySelector("[data-action='reset']")); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toEqual("Contact"); + }); + }); + }); +}); + +function sleep(n: number = 500) { + return new Promise((r) => setTimeout(r, n)); +} + +function click(target: EventTarget | null | undefined) { + target?.dispatchEvent( + new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + }) + ); +} diff --git a/packages/router/__tests__/blocking-test.ts b/packages/router/__tests__/navigation-blocking-test.ts similarity index 99% rename from packages/router/__tests__/blocking-test.ts rename to packages/router/__tests__/navigation-blocking-test.ts index 6d3768d10a..e5fe5e3a74 100644 --- a/packages/router/__tests__/blocking-test.ts +++ b/packages/router/__tests__/navigation-blocking-test.ts @@ -1,5 +1,3 @@ -/* eslint-disable jest/no-focused-tests */ - import type { Router } from "../index"; import { createMemoryHistory, createRouter } from "../index"; @@ -14,7 +12,7 @@ const routes = [ { path: "/help" }, ]; -describe("blocking", () => { +describe("navigation blocking", () => { let router: Router; it("initializes an 'unblocked' blocker", () => { router = createRouter({ From ba5c76d1b757b67df8670f4371bf674a95ce9067 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 12 Jan 2023 22:31:16 -0800 Subject: [PATCH 31/47] update tests --- packages/router/__tests__/router-memory-test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/router/__tests__/router-memory-test.ts b/packages/router/__tests__/router-memory-test.ts index 2a1635fac4..f4d133c518 100644 --- a/packages/router/__tests__/router-memory-test.ts +++ b/packages/router/__tests__/router-memory-test.ts @@ -48,6 +48,7 @@ describe("a memory router", () => { restoreScrollPosition: null, revalidation: "idle", fetchers: new Map(), + blockers: new Map(), }); router.dispose(); }); From 6fba7e51be2e0822fcd46864392c07c164d25849 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Thu, 12 Jan 2023 22:34:44 -0800 Subject: [PATCH 32/47] bump umd bundle size limit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0f8297498c..2a7f803300 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "38.5 kB" + "none": "41 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "12.5 kB" From f4d6b0022240c0ac7382e9345ef98320ea27d54f Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 09:56:26 -0800 Subject: [PATCH 33/47] update blocker and shouldBlock function interface --- .changeset/lazy-needles-shout.md | 5 ++ .../__tests__/use-blocker-test.tsx | 45 ++++++++---- packages/react-router-dom/index.tsx | 16 +++-- .../__tests__/navigation-blocking-test.ts | 22 ++++++ packages/router/router.ts | 69 ++++++++++++------- 5 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 .changeset/lazy-needles-shout.md diff --git a/.changeset/lazy-needles-shout.md b/.changeset/lazy-needles-shout.md new file mode 100644 index 0000000000..27bbb1dddb --- /dev/null +++ b/.changeset/lazy-needles-shout.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Added pass-through event listener options argument to `useBeforeUnload` diff --git a/packages/react-router-dom/__tests__/use-blocker-test.tsx b/packages/react-router-dom/__tests__/use-blocker-test.tsx index 84e25b5c2f..755bdba213 100644 --- a/packages/react-router-dom/__tests__/use-blocker-test.tsx +++ b/packages/react-router-dom/__tests__/use-blocker-test.tsx @@ -117,7 +117,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("About"); + expect(h1?.textContent).toBe("About"); }); it("gets an 'unblocked' blocker after navigation starts", async () => { @@ -128,6 +128,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -140,6 +141,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); }); @@ -196,7 +198,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).not.toEqual("About"); + expect(h1?.textContent).not.toBe("About"); }); it("gets a 'blocked' blocker after navigation starts", async () => { @@ -207,6 +209,7 @@ describe("navigation blocking with useBlocker", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); @@ -219,6 +222,7 @@ describe("navigation blocking with useBlocker", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); }); @@ -290,6 +294,7 @@ describe("navigation blocking with useBlocker", () => { state: "proceeding", proceed: undefined, reset: undefined, + location: expect.any(Object), }); }); @@ -305,6 +310,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -317,7 +323,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("About"); + expect(h1?.textContent).toBe("About"); }); it("gets an 'unblocked' blocker after resetting navigation", async () => { @@ -331,6 +337,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -344,7 +351,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("Home"); + expect(h1?.textContent).toBe("Home"); }); }); }); @@ -406,7 +413,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("About"); + expect(h1?.textContent).toBe("About"); }); it("gets an 'unblocked' blocker after navigation starts", async () => { @@ -417,6 +424,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -429,6 +437,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); }); @@ -489,7 +498,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).not.toEqual("About"); + expect(h1?.textContent).not.toBe("About"); }); it("gets a 'blocked' blocker after navigation starts", async () => { @@ -500,6 +509,7 @@ describe("navigation blocking with useBlocker", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); @@ -512,6 +522,7 @@ describe("navigation blocking with useBlocker", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); }); @@ -587,6 +598,7 @@ describe("navigation blocking with useBlocker", () => { state: "proceeding", proceed: undefined, reset: undefined, + location: expect.any(Object), }); }); @@ -602,6 +614,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -614,7 +627,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("About"); + expect(h1?.textContent).toBe("About"); }); it("gets an 'unblocked' blocker after resetting navigation", async () => { @@ -628,6 +641,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -641,7 +655,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("Home"); + expect(h1?.textContent).toBe("Home"); }); }); }); @@ -711,7 +725,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("About"); + expect(h1?.textContent).toBe("About"); }); it("gets an 'unblocked' blocker after navigation starts", async () => { @@ -722,6 +736,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -734,6 +749,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); }); @@ -802,7 +818,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).not.toEqual("About"); + expect(h1?.textContent).not.toBe("About"); }); it("gets a 'blocked' blocker after navigation starts", async () => { @@ -813,6 +829,7 @@ describe("navigation blocking with useBlocker", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); @@ -825,6 +842,7 @@ describe("navigation blocking with useBlocker", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); }); @@ -908,6 +926,7 @@ describe("navigation blocking with useBlocker", () => { state: "proceeding", proceed: undefined, reset: undefined, + location: expect.any(Object), }); }); @@ -923,6 +942,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -935,7 +955,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("About"); + expect(h1?.textContent).toBe("About"); }); it("gets an 'unblocked' blocker after resetting navigation", async () => { @@ -949,6 +969,7 @@ describe("navigation blocking with useBlocker", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -962,7 +983,7 @@ describe("navigation blocking with useBlocker", () => { await sleep(LOADER_LATENCY_MS); }); let h1 = node.querySelector("h1"); - expect(h1?.textContent).toEqual("Contact"); + expect(h1?.textContent).toBe("Contact"); }); }); }); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 3ff910790a..9fdf3845f8 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -65,6 +65,7 @@ import { export type { Blocker, + BlockerFunction, FormEncType, FormMethod, GetScrollRestorationKeyFunction, @@ -987,9 +988,9 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction) { let { router } = useDataRouterContext(DataRouterHook.UseBlocker); let blockerFunction = React.useCallback( - (location, action) => { + (args) => { return typeof shouldBlock === "function" - ? shouldBlock(location, action) === true + ? shouldBlock(args) === true : shouldBlock === true; }, [shouldBlock] @@ -1213,14 +1214,17 @@ function useScrollRestoration({ * `React.useCallback()`. */ export function useBeforeUnload( - callback: (event: BeforeUnloadEvent) => any + callback: (event: BeforeUnloadEvent) => any, + options?: { capture?: boolean } ): void { + let { capture } = options || {}; React.useEffect(() => { - window.addEventListener("beforeunload", callback); + let opts = capture != null ? { capture } : undefined; + window.addEventListener("beforeunload", callback, opts); return () => { - window.removeEventListener("beforeunload", callback); + window.removeEventListener("beforeunload", callback, opts); }; - }, [callback]); + }, [callback, capture]); } //#endregion diff --git a/packages/router/__tests__/navigation-blocking-test.ts b/packages/router/__tests__/navigation-blocking-test.ts index 0ea983e8e2..e0fdc616f2 100644 --- a/packages/router/__tests__/navigation-blocking-test.ts +++ b/packages/router/__tests__/navigation-blocking-test.ts @@ -30,6 +30,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -62,6 +63,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -72,6 +74,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); }); @@ -94,6 +97,7 @@ describe("navigation blocking", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); @@ -104,6 +108,7 @@ describe("navigation blocking", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); }); @@ -118,6 +123,7 @@ describe("navigation blocking", () => { state: "proceeding", proceed: undefined, reset: undefined, + location: expect.any(Object), }); }); @@ -130,6 +136,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -152,6 +159,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -197,6 +205,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -207,6 +216,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); }); @@ -229,6 +239,7 @@ describe("navigation blocking", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); @@ -239,6 +250,7 @@ describe("navigation blocking", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); }); @@ -253,6 +265,7 @@ describe("navigation blocking", () => { state: "proceeding", proceed: undefined, reset: undefined, + location: expect.any(Object), }); }); @@ -265,6 +278,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -296,6 +310,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined }); }); @@ -343,6 +358,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -353,6 +369,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); }); @@ -375,6 +392,7 @@ describe("navigation blocking", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); @@ -385,6 +403,7 @@ describe("navigation blocking", () => { state: "blocked", proceed: expect.any(Function), reset: expect.any(Function), + location: expect.any(Object), }); }); }); @@ -415,6 +434,7 @@ describe("navigation blocking", () => { state: "proceeding", proceed: undefined, reset: undefined, + location: expect.any(Object), }); }); @@ -427,6 +447,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); @@ -449,6 +470,7 @@ describe("navigation blocking", () => { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 08acd35320..75eeb15ef9 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -494,32 +494,36 @@ type FetcherStates = { export type Fetcher = FetcherStates[keyof FetcherStates]; -export type BlockerBlocked = { +export interface BlockerBlocked { state: "blocked"; reset(): void; proceed(): void; -}; + location: Location; +} -export type BlockerUnblocked = { +export interface BlockerUnblocked { state: "unblocked"; reset: undefined; proceed: undefined; -}; + location: undefined; +} -export type BlockerProceeding = { +export interface BlockerProceeding { state: "proceeding"; reset: undefined; proceed: undefined; -}; + location: Location; +} export type Blocker = BlockerUnblocked | BlockerBlocked | BlockerProceeding; export type BlockerState = Blocker["state"]; -export type BlockerFunction = ( - location: Location, - action: HistoryAction -) => boolean; +export type BlockerFunction = (args: { + currentLocation: Location; + nextLocation: Location; + historyAction: HistoryAction; +}) => boolean; interface ShortCircuitable { /** @@ -622,10 +626,11 @@ export const IDLE_FETCHER: FetcherStates["Idle"] = { formData: undefined, }; -export const IDLE_BLOCKER: Blocker = { +export const IDLE_BLOCKER: BlockerUnblocked = { state: "unblocked", proceed: undefined, reset: undefined, + location: undefined, }; const isBrowser = @@ -788,7 +793,11 @@ export function createRouter(init: RouterInit): Router { return; } - let blockerKey = shouldBlockNavigation(historyAction, location); + let blockerKey = shouldBlockNavigation({ + currentLocation: state.location, + nextLocation: location, + historyAction, + }); if (blockerKey) { // Restore the URL to match the current UI, but don't update router state ignoreNextHistoryUpdate = true; @@ -797,11 +806,13 @@ export function createRouter(init: RouterInit): Router { // Put the blocker into a blocked state updateBlocker(blockerKey, { state: "blocked", + location, proceed() { updateBlocker(blockerKey!, { state: "proceeding", proceed: undefined, reset: undefined, + location, }); // Re-do the same POP navigation we just blocked init.history.go(delta); @@ -954,16 +965,17 @@ export function createRouter(init: RouterInit): Router { let { path, submission, error } = normalizeNavigateOptions(to, opts); - let location = createLocation(state.location, path, opts && opts.state); + let currentLocation = state.location; + let nextLocation = createLocation(state.location, path, opts && opts.state); // When using navigate as a PUSH/REPLACE we aren't reading an already-encoded // URL from window.location, so we need to encode it here so the behavior // remains the same as POP and non-data-router usages. new URL() does all // the same encoding we'd get from a history.pushState/window.location read // without having to touch history - location = { - ...location, - ...init.history.encodeLocation(location), + nextLocation = { + ...nextLocation, + ...init.history.encodeLocation(nextLocation), }; let userReplace = opts && opts.replace != null ? opts.replace : undefined; @@ -991,16 +1003,22 @@ export function createRouter(init: RouterInit): Router { ? opts.preventScrollReset === true : undefined; - let blockerKey = shouldBlockNavigation(historyAction, location); + let blockerKey = shouldBlockNavigation({ + currentLocation, + nextLocation, + historyAction, + }); if (blockerKey) { // Put the blocker into a blocked state updateBlocker(blockerKey, { state: "blocked", + location: nextLocation, proceed() { updateBlocker(blockerKey!, { state: "proceeding", proceed: undefined, reset: undefined, + location: nextLocation, }); // Send the same navigation through navigate(to, opts); @@ -1013,7 +1031,7 @@ export function createRouter(init: RouterInit): Router { return; } - return await startNavigation(historyAction, location, { + return await startNavigation(historyAction, nextLocation, { submission, // Send through the formData serialization error if we have one so we can // render at the right error boundary after we match routes @@ -2139,10 +2157,15 @@ export function createRouter(init: RouterInit): Router { updateState({ blockers: new Map(state.blockers) }); } - function shouldBlockNavigation( - historyAction: HistoryAction, - location: Location - ): string | undefined { + function shouldBlockNavigation({ + currentLocation, + nextLocation, + historyAction, + }: { + currentLocation: Location; + nextLocation: Location; + historyAction: HistoryAction; + }): string | undefined { if (activeBlocker == null) { return; } @@ -2164,7 +2187,7 @@ export function createRouter(init: RouterInit): Router { // At this point, we know we're unblocked/blocked so we need to check the // user-provided blocker function - if (blockerFunction(location, historyAction)) { + if (blockerFunction({ currentLocation, nextLocation, historyAction })) { return activeBlocker; } } From e25325e405de630ed78bd4b43e9bed7b499464f5 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 09:56:36 -0800 Subject: [PATCH 34/47] update example --- examples/navigation-blocking/src/app.tsx | 109 +++++++++++++++++++++-- 1 file changed, 101 insertions(+), 8 deletions(-) diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index 180a7e2770..8bcdd0f01c 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -1,5 +1,5 @@ import React from "react"; -import type { Blocker } from "react-router-dom"; +import type { Blocker, BlockerFunction } from "react-router-dom"; import { createBrowserRouter, createRoutesFromElements, @@ -9,8 +9,10 @@ import { Outlet, Route, RouterProvider, + useBeforeUnload, useBlocker, useLocation, + useNavigate, } from "react-router-dom"; let router = createBrowserRouter( @@ -25,11 +27,20 @@ let router = createBrowserRouter( element={ <>

Three

- + + + } + /> + json({ ok: true })} + element={ + <> +

Four

+ } /> - Four} /> Five} /> ) @@ -66,8 +77,8 @@ function Layout() { Index   One   Two   - Three (Form)   - Four   + Three (Form with blocker)   + Four (Form with prompt)   Five   External link to Remix Docs   @@ -79,12 +90,61 @@ function Layout() { ); } -function ImportantForm() { +// You can abstract `useBlocker` to use the browser's `window.confirm` dialog to +// determine whether or not the user should navigate within the current origin. +// `useBlocker` can also be used in conjunction with `useBeforeUnload` to +// prevent navigation away from the current origin. + +// IMPORTANT: There are edge cases with this behavior in which React Router +// cannot reliably access the correct location in the history stack. In such +// cases the user may attempt to stay on the page but the app navigates anyway, +// or the app may stay on the correct page but the browser's history stack gets +// out of whack. You should test your own implementation thoroughly to make sure +// the tradeoffs are right for your users. +function usePrompt( + shouldPrompt: string | null | undefined | false, + opts: { + beforeUnload?: boolean; + } = {} +) { + let { beforeUnload = false } = opts; + let navigate = useNavigate(); + let blocker = useBlocker(!!shouldPrompt); + let previousBlockerState = React.useRef(null); + React.useEffect(() => { + // we only call this once when the blocker state changes. This ignores + // changes to shouldPrompt to prevent multiple dialogs from being queued up + if (blocker.state === previousBlockerState.current) return; + + if (blocker.state === "blocked" && typeof shouldPrompt === "string") { + blocker.reset(); + let shouldProceed = window.confirm(shouldPrompt); + if (shouldProceed) { + navigate(blocker.location); + } + } + + previousBlockerState.current = blocker.state; + }, [blocker.state, blocker, shouldPrompt, navigate]); + + useBeforeUnload( + React.useCallback( + (event) => { + if (beforeUnload && shouldPrompt) { + event.preventDefault(); + event.returnValue = shouldPrompt; + } + }, + [shouldPrompt, beforeUnload] + ), + { capture: true } + ); +} + +function ImportantFormWithBlocker() { let [value, setValue] = React.useState(""); let isBlocked = value !== ""; let blocker = useBlocker(isBlocked); - router.getBlocker("a", () => false); - router.getBlocker("b", () => false); // Reset the blocker if the user cleans the form React.useEffect(() => { @@ -135,3 +195,36 @@ function ImportantForm() { ); } + +function ImportantFormWithPrompt() { + let [value, setValue] = React.useState(""); + let isBlocked = value !== ""; + usePrompt(isBlocked && "Are you sure you want to leave?", { + beforeUnload: true, + }); + + return ( + <> +

+ Is the form dirty?{" "} + {isBlocked ? ( + Yes + ) : ( + No + )} +

+ +
+ + +
+ + ); +} From 27568f76b1a4d8b28359ec00dc9fe83936520d55 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 10:04:03 -0800 Subject: [PATCH 35/47] update example and mark exports as unstable --- .../navigation-blocking/package-lock.json | 48 ++++++++++--------- examples/navigation-blocking/package.json | 2 +- examples/navigation-blocking/src/app.tsx | 7 ++- packages/react-router-dom/index.tsx | 8 ++-- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/examples/navigation-blocking/package-lock.json b/examples/navigation-blocking/package-lock.json index daba437c98..0ec340c526 100644 --- a/examples/navigation-blocking/package-lock.json +++ b/examples/navigation-blocking/package-lock.json @@ -8,7 +8,7 @@ "dependencies": { "react": "18.1.0", "react-dom": "18.1.0", - "react-router-dom": "^6.4.0" + "react-router-dom": "^6.6.2" }, "devDependencies": { "@rollup/plugin-replace": "4.0.0", @@ -462,9 +462,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.0.tgz", - "integrity": "sha512-SCR1cxRSMNKjaVYptCzBApPDqGwa3FGdjVHc+rOToocNPHQdIYLZBfv/3f+KvYuXDkUGVIW9IAzmPNZDRL1I4A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.2.1.tgz", + "integrity": "sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ==", "engines": { "node": ">=14" } @@ -1309,11 +1309,11 @@ } }, "node_modules/react-router": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.0.tgz", - "integrity": "sha512-B+5bEXFlgR1XUdHYR6P94g299SjrfCBMmEDJNcFbpAyRH1j1748yt9NdDhW3++nw1lk3zQJ6aOO66zUx3KlTZg==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.6.2.tgz", + "integrity": "sha512-uJPG55Pek3orClbURDvfljhqFvMgJRo59Pktywkk8hUUkTY2aRfza8Yhl/vZQXs+TNQyr6tu+uqz/fLxPICOGQ==", "dependencies": { - "@remix-run/router": "1.0.0" + "@remix-run/router": "1.2.1" }, "engines": { "node": ">=14" @@ -1323,11 +1323,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.0.tgz", - "integrity": "sha512-4Aw1xmXKeleYYQ3x0Lcl2undHR6yMjXZjd9DKZd53SGOYqirrUThyUb0wwAX5VZAyvSuzjNJmZlJ3rR9+/vzqg==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.6.2.tgz", + "integrity": "sha512-6SCDXxRQqW5af8ImOqKza7icmQ47/EMbz572uFjzvcArg3lZ+04PxSPp8qGs+p2Y+q+b+S/AjXv8m8dyLndIIA==", "dependencies": { - "react-router": "6.4.0" + "@remix-run/router": "1.2.1", + "react-router": "6.6.2" }, "engines": { "node": ">=14" @@ -1816,9 +1817,9 @@ } }, "@remix-run/router": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.0.tgz", - "integrity": "sha512-SCR1cxRSMNKjaVYptCzBApPDqGwa3FGdjVHc+rOToocNPHQdIYLZBfv/3f+KvYuXDkUGVIW9IAzmPNZDRL1I4A==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.2.1.tgz", + "integrity": "sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ==" }, "@rollup/plugin-replace": { "version": "4.0.0", @@ -2343,19 +2344,20 @@ "dev": true }, "react-router": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.0.tgz", - "integrity": "sha512-B+5bEXFlgR1XUdHYR6P94g299SjrfCBMmEDJNcFbpAyRH1j1748yt9NdDhW3++nw1lk3zQJ6aOO66zUx3KlTZg==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.6.2.tgz", + "integrity": "sha512-uJPG55Pek3orClbURDvfljhqFvMgJRo59Pktywkk8hUUkTY2aRfza8Yhl/vZQXs+TNQyr6tu+uqz/fLxPICOGQ==", "requires": { - "@remix-run/router": "1.0.0" + "@remix-run/router": "1.2.1" } }, "react-router-dom": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.0.tgz", - "integrity": "sha512-4Aw1xmXKeleYYQ3x0Lcl2undHR6yMjXZjd9DKZd53SGOYqirrUThyUb0wwAX5VZAyvSuzjNJmZlJ3rR9+/vzqg==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.6.2.tgz", + "integrity": "sha512-6SCDXxRQqW5af8ImOqKza7icmQ47/EMbz572uFjzvcArg3lZ+04PxSPp8qGs+p2Y+q+b+S/AjXv8m8dyLndIIA==", "requires": { - "react-router": "6.4.0" + "@remix-run/router": "1.2.1", + "react-router": "6.6.2" } }, "resolve": { diff --git a/examples/navigation-blocking/package.json b/examples/navigation-blocking/package.json index d828fa3f2a..5c18774281 100644 --- a/examples/navigation-blocking/package.json +++ b/examples/navigation-blocking/package.json @@ -9,7 +9,7 @@ "dependencies": { "react": "18.1.0", "react-dom": "18.1.0", - "react-router-dom": "^6.4.0" + "react-router-dom": "^6.6.2" }, "devDependencies": { "@rollup/plugin-replace": "4.0.0", diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index 8bcdd0f01c..a585912dd1 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -1,5 +1,8 @@ import React from "react"; -import type { Blocker, BlockerFunction } from "react-router-dom"; +import type { + unstable_Blocker as Blocker, + unstable_BlockerFunction as BlockerFunction, +} from "react-router-dom"; import { createBrowserRouter, createRoutesFromElements, @@ -10,7 +13,7 @@ import { Route, RouterProvider, useBeforeUnload, - useBlocker, + unstable_useBlocker as useBlocker, useLocation, useNavigate, } from "react-router-dom"; diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 9fdf3845f8..e27c8e0799 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -64,8 +64,8 @@ import { //////////////////////////////////////////////////////////////////////////////// export type { - Blocker, - BlockerFunction, + Blocker as unstable_Blocker, + BlockerFunction as unstable_BlockerFunction, FormEncType, FormMethod, GetScrollRestorationKeyFunction, @@ -984,7 +984,7 @@ export function useFormAction( // cases for multi-blocker yet let blockerKey = "blocker-singleton"; -export function useBlocker(shouldBlock: boolean | BlockerFunction) { +function useBlocker(shouldBlock: boolean | BlockerFunction) { let { router } = useDataRouterContext(DataRouterHook.UseBlocker); let blockerFunction = React.useCallback( @@ -1004,6 +1004,8 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction) { return blocker; } +export { useBlocker as unstable_useBlocker }; + function createFetcherForm(fetcherKey: string, routeId: string) { let FetcherForm = React.forwardRef( (props, ref) => { From fa00b37147726e75d8eb77ae141e87de5698b62f Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 10:15:46 -0800 Subject: [PATCH 36/47] add changeset --- .changeset/violet-timers-type.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/violet-timers-type.md diff --git a/.changeset/violet-timers-type.md b/.changeset/violet-timers-type.md new file mode 100644 index 0000000000..5299abb23c --- /dev/null +++ b/.changeset/violet-timers-type.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": minor +"@remix-run/router": minor +--- + +Add `unstable_useBlocker` hook for blocking navigations within the app's location origin From 546e3ac038ee627eeab9c74fef1fc22a17227ac1 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 10:16:59 -0800 Subject: [PATCH 37/47] update changeset --- .changeset/violet-timers-type.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/violet-timers-type.md b/.changeset/violet-timers-type.md index 5299abb23c..0f97aaab93 100644 --- a/.changeset/violet-timers-type.md +++ b/.changeset/violet-timers-type.md @@ -1,6 +1,5 @@ --- "react-router-dom": minor -"@remix-run/router": minor --- Add `unstable_useBlocker` hook for blocking navigations within the app's location origin From 76e7fde7800cd6fb0ba9d3e6e6d166e6237b3c9b Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 10:18:07 -0800 Subject: [PATCH 38/47] update readme --- examples/navigation-blocking/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/navigation-blocking/README.md b/examples/navigation-blocking/README.md index 7cfccd32b3..f2875a1c51 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 `useBlocker` to prevent navigating away from a page where you might lose user-entered form data. A potential better UX for this is storing user-entered information in `sessionStorage` and pre-populating the form on return. +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. ## Preview From 2334a5bf78ebd171fc1e14024638c4de66147862 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 10:22:25 -0800 Subject: [PATCH 39/47] relax reliance on boolean type for blocker function --- packages/react-router-dom/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index e27c8e0799..de91e6c563 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -990,8 +990,8 @@ function useBlocker(shouldBlock: boolean | BlockerFunction) { let blockerFunction = React.useCallback( (args) => { return typeof shouldBlock === "function" - ? shouldBlock(args) === true - : shouldBlock === true; + ? !!shouldBlock(args) + : !!shouldBlock; }, [shouldBlock] ); From b5079937cfe4ba991c4be2177d7a917c0008148e Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 10:49:07 -0800 Subject: [PATCH 40/47] update tests --- packages/react-router-dom/__tests__/use-blocker-test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router-dom/__tests__/use-blocker-test.tsx b/packages/react-router-dom/__tests__/use-blocker-test.tsx index 755bdba213..417540729c 100644 --- a/packages/react-router-dom/__tests__/use-blocker-test.tsx +++ b/packages/react-router-dom/__tests__/use-blocker-test.tsx @@ -1,14 +1,14 @@ import * as React from "react"; import * as ReactDOM from "react-dom/client"; import { act } from "react-dom/test-utils"; -import type { Blocker, RouteObject } from "../index"; +import type { unstable_Blocker as Blocker, RouteObject } from "../index"; import { createMemoryRouter, json, NavLink, Outlet, RouterProvider, - useBlocker, + unstable_useBlocker as useBlocker, useNavigate, } from "../index"; From e1426d06e3e053fc7c7eda5a4a220b009e701cf5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 13 Jan 2023 14:59:48 -0500 Subject: [PATCH 41/47] Revert to prior usePrompt example --- examples/navigation-blocking/src/app.tsx | 40 +++++++++++------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index a585912dd1..9894f90966 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -105,40 +105,36 @@ function Layout() { // out of whack. You should test your own implementation thoroughly to make sure // the tradeoffs are right for your users. function usePrompt( - shouldPrompt: string | null | undefined | false, - opts: { + message: string | null | undefined | false, + { + beforeUnload, + }: { beforeUnload?: boolean; } = {} ) { - let { beforeUnload = false } = opts; - let navigate = useNavigate(); - let blocker = useBlocker(!!shouldPrompt); - let previousBlockerState = React.useRef(null); + let blocker = useBlocker( + React.useCallback( + () => (typeof message === "string" ? !window.confirm(message) : false), + [message] + ) + ); + let prevState = React.useRef(blocker.state); React.useEffect(() => { - // we only call this once when the blocker state changes. This ignores - // changes to shouldPrompt to prevent multiple dialogs from being queued up - if (blocker.state === previousBlockerState.current) return; - - if (blocker.state === "blocked" && typeof shouldPrompt === "string") { + if (blocker.state === "blocked") { blocker.reset(); - let shouldProceed = window.confirm(shouldPrompt); - if (shouldProceed) { - navigate(blocker.location); - } } - - previousBlockerState.current = blocker.state; - }, [blocker.state, blocker, shouldPrompt, navigate]); + prevState.current = blocker.state; + }, [blocker]); useBeforeUnload( React.useCallback( (event) => { - if (beforeUnload && shouldPrompt) { + if (beforeUnload && typeof message === "string") { event.preventDefault(); - event.returnValue = shouldPrompt; + event.returnValue = message; } }, - [shouldPrompt, beforeUnload] + [message, beforeUnload] ), { capture: true } ); @@ -202,7 +198,7 @@ function ImportantFormWithBlocker() { function ImportantFormWithPrompt() { let [value, setValue] = React.useState(""); let isBlocked = value !== ""; - usePrompt(isBlocked && "Are you sure you want to leave?", { + usePrompt(isBlocked && "Are you sure you want to leave there buddy?", { beforeUnload: true, }); From 16e464f5d7abb4de7cad2b02d8cab65fdadddc26 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 13 Jan 2023 15:35:13 -0500 Subject: [PATCH 42/47] Move useBlocker to react-router, remove getBlockerState --- packages/react-router-dom/index.tsx | 34 +++------------------------- packages/react-router-dom/server.tsx | 3 --- packages/react-router/index.ts | 6 +++++ packages/react-router/lib/hooks.tsx | 33 +++++++++++++++++++++++++++ packages/router/router.ts | 24 +++----------------- 5 files changed, 45 insertions(+), 55 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index de91e6c563..f5a1dd4590 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -25,8 +25,6 @@ import { UNSAFE_enhanceManualRouteObjects as enhanceManualRouteObjects, } from "react-router"; import type { - Blocker, - BlockerFunction, BrowserHistory, Fetcher, FormEncType, @@ -64,8 +62,6 @@ import { //////////////////////////////////////////////////////////////////////////////// export type { - Blocker as unstable_Blocker, - BlockerFunction as unstable_BlockerFunction, FormEncType, FormMethod, GetScrollRestorationKeyFunction, @@ -80,6 +76,8 @@ export type { ActionFunction, ActionFunctionArgs, AwaitProps, + unstable_Blocker, + unstable_BlockerFunction, DataRouteMatch, DataRouteObject, Fetcher, @@ -146,6 +144,7 @@ export { useActionData, useAsyncError, useAsyncValue, + unstable_useBlocker, useHref, useInRouterContext, useLoaderData, @@ -717,7 +716,6 @@ enum DataRouterHook { UseScrollRestoration = "useScrollRestoration", UseSubmitImpl = "useSubmitImpl", UseFetcher = "useFetcher", - UseBlocker = "useBlocker", } enum DataRouterStateHook { @@ -980,32 +978,6 @@ export function useFormAction( return createPath(path); } -// useBlocker() is a singleton for now since we don't have any compelling use -// cases for multi-blocker yet -let blockerKey = "blocker-singleton"; - -function useBlocker(shouldBlock: boolean | BlockerFunction) { - let { router } = useDataRouterContext(DataRouterHook.UseBlocker); - - let blockerFunction = React.useCallback( - (args) => { - return typeof shouldBlock === "function" - ? !!shouldBlock(args) - : !!shouldBlock; - }, - [shouldBlock] - ); - - let blocker = router.getBlocker(blockerKey, blockerFunction); - - // Cleanup on unmount - React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]); - - return blocker; -} - -export { useBlocker as unstable_useBlocker }; - function createFetcherForm(fetcherKey: string, routeId: string) { let FetcherForm = React.forwardRef( (props, ref) => { diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index a7c61cb39c..3ace651b43 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -301,9 +301,6 @@ export function createStaticRouter( getBlocker() { throw msg("getBlocker"); }, - getBlockerState() { - throw msg("getBlockerState"); - }, deleteBlocker() { throw msg("deleteBlocker"); }, diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 2af02fef6c..3d55ed2e22 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -1,6 +1,8 @@ import type { ActionFunction, ActionFunctionArgs, + Blocker, + BlockerFunction, Fetcher, HydrationState, JsonFunction, @@ -82,6 +84,7 @@ import { } from "./lib/context"; import type { NavigateFunction } from "./lib/hooks"; import { + useBlocker, useHref, useInRouterContext, useLocation, @@ -114,6 +117,8 @@ export type { ActionFunction, ActionFunctionArgs, AwaitProps, + Blocker as unstable_Blocker, + BlockerFunction as unstable_BlockerFunction, DataRouteMatch, DataRouteObject, Fetcher, @@ -179,6 +184,7 @@ export { useActionData, useAsyncError, useAsyncValue, + useBlocker as unstable_useBlocker, useHref, useInRouterContext, useLoaderData, diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 96ff63e747..eb4bc220a8 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1,5 +1,7 @@ import * as React from "react"; import type { + Blocker, + BlockerFunction, Location, ParamParseKey, Params, @@ -650,6 +652,7 @@ export function _renderMatches( } enum DataRouterHook { + UseBlocker = "useBlocker", UseRevalidator = "useRevalidator", } @@ -818,6 +821,36 @@ export function useAsyncError(): unknown { return value?._error; } +// useBlocker() is a singleton for now since we don't have any compelling use +// cases for multi-blocker yet +let blockerKey = "blocker-singleton"; + +/** + * Allow the application to block navigations within the SPA and present the + * user a confirmation dialog to confirm the navigation. Mostly used to avoid + * using half-filled form data. This does not handle hard-reloads or + * cross-origin navigations. + */ +export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker { + let { router } = useDataRouterContext(DataRouterHook.UseBlocker); + + let blockerFunction = React.useCallback( + (args) => { + return typeof shouldBlock === "function" + ? !!shouldBlock(args) + : !!shouldBlock; + }, + [shouldBlock] + ); + + let blocker = router.getBlocker(blockerKey, blockerFunction); + + // Cleanup on unmount + React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]); + + return blocker; +} + const alreadyWarned: Record = {}; function warningOnce(key: string, cond: boolean, message: string) { diff --git a/packages/router/router.ts b/packages/router/router.ts index 75eeb15ef9..ceedeb6493 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -201,15 +201,6 @@ export interface Router { */ getBlocker(key: string, fn: BlockerFunction): Blocker; - /** - * @internal - * PRIVATE - DO NOT USE - * - * Get the state of a navigation blocker - * @param key The identifier for the blocker - */ - getBlockerState(key: string): BlockerState; - /** * @internal * PRIVATE - DO NOT USE @@ -494,21 +485,21 @@ type FetcherStates = { export type Fetcher = FetcherStates[keyof FetcherStates]; -export interface BlockerBlocked { +interface BlockerBlocked { state: "blocked"; reset(): void; proceed(): void; location: Location; } -export interface BlockerUnblocked { +interface BlockerUnblocked { state: "unblocked"; reset: undefined; proceed: undefined; location: undefined; } -export interface BlockerProceeding { +interface BlockerProceeding { state: "proceeding"; reset: undefined; proceed: undefined; @@ -517,8 +508,6 @@ export interface BlockerProceeding { export type Blocker = BlockerUnblocked | BlockerBlocked | BlockerProceeding; -export type BlockerState = Blocker["state"]; - export type BlockerFunction = (args: { currentLocation: Location; nextLocation: Location; @@ -2126,12 +2115,6 @@ export function createRouter(init: RouterInit): Router { return blocker; } - function getBlockerState(key: string): BlockerState { - let blocker = state.blockers.get(key); - if (!blocker) return "unblocked"; - return blocker.state; - } - function deleteBlocker(key: string) { state.blockers.delete(key); blockerFunctions.delete(key); @@ -2292,7 +2275,6 @@ export function createRouter(init: RouterInit): Router { deleteFetcher, dispose, getBlocker, - getBlockerState, deleteBlocker, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, From b9739bac62e589440122434bf5bab63d97af4bcd Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 13 Jan 2023 16:04:10 -0500 Subject: [PATCH 43/47] Minor updates --- examples/navigation-blocking/src/app.tsx | 39 ++++++++++++++---------- packages/react-router-native/index.tsx | 3 ++ packages/router/history.ts | 5 ++- packages/router/router.ts | 12 ++------ 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index 9894f90966..805d10223b 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -152,21 +152,6 @@ function ImportantFormWithBlocker() { } }, [blocker, isBlocked]); - // Display our confirmation UI - const blockerUI: Record = { - unblocked:

Blocker is currently unblocked

, - blocked: ( - <> -

Blocked the last navigation

- - - - ), - proceeding: ( -

Proceeding through blocked navigation

- ), - }; - return ( <>

@@ -190,11 +175,33 @@ function ImportantFormWithBlocker() { - {blockerUI[blocker.state]} + {blocker ? : null} ); } +function ConfirmNavigation({ blocker }: { blocker: Blocker }) { + if (blocker.state === "blocked") { + return ( + <> +

+ Blocked the last navigation to {blocker.location.pathname} +

+ + + + ); + } + + if (blocker.state === "proceeding") { + return ( +

Proceeding through blocked navigation

+ ); + } + + return

Blocker is currently unblocked

; +} + function ImportantFormWithPrompt() { let [value, setValue] = React.useState(""); let isBlocked = value !== ""; diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index 5d66fccb83..d89e3fba19 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -23,6 +23,8 @@ export type { ActionFunction, ActionFunctionArgs, AwaitProps, + unstable_Blocker, + unstable_BlockerFunction, DataRouteMatch, DataRouteObject, Fetcher, @@ -89,6 +91,7 @@ export { useActionData, useAsyncError, useAsyncValue, + unstable_useBlocker, useHref, useInRouterContext, useLoaderData, diff --git a/packages/router/history.ts b/packages/router/history.ts index c14cf41ac5..fee3e1191f 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -82,6 +82,9 @@ export interface Update { */ location: Location; + /** + * The delta between this location and the former location in the history stack + */ delta: number; } @@ -615,8 +618,8 @@ function getUrlBasedHistory( if (nextIndex != null) { let delta = nextIndex - index; action = nextAction; + index = nextIndex; if (listener) { - index = nextIndex; listener({ action, location: history.location, delta }); } } else { diff --git a/packages/router/router.ts b/packages/router/router.ts index ceedeb6493..21ae862585 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -1112,15 +1112,6 @@ export function createRouter(init: RouterInit): Router { return; } - // Short circuit if navigation is blocked - if ( - Array.from(state.blockers).some(([_, blocker]) => { - return blocker.state === "blocked"; - }) - ) { - return; - } - // Short circuit if it's only a hash change if (isHashChangeOnly(state.location, location)) { completeNavigation(location, { matches }); @@ -2127,6 +2118,8 @@ export function createRouter(init: RouterInit): Router { function updateBlocker(key: string, newBlocker: Blocker) { let blocker = state.blockers.get(key) || IDLE_BLOCKER; + // Poor mans state machine :) + // https://mermaid.live/edit#pako:eNqVkc9OwzAMxl8l8nnjAYrEtDIOHEBIgwvKJTReGy3_lDpIqO27k6awMG0XcrLlnz87nwdonESogKXXBuE79rq75XZO3-yHds0RJVuv70YrPlUrCEe2HfrORS3rubqZfuhtpg5C9wk5tZ4VKcRUq88q9Z8RS0-48cE1iHJkL0ugbHuFLus9L6spZy8nX9MP2CNdomVaposqu3fGayT8T8-jJQwhepo_UtpgBQaDEUom04dZhAN1aJBDlUKJBxE1ceB2Smj0Mln-IBW5AFU2dwUiktt_2Qaq2dBfaKdEup85UV7Yd-dKjlnkabl2Pvr0DTkTreM invariant( (blocker.state === "unblocked" && newBlocker.state === "blocked") || (blocker.state === "blocked" && newBlocker.state === "blocked") || @@ -3673,5 +3666,4 @@ function getTargetMatch( let pathMatches = getPathContributingMatches(matches); return pathMatches[pathMatches.length - 1]; } - //#endregion From 28da8cb5e74e9ee885d0a825ae45d7ed61cc79b0 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 13:08:42 -0800 Subject: [PATCH 44/47] update build script --- rollup.config.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index d4f780327f..ba258c604f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,9 +2,15 @@ const fs = require("fs"); const path = require("path"); module.exports = function rollup(options) { - return fs - .readdirSync("packages") + return [ + "router", + "react-router", + "react-router-dom", + "react-router-dom-v5-compat", + "react-router-native", + ] .flatMap((dir) => { + // if (dir !== "router") return null; let configPath = path.join("packages", dir, "rollup.config.js"); try { fs.readFileSync(configPath); From b29b5e0d6d2005188f64ac6f81fc6e86dadc18b5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 13 Jan 2023 16:08:59 -0500 Subject: [PATCH 45/47] Bump bundle --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a7f803300..3cb029f761 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "none": "41 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "12.5 kB" + "none": "13 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { "none": "15 kB" From 887314a883d2e54539665d7834548d5cec657b75 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 13:09:18 -0800 Subject: [PATCH 46/47] export from react-router-native --- packages/react-router-native/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index 5d66fccb83..d89e3fba19 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -23,6 +23,8 @@ export type { ActionFunction, ActionFunctionArgs, AwaitProps, + unstable_Blocker, + unstable_BlockerFunction, DataRouteMatch, DataRouteObject, Fetcher, @@ -89,6 +91,7 @@ export { useActionData, useAsyncError, useAsyncValue, + unstable_useBlocker, useHref, useInRouterContext, useLoaderData, From 774917212351250d715309103385c1f77cf26fec Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 13 Jan 2023 13:15:50 -0800 Subject: [PATCH 47/47] rm prompt from example --- examples/navigation-blocking/src/app.tsx | 104 +---------------------- 1 file changed, 4 insertions(+), 100 deletions(-) diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx index 805d10223b..29d4457a93 100644 --- a/examples/navigation-blocking/src/app.tsx +++ b/examples/navigation-blocking/src/app.tsx @@ -1,8 +1,5 @@ import React from "react"; -import type { - unstable_Blocker as Blocker, - unstable_BlockerFunction as BlockerFunction, -} from "react-router-dom"; +import type { unstable_Blocker as Blocker } from "react-router-dom"; import { createBrowserRouter, createRoutesFromElements, @@ -12,10 +9,8 @@ import { Outlet, Route, RouterProvider, - useBeforeUnload, unstable_useBlocker as useBlocker, useLocation, - useNavigate, } from "react-router-dom"; let router = createBrowserRouter( @@ -30,21 +25,11 @@ let router = createBrowserRouter( element={ <>

Three

- - - } - /> - json({ ok: true })} - element={ - <> -

Four

- + } /> - Five} /> + Four} /> ) ); @@ -83,7 +68,6 @@ function Layout() { Three (Form with blocker)   Four (Form with prompt)   Five   - External link to Remix Docs  

Current location (index): {location.pathname} ({historyIndex}) @@ -93,54 +77,7 @@ function Layout() { ); } -// You can abstract `useBlocker` to use the browser's `window.confirm` dialog to -// determine whether or not the user should navigate within the current origin. -// `useBlocker` can also be used in conjunction with `useBeforeUnload` to -// prevent navigation away from the current origin. - -// IMPORTANT: There are edge cases with this behavior in which React Router -// cannot reliably access the correct location in the history stack. In such -// cases the user may attempt to stay on the page but the app navigates anyway, -// or the app may stay on the correct page but the browser's history stack gets -// out of whack. You should test your own implementation thoroughly to make sure -// the tradeoffs are right for your users. -function usePrompt( - message: string | null | undefined | false, - { - beforeUnload, - }: { - beforeUnload?: boolean; - } = {} -) { - let blocker = useBlocker( - React.useCallback( - () => (typeof message === "string" ? !window.confirm(message) : false), - [message] - ) - ); - let prevState = React.useRef(blocker.state); - React.useEffect(() => { - if (blocker.state === "blocked") { - blocker.reset(); - } - prevState.current = blocker.state; - }, [blocker]); - - useBeforeUnload( - React.useCallback( - (event) => { - if (beforeUnload && typeof message === "string") { - event.preventDefault(); - event.returnValue = message; - } - }, - [message, beforeUnload] - ), - { capture: true } - ); -} - -function ImportantFormWithBlocker() { +function ImportantForm() { let [value, setValue] = React.useState(""); let isBlocked = value !== ""; let blocker = useBlocker(isBlocked); @@ -201,36 +138,3 @@ function ConfirmNavigation({ blocker }: { blocker: Blocker }) { return

Blocker is currently unblocked

; } - -function ImportantFormWithPrompt() { - let [value, setValue] = React.useState(""); - let isBlocked = value !== ""; - usePrompt(isBlocked && "Are you sure you want to leave there buddy?", { - beforeUnload: true, - }); - - return ( - <> -

- Is the form dirty?{" "} - {isBlocked ? ( - Yes - ) : ( - No - )} -

- -
- - -
- - ); -}