diff --git a/.changeset/fetcher-key.md b/.changeset/fetcher-key.md new file mode 100644 index 0000000000..3be4e924c8 --- /dev/null +++ b/.changeset/fetcher-key.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": minor +--- + +Add support for manual fetcher key specification via `useFetcher({ key: string })` so you can access the same fetcher instance from different components in your application without prop-drilling ([RFC](https://github.com/remix-run/remix/discussions/7698)) diff --git a/.changeset/fetcher-persistence.md b/.changeset/fetcher-persistence.md new file mode 100644 index 0000000000..14e0b2a6c9 --- /dev/null +++ b/.changeset/fetcher-persistence.md @@ -0,0 +1,15 @@ +--- +"react-router-dom": minor +"@remix-run/router": minor +--- + +Fix the persistence behavior of fetchers so that they don't get cleaned up on `fetcher` unmount, but instead get cleaned up on fetcher completion (which may be after the fetcher unmounts in the UI) ([RFC](https://github.com/remix-run/remix/discussions/7698)) + +- This is a long-standing bug fix as the `useFetchers()` API was always supposed to only reflect **in-flight** fetcher information for pending/optimistic UI +- It was not intended to reflect fetcher data or hang onto fetchers after they returned to an `idle` state +- To do this we've re-architected things a bit and now it's the `react-router-dom` layer that holds stateful fetcher data to expose via `useFetcher()` +- The `router` now only knows about in-flight fetchers - they do not exist in `state.fetchers` until a `fetch()` call is made, and they are removed when it returns to `idle` (and the data is handed off to the React layer) +- **Warning:** This has a few potential "breaking bug" side-effects for your application: + - `useFetchers()` longer exposes the `data` field because it now only represents in-flight fetchers, and thus it does not reflect fetchers that have completed and have data + - Fetchers that previously unmounted _while in-flight_ will not be immediately aborted and will instead be cleaned up once they return to an `idle` state. They will remain exposed via `useFetchers` while in-flight so you can still access pending/optimistic data after unmount. + - Fetchers that complete while still mounted will no longer appear in `useFetchers()` - they served effectively no purpose in there since you can access the data via `useFetcher().data`) diff --git a/.changeset/fix-type-bug.md b/.changeset/fix-type-bug.md new file mode 100644 index 0000000000..640a6cf616 --- /dev/null +++ b/.changeset/fix-type-bug.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": patch +--- + +Fix `router.deleteFetcher` type definition which incorrectly specified `key` as an optional parameter diff --git a/.changeset/remove-get-fetcher.md b/.changeset/remove-get-fetcher.md new file mode 100644 index 0000000000..b007716a8d --- /dev/null +++ b/.changeset/remove-get-fetcher.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": minor +--- + +Remove the internal `router.getFetcher` API diff --git a/.changeset/submit-navigate-fetcherKey.md b/.changeset/submit-navigate-fetcherKey.md new file mode 100644 index 0000000000..e4e36c6e86 --- /dev/null +++ b/.changeset/submit-navigate-fetcherKey.md @@ -0,0 +1,8 @@ +--- +"react-router-dom": minor +--- + +Add `navigate`/`fetcherKey` params/props to `useSumbit`/`Form` to support kicking off a fetcher submission under the hood with an optionally user-specified key + +- Invoking a fetcher in this way is ephemeral and stateless +- If you need to access the state of one of these fetchers, you will need to leverage `useFetcher({ key })` to look it up elsewhere diff --git a/package.json b/package.json index f1746f97c7..88e17869f7 100644 --- a/package.json +++ b/package.json @@ -113,16 +113,16 @@ "none": "48.3 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "13.9 kB" + "none": "13.8 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "15.9 kB" + "none": "16.5 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "22.1 kB" + "none": "22.7 kB" } } } diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 4d335c7c2a..0496ff74e1 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -34,6 +34,7 @@ import { useMatches, useSearchParams, createRoutesFromElements, + redirect, } from "react-router-dom"; import { createDeferred } from "../../router/__tests__/utils/utils"; @@ -4641,7 +4642,7 @@ function testDomRouter( id="output" >

- ["idle"] + []

1 @@ -4662,7 +4663,7 @@ function testDomRouter( id="output" >

- ["idle"] + []

1 @@ -4675,9 +4676,7 @@ function testDomRouter( " `); - // Resolve Comp2 loader and complete navigation - Comp1 fetcher is still - // reflected here since deleteFetcher doesn't updateState - // TODO: Is this expected? + // Resolve Comp2 loader and complete navigation dfd2.resolve("data 2"); await waitFor(() => screen.getByText(/2.*idle/)); expect(getHtml(container.querySelector("#output")!)) @@ -4686,7 +4685,7 @@ function testDomRouter( id="output" >

- ["idle"] + []

2 @@ -4729,7 +4728,7 @@ function testDomRouter( id="output" >

- ["idle"] + []

2 @@ -5127,6 +5126,475 @@ function testDomRouter( expect(html).toContain("fetcher count:1"); }); + describe("useFetcher({ key })", () => { + it("generates unique keys for fetchers by default", async () => { + let dfd1 = createDeferred(); + let dfd2 = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetcher1 = useFetcher(); + let fetcher2 = useFetcher(); + let fetchers = useFetchers(); + return ( + <> + + +

{`${fetchers.length}, ${fetcher1.state}/${fetcher1.data}, ${fetcher2.state}/${fetcher2.data}`}
+ + ); + }, + }, + { + path: "/fetch1", + loader: () => dfd1.promise, + }, + { + path: "/fetch2", + loader: () => dfd2.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(container.querySelector("pre")!.innerHTML).toBe( + "0, idle/undefined, idle/undefined" + ); + + fireEvent.click(screen.getByText("Load 1")); + await waitFor(() => + screen.getByText("1, loading/undefined, idle/undefined") + ); + + dfd1.resolve("FETCH 1"); + await waitFor(() => + screen.getByText("0, idle/FETCH 1, idle/undefined") + ); + + fireEvent.click(screen.getByText("Load 2")); + await waitFor(() => + screen.getByText("1, idle/FETCH 1, loading/undefined") + ); + + dfd2.resolve("FETCH 2"); + await waitFor(() => + screen.getByText("0, idle/FETCH 1, idle/FETCH 2") + ); + }); + + it("allows users to specify their own key to share fetchers", async () => { + let dfd1 = createDeferred(); + let dfd2 = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetcher1 = useFetcher({ key: "shared" }); + let fetcher2 = useFetcher({ key: "shared" }); + let fetchers = useFetchers(); + return ( + <> + + +
{`${fetchers.length}, ${fetcher1.state}/${fetcher1.data}, ${fetcher2.state}/${fetcher2.data}`}
+ + ); + }, + }, + { + path: "/fetch1", + loader: () => dfd1.promise, + }, + { + path: "/fetch2", + loader: () => dfd2.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(container.querySelector("pre")!.innerHTML).toBe( + "0, idle/undefined, idle/undefined" + ); + + fireEvent.click(screen.getByText("Load 1")); + await waitFor(() => + screen.getByText("1, loading/undefined, loading/undefined") + ); + + dfd1.resolve("FETCH 1"); + await waitFor(() => + screen.getByText("0, idle/FETCH 1, idle/FETCH 1") + ); + + fireEvent.click(screen.getByText("Load 2")); + await waitFor(() => + screen.getByText("1, loading/FETCH 1, loading/FETCH 1") + ); + + dfd2.resolve("FETCH 2"); + await waitFor(() => + screen.getByText("0, idle/FETCH 2, idle/FETCH 2") + ); + }); + }); + + describe("fetcher persistence", () => { + it("loading fetchers persist until completion", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetchers = useFetchers(); + return ( + <> +
{`Num fetchers: ${fetchers.length}`}
+ Go to /page + + + ); + }, + children: [ + { + index: true, + Component() { + let fetcher = useFetcher(); + return ( + + ); + }, + }, + { + path: "page", + Component() { + return

Page

; + }, + }, + ], + }, + { + path: "/fetch", + loader: () => dfd.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("Num fetchers: 0"); + + fireEvent.click(screen.getByText("Load (idle)")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + expect(getHtml(container)).toMatch("Load (loading)"); + + fireEvent.click(screen.getByText("Go to /page")); + await waitFor(() => screen.getByText("Page")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + expect(getHtml(container)).toMatch("Page"); + + // Resolve after the navigation - no-op + dfd.resolve("FETCH"); + await waitFor(() => screen.getByText("Num fetchers: 0")); + expect(getHtml(container)).toMatch("Page"); + }); + + it("submitting fetchers persist until completion", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetchers = useFetchers(); + return ( + <> +
{`Num fetchers: ${fetchers.length}`}
+ Go to /page + + + ); + }, + children: [ + { + index: true, + Component() { + let fetcher = useFetcher(); + return ( + + ); + }, + }, + { + path: "page", + Component() { + return

Page

; + }, + }, + ], + }, + { + path: "/fetch", + action: () => dfd.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("Num fetchers: 0"); + + fireEvent.click(screen.getByText("Submit (idle)")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + expect(getHtml(container)).toMatch("Submit (submitting)"); + + fireEvent.click(screen.getByText("Go to /page")); + await waitFor(() => screen.getByText("Page")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + + // Resolve after the navigation - trigger cleanup + dfd.resolve("FETCH"); + await waitFor(() => screen.getByText("Num fetchers: 0")); + }); + + it("submitting fetchers w/revalidations are cleaned up on completion", async () => { + let count = 0; + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetchers = useFetchers(); + return ( + <> +
{`Num fetchers: ${fetchers.length}`}
+ Go to /page + + + ); + }, + children: [ + { + index: true, + Component() { + let fetcher = useFetcher({ persist: true }); + return ( + + ); + }, + }, + { + path: "page", + Component() { + let data = useLoaderData() as { count: number }; + return

{`Page (${data.count})`}

; + }, + async loader() { + await new Promise((r) => setTimeout(r, 10)); + return { count: ++count }; + }, + }, + ], + }, + { + path: "/fetch", + action: () => dfd.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("Num fetchers: 0"); + + fireEvent.click(screen.getByText("Submit (idle)")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + expect(getHtml(container)).toMatch("Submit (submitting)"); + + fireEvent.click(screen.getByText("Go to /page")); + await waitFor(() => screen.getByText("Page (1)")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + + // Resolve after the navigation and revalidation + dfd.resolve("FETCH"); + await waitFor(() => screen.getByText("Num fetchers: 0")); + expect(getHtml(container)).toMatch("Page (2)"); + }); + + it("submitting fetchers w/redirects are cleaned up on completion", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetchers = useFetchers(); + return ( + <> +
{`Num fetchers: ${fetchers.length}`}
+ Go to /page + + + ); + }, + children: [ + { + index: true, + Component() { + let fetcher = useFetcher({ persist: true }); + return ( + + ); + }, + }, + { + path: "page", + Component() { + return

Page

; + }, + }, + { + path: "redirect", + Component() { + return

Redirect

; + }, + }, + ], + }, + { + path: "/fetch", + action: () => dfd.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("Num fetchers: 0"); + + fireEvent.click(screen.getByText("Submit (idle)")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + expect(getHtml(container)).toMatch("Submit (submitting)"); + + fireEvent.click(screen.getByText("Go to /page")); + await waitFor(() => screen.getByText("Page")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + + // Resolve after the navigation - trigger cleanup + // We don't process the redirect here since it was superseded by a + // navigation, but we assert that it gets cleaned up afterwards + dfd.resolve(redirect("/redirect")); + await waitFor(() => screen.getByText("Num fetchers: 0")); + expect(getHtml(container)).toMatch("Page"); + }); + + it("submitting fetcher.Form persist until completion", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetchers = useFetchers(); + return ( + <> +
{`Num fetchers: ${fetchers.length}`}
+ Go to /page + + + ); + }, + children: [ + { + index: true, + Component() { + let fetcher = useFetcher(); + return ( + + + + ); + }, + }, + { + path: "page", + Component() { + return

Page

; + }, + }, + ], + }, + { + path: "/fetch", + action: () => dfd.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("Num fetchers: 0"); + + fireEvent.click(screen.getByText("Submit (idle)")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + expect(getHtml(container)).toMatch("Submit (submitting)"); + + fireEvent.click(screen.getByText("Go to /page")); + await waitFor(() => screen.getByText("Page")); + expect(getHtml(container)).toMatch("Num fetchers: 1"); + + // Resolve after the navigation and revalidation + dfd.resolve("FETCH"); + await waitFor(() => screen.getByText("Num fetchers: 0")); + }); + }); + describe("with a basename", () => { it("prepends the basename to fetcher.load paths", async () => { let router = createTestRouter( @@ -5353,6 +5821,190 @@ function testDomRouter( }); }); + describe("
", () => { + function setupTest( + method: "get" | "post", + navigate: boolean, + renderFetcher = false + ) { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createTestRouter( + [ + { + path: "/", + async action({ request }) { + let resolvedValue = await actionDefer.promise; + let formData = await request.formData(); + return `${resolvedValue}:${formData.get("test")}`; + }, + loader: () => loaderDefer.promise, + Component() { + let data = useLoaderData() as string; + let actionData = useActionData() as string | undefined; + let location = useLocation(); + let navigation = useNavigation(); + let fetchers = useFetchers(); + return ( +
+ + + + +
+                      {[
+                        location.key,
+                        navigation.state,
+                        data,
+                        actionData,
+                        fetchers.map((f) => f.state),
+                      ].join(",")}
+                    
+ +
+ ); + }, + ...(renderFetcher + ? { + children: [ + { + index: true, + Component() { + let fetcher = useFetcher({ key: "my-key" }); + return ( +
{`fetcher:${fetcher.state}:${fetcher.data}`}
+ ); + }, + }, + ], + } + : {}), + }, + ], + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": "INIT" } }, + } + ); + + let { container } = render(); + + return { container, loaderDefer, actionDefer }; + } + + it('defaults to a navigation on
', async () => { + let { container, loaderDefer } = setupTest("get", true); + + expect(getHtml(container)).toMatch("default,idle,INIT,,"); + + fireEvent.click(screen.getByText("Submit Form")); + await waitFor(() => screen.getByText("default,loading,INIT,,")); + + loaderDefer.resolve("LOADER"); + await waitFor(() => screen.getByText(/idle,LOADER,/)); + // Navigation changes the location key + expect(getHtml(container)).not.toMatch("default"); + }); + + it('defaults to a navigation on ', async () => { + let { container, loaderDefer, actionDefer } = setupTest("post", true); + + expect(getHtml(container)).toMatch("default,idle,INIT,,"); + + fireEvent.click(screen.getByText("Submit Form")); + await waitFor(() => screen.getByText("default,submitting,INIT,,")); + + actionDefer.resolve("ACTION"); + await waitFor(() => + screen.getByText("default,loading,INIT,ACTION:value,") + ); + + loaderDefer.resolve("LOADER"); + await waitFor(() => screen.getByText(/idle,LOADER,ACTION:value/)); + // Navigation changes the location key + expect(getHtml(container)).not.toMatch("default"); + }); + + it('uses a fetcher for ', async () => { + let { container, loaderDefer } = setupTest("get", false); + + expect(getHtml(container)).toMatch("default,idle,INIT,,"); + + fireEvent.click(screen.getByText("Submit Form")); + // Fetcher does not trigger useNavigation + await waitFor(() => screen.getByText("default,idle,INIT,,loading")); + + loaderDefer.resolve("LOADER"); + // Fetcher does not change the location key. Because no useFetcher() + // accessed this key, the fetcher/data doesn't stick around + await waitFor(() => screen.getByText("default,idle,INIT,,")); + }); + + it('uses a fetcher for ', async () => { + let { container, loaderDefer, actionDefer } = setupTest("post", false); + + expect(getHtml(container)).toMatch("default,idle,INIT,"); + + fireEvent.click(screen.getByText("Submit Form")); + // Fetcher does not trigger useNavigation + await waitFor(() => screen.getByText("default,idle,INIT,,submitting")); + + actionDefer.resolve("ACTION"); + await waitFor(() => screen.getByText("default,idle,INIT,,loading")); + + loaderDefer.resolve("LOADER"); + // Fetcher does not change the location key. Because no useFetcher() + // accessed this key, the fetcher/data doesn't stick around + await waitFor(() => screen.getByText("default,idle,LOADER,,")); + }); + + it('uses a fetcher for ', async () => { + let { container, loaderDefer } = setupTest("get", false, true); + + expect(getHtml(container)).toMatch("default,idle,INIT,,"); + + fireEvent.click(screen.getByText("Submit Form")); + // Fetcher does not trigger useNavigation + await waitFor(() => screen.getByText("default,idle,INIT,,loading")); + expect(getHtml(container)).toMatch("fetcher:loading:undefined"); + + loaderDefer.resolve("LOADER"); + // Fetcher does not change the location key. Because no useFetcher() + // accessed this key, the fetcher/data doesn't stick around + await waitFor(() => screen.getByText("default,idle,INIT,,")); + expect(getHtml(container)).toMatch("fetcher:idle:LOADER"); + }); + + it('uses a fetcher for ', async () => { + let { container, loaderDefer, actionDefer } = setupTest( + "post", + false, + true + ); + + expect(getHtml(container)).toMatch("default,idle,INIT,"); + + fireEvent.click(screen.getByText("Submit Form")); + // Fetcher does not trigger useNavigation + await waitFor(() => screen.getByText("default,idle,INIT,,submitting")); + + actionDefer.resolve("ACTION"); + await waitFor(() => screen.getByText("default,idle,INIT,,loading")); + expect(getHtml(container)).toMatch("fetcher:loading:ACTION:value"); + + loaderDefer.resolve("LOADER"); + // Fetcher does not change the location key. Because no useFetcher() + // accessed this key, the fetcher/data doesn't stick around + await waitFor(() => screen.getByText("default,idle,LOADER,,")); + expect(getHtml(container)).toMatch("fetcher:idle:ACTION:value"); + }); + }); + describe("errors", () => { it("renders hydration errors on leaf elements", async () => { let router = createTestRouter( @@ -5897,9 +6549,13 @@ function testDomRouter( // This test ensures that when manual routes are used, we add hasErrorBoundary it("renders navigation errors on lazy leaf elements (when using manual route objects)", async () => { - let lazyDefer = createDeferred(); + let lazyRouteModule = { + loader: () => barDefer.promise, + element: , + errorElement: , + }; + let lazyDefer = createDeferred(); let barDefer = createDeferred(); - let routes: RouteObject[] = [ { path: "/", @@ -5911,7 +6567,7 @@ function testDomRouter( }, { path: "bar", - lazy: async () => lazyDefer.promise as Promise, + lazy: () => lazyDefer.promise, }, ], }, @@ -5957,11 +6613,7 @@ function testDomRouter( `); fireEvent.click(screen.getByText("Link to Bar")); - await lazyDefer.resolve({ - loader: () => barDefer.promise, - element: , - errorElement: , - }); + await lazyDefer.resolve(lazyRouteModule); barDefer.reject(new Error("Kaboom!")); await waitFor(() => screen.getByText("idle")); expect(getHtml(container.querySelector("#output")!)) diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index 4aeeaa6957..1402c2f79a 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -169,6 +169,16 @@ export interface SubmitOptions { */ encType?: FormEncType; + /** + * Indicate a specific fetcherKey to use when using navigate=false + */ + fetcherKey?: string; + + /** + * navigate=false will use a fetcher instead of a navigation + */ + navigate?: boolean; + /** * Set `true` to replace the current entry in the browser's history stack * instead of creating a new one (i.e. stay on "the same page"). Defaults diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index f115357c4f..41fcd42564 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -59,6 +59,7 @@ import { UNSAFE_invariant as invariant, UNSAFE_warning as warning, matchPath, + IDLE_FETCHER, } from "@remix-run/router"; import type { @@ -349,6 +350,20 @@ if (__DEV__) { export { ViewTransitionContext as UNSAFE_ViewTransitionContext }; +// TODO: (v7) Change the useFetcher data from `any` to `unknown` +type FetchersContextObject = { + fetcherData: Map; + register: (key: string) => void; + unregister: (key: string) => void; +}; + +const FetchersContext = React.createContext(null); +if (__DEV__) { + FetchersContext.displayName = "Fetchers"; +} + +export { FetchersContext as UNSAFE_FetchersContext }; + //#endregion //////////////////////////////////////////////////////////////////////////////// @@ -439,6 +454,8 @@ export function RouterProvider({ currentLocation: Location; nextLocation: Location; }>(); + let { fetcherContext, fetcherData } = useFetcherDataLayer(); + let { v7_startTransition } = future || {}; let optInStartTransition = React.useCallback( @@ -457,6 +474,12 @@ export function RouterProvider({ newState: RouterState, { unstable_viewTransitionOpts: viewTransitionOpts } ) => { + newState.fetchers.forEach((fetcher, key) => { + if (fetcher.data !== undefined) { + fetcherData.current.set(key, fetcher.data); + } + }); + if ( !viewTransitionOpts || router.window == null || @@ -484,7 +507,7 @@ export function RouterProvider({ }); } }, - [optInStartTransition, transition, renderDfd, router.window] + [router.window, transition, renderDfd, fetcherData, optInStartTransition] ); // Need to use a layout effect here so we are subscribed early enough to @@ -587,20 +610,22 @@ export function RouterProvider({ <> - - - {state.initialized ? ( - - ) : ( - fallbackElement - )} - - + + + + {state.initialized ? ( + + ) : ( + fallbackElement + )} + + + {null} @@ -1049,6 +1074,16 @@ export interface FetcherFormProps } export interface FormProps extends FetcherFormProps { + /** + * Indicate a specific fetcherKey to use when using navigate=false + */ + fetcherKey?: string; + + /** + * navigate=false will use a fetcher instead of a navigation + */ + navigate?: boolean; + /** * Forces a full document navigation instead of a fetch. */ @@ -1104,6 +1139,8 @@ interface FormImplProps extends FormProps { const FormImpl = React.forwardRef( ( { + fetcherKey, + navigate, reloadDocument, replace, state, @@ -1134,7 +1171,9 @@ const FormImpl = React.forwardRef( method; submit(submitter || event.currentTarget, { + fetcherKey, method: submitMethod, + navigate, replace, state, relative, @@ -1188,12 +1227,12 @@ if (__DEV__) { enum DataRouterHook { UseScrollRestoration = "useScrollRestoration", UseSubmit = "useSubmit", - UseSubmitFetcher = "useSubmitFetcher", UseFetcher = "useFetcher", useViewTransitionState = "useViewTransitionState", } enum DataRouterStateHook { + UseFetcher = "useFetcher", UseFetchers = "useFetchers", UseScrollRestoration = "useScrollRestoration", } @@ -1216,6 +1255,47 @@ function useDataRouterState(hookName: DataRouterStateHook) { return state; } +function useFetcherDataLayer() { + let fetcherRefs = React.useRef>(new Map()); + let fetcherData = React.useRef>(new Map()); + + let registerFetcher = React.useCallback( + (key: string) => { + let count = fetcherRefs.current.get(key); + if (count == null) { + fetcherRefs.current.set(key, 1); + } else { + fetcherRefs.current.set(key, count + 1); + } + }, + [fetcherRefs] + ); + + let unregisterFetcher = React.useCallback( + (key: string) => { + let count = fetcherRefs.current.get(key); + if (count == null || count <= 1) { + fetcherRefs.current.delete(key); + fetcherData.current.delete(key); + } else { + fetcherRefs.current.set(key, count - 1); + } + }, + [fetcherData, fetcherRefs] + ); + + let fetcherContext = React.useMemo( + () => ({ + fetcherData: fetcherData.current, + register: registerFetcher, + unregister: unregisterFetcher, + }), + [fetcherData, registerFetcher, unregisterFetcher] + ); + + return { fetcherContext, fetcherData }; +} + /** * Handles the click behavior for router `` components. This is useful if * you need to create custom `` components with the same click behavior we @@ -1379,6 +1459,9 @@ function validateClientSideSubmission() { } } +let fetcherId = 0; +let getUniqueFetcherId = () => `__${String(++fetcherId)}__`; + /** * Returns a function that may be used to programmatically submit a form (or * some arbitrary data) to the server. @@ -1397,57 +1480,33 @@ export function useSubmit(): SubmitFunction { basename ); - router.navigate(options.action || action, { - preventScrollReset: options.preventScrollReset, - formData, - body, - formMethod: options.method || (method as HTMLFormMethod), - formEncType: options.encType || (encType as FormEncType), - replace: options.replace, - state: options.state, - fromRouteId: currentRouteId, - unstable_viewTransition: options.unstable_viewTransition, - }); + if (options.navigate === false) { + let key = options.fetcherKey || getUniqueFetcherId(); + router.fetch(key, currentRouteId, options.action || action, { + preventScrollReset: options.preventScrollReset, + formData, + body, + formMethod: options.method || (method as HTMLFormMethod), + formEncType: options.encType || (encType as FormEncType), + }); + } else { + router.navigate(options.action || action, { + preventScrollReset: options.preventScrollReset, + formData, + body, + formMethod: options.method || (method as HTMLFormMethod), + formEncType: options.encType || (encType as FormEncType), + replace: options.replace, + state: options.state, + fromRouteId: currentRouteId, + unstable_viewTransition: options.unstable_viewTransition, + }); + } }, [router, basename, currentRouteId] ); } -/** - * Returns the implementation for fetcher.submit - */ -function useSubmitFetcher( - fetcherKey: string, - fetcherRouteId: string -): FetcherSubmitFunction { - let { router } = useDataRouterContext(DataRouterHook.UseSubmitFetcher); - let { basename } = React.useContext(NavigationContext); - - return React.useCallback( - (target, options = {}) => { - validateClientSideSubmission(); - - let { action, method, encType, formData, body } = getFormSubmissionInfo( - target, - basename - ); - - invariant( - fetcherRouteId != null, - "No routeId available for useFetcher()" - ); - router.fetch(fetcherKey, fetcherRouteId, options.action || action, { - preventScrollReset: options.preventScrollReset, - formData, - body, - formMethod: options.method || (method as HTMLFormMethod), - formEncType: options.encType || (encType as FormEncType), - }); - }, - [router, basename, fetcherKey, fetcherRouteId] - ); -} - // v7: Eventually we should deprecate this entirely in favor of using the // router method directly? export function useFormAction( @@ -1504,23 +1563,10 @@ export function useFormAction( return createPath(path); } -function createFetcherForm(fetcherKey: string, routeId: string) { - let FetcherForm = React.forwardRef( - (props, ref) => { - let submit = useSubmitFetcher(fetcherKey, routeId); - return ; - } - ); - if (__DEV__) { - FetcherForm.displayName = "fetcher.Form"; - } - return FetcherForm; -} - -let fetcherId = 0; - export type FetcherWithComponents = Fetcher & { - Form: ReturnType; + Form: React.ForwardRefExoticComponent< + FetcherFormProps & React.RefAttributes + >; submit: FetcherSubmitFunction; load: (href: string) => void; }; @@ -1531,65 +1577,104 @@ export type FetcherWithComponents = Fetcher & { * Interacts with route loaders and actions without causing a navigation. Great * for any interaction that stays on the same page. */ -export function useFetcher(): FetcherWithComponents { +export function useFetcher({ + key, +}: { key?: string } = {}): FetcherWithComponents { let { router } = useDataRouterContext(DataRouterHook.UseFetcher); - + let state = useDataRouterState(DataRouterStateHook.UseFetcher); + let fetchersContext = React.useContext(FetchersContext); let route = React.useContext(RouteContext); - invariant(route, `useFetcher must be used inside a RouteContext`); - let routeId = route.matches[route.matches.length - 1]?.route.id; + let [fetcherKey, setFetcherKey] = React.useState(key || ""); + if (!fetcherKey) { + setFetcherKey(getUniqueFetcherId()); + } + + invariant( + fetchersContext, + `useFetcher must be used inside a FetchersContext` + ); + invariant(route, `useFetcher must be used inside a RouteContext`); invariant( routeId != null, `useFetcher can only be used on routes that contain a unique "id"` ); - let [fetcherKey] = React.useState(() => String(++fetcherId)); - let [Form] = React.useState(() => { - invariant(routeId, `No routeId available for fetcher.Form()`); - return createFetcherForm(fetcherKey, routeId); - }); - let [load] = React.useState(() => (href: string) => { - invariant(router, "No router available for fetcher.load()"); - invariant(routeId, "No routeId available for fetcher.load()"); - router.fetch(fetcherKey, routeId, href); - }); - let submit = useSubmitFetcher(fetcherKey, routeId); + let { fetcherData, register, unregister } = fetchersContext; - let fetcher = router.getFetcher(fetcherKey); + // Register/deregister with FetchersContext + React.useEffect(() => { + register(fetcherKey); + return () => unregister(fetcherKey); + }, [fetcherKey, register, unregister]); + + // Fetcher additions (load) + let load = React.useCallback( + (href: string) => { + invariant(routeId, `fetcher.load routeId unavailable`); + router.fetch(fetcherKey, routeId, href); + }, + [fetcherKey, routeId, router] + ); - let fetcherWithComponents = React.useMemo( - () => ({ + // Fetcher additions (submit) + let submitImpl = useSubmit(); + let submit = React.useCallback( + (target, opts) => { + submitImpl(target, { + ...opts, + navigate: false, + fetcherKey, + }); + }, + [fetcherKey, submitImpl] + ); + let Form = React.useMemo(() => { + let FetcherForm = React.forwardRef( + (props, ref) => { + return ( + + ); + } + ); + if (__DEV__) { + FetcherForm.displayName = "fetcher.Form"; + } + return FetcherForm; + }, [fetcherKey, submit]); + + // Exposed stateful fetcher with data from FetchersContext + let data = fetcherData.get(fetcherKey); + return React.useMemo(() => { + // Prefer the fetcher from `state` not `router.state` since DataRouterContext + // is memoized so this ensures we update on fetcher state updates + let fetcher = state.fetchers.get(fetcherKey) || IDLE_FETCHER; + return { + ...fetcher, Form, submit, load, - ...fetcher, - }), - [fetcher, Form, submit, load] - ); - - React.useEffect(() => { - // Is this busted when the React team gets real weird and calls effects - // twice on mount? We really just need to garbage collect here when this - // fetcher is no longer around. - return () => { - if (!router) { - console.warn(`No router available to clean up from useFetcher()`); - return; - } - router.deleteFetcher(fetcherKey); + data, }; - }, [router, fetcherKey]); - - return fetcherWithComponents; + }, [Form, data, fetcherKey, load, state.fetchers, submit]); } /** * Provides all fetchers currently on the page. Useful for layouts and parent * routes that need to provide pending/optimistic UI regarding the fetch. */ -export function useFetchers(): Fetcher[] { +export function useFetchers(): Omit[] { let state = useDataRouterState(DataRouterStateHook.UseFetchers); - return [...state.fetchers.values()]; + return [...state.fetchers.values()].map((fetcher) => { + let { data, ...rest } = fetcher; + return rest; + }); } const SCROLL_RESTORATION_STORAGE_KEY = "react-router-scroll-positions"; diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 7946218731..c222fcae90 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -35,6 +35,7 @@ import { UNSAFE_DataRouterContext as DataRouterContext, UNSAFE_DataRouterStateContext as DataRouterStateContext, UNSAFE_ViewTransitionContext as ViewTransitionContext, + UNSAFE_FetchersContext as FetchersContext, } from "react-router-dom"; export interface StaticRouterProps { @@ -132,17 +133,25 @@ export function StaticRouterProvider({ <> - - - - - + (), + register: () => {}, + unregister: () => {}, + }} + > + + + + + + {hydrateScript ? ( @@ -326,9 +335,6 @@ export function createStaticRouter( }, createHref, encodeLocation, - getFetcher() { - return IDLE_FETCHER; - }, deleteFetcher() { throw msg("deleteFetcher"); }, diff --git a/packages/router/__tests__/defer-test.ts b/packages/router/__tests__/defer-test.ts index 0388f26a9f..63ad7d942d 100644 --- a/packages/router/__tests__/defer-test.ts +++ b/packages/router/__tests__/defer-test.ts @@ -1,5 +1,5 @@ import { createMemoryHistory } from "../history"; -import { createRouter } from "../router"; +import { RouterState, createRouter } from "../router"; import { AbortedDeferredError, ErrorResponseImpl, defer } from "../utils"; import { deferredData, trackedPromise } from "./utils/custom-matchers"; import { cleanup, createDeferred, setup } from "./utils/data-router-setup"; @@ -1379,12 +1379,10 @@ describe("deferred data", () => { }); await dfd.resolve("2"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: { - critical: "1", - lazy: "2", - }, + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual({ + critical: "1", + lazy: "2", }); // Trigger a revalidation for the same fetcher @@ -1397,21 +1395,17 @@ describe("deferred data", () => { lazy: dfd2.promise, }) ); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: { - critical: "1", - lazy: "2", - }, + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual({ + critical: "1", + lazy: "2", }); await dfd2.resolve("4"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: { - critical: "3", - lazy: "4", - }, + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual({ + critical: "3", + lazy: "4", }); }); @@ -1500,12 +1494,10 @@ describe("deferred data", () => { await dfd2.resolve("4"); await loaderPromise1; await loaderPromise2; - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: { - critical: "3", - lazy: "4", - }, + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual({ + critical: "3", + lazy: "4", }); }); @@ -1624,10 +1616,8 @@ describe("deferred data", () => { lazy: expect.trackedPromise("Yep!"), }, }); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "ACTION", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("ACTION"); }); it("differentiates between navigation and fetcher deferreds on cancellations", async () => { @@ -1654,6 +1644,13 @@ describe("deferred data", () => { }, }); + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get(key)?.data) { + fetcherData = state.fetchers.get(key)?.data; + } + }); + // navigate to root, kicking off a reload of the root loader let key = "key"; router.navigate("/"); @@ -1692,10 +1689,8 @@ describe("deferred data", () => { expect(router.state.loaderData).toEqual({ root: { value: expect.trackedPromise(2) }, }); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: { value: 3 }, - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toEqual({ value: 3 }); // Assert that both the route loader and fetcher loader were aborted expect(signals[0].aborted).toBe(true); // initial route diff --git a/packages/router/__tests__/fetchers-test.ts b/packages/router/__tests__/fetchers-test.ts index e30ce8fff2..e020284001 100644 --- a/packages/router/__tests__/fetchers-test.ts +++ b/packages/router/__tests__/fetchers-test.ts @@ -100,6 +100,11 @@ describe("fetchers", () => { }); let key = "key"; + let fetcherData; + router.subscribe( + (state) => (fetcherData = state.fetchers.get(key)?.data) + ); + router.fetch(key, "root", "/"); expect(router.state.fetchers.get(key)).toEqual({ state: "loading", @@ -110,13 +115,8 @@ describe("fetchers", () => { }); await dfd.resolve("DATA"); - expect(router.state.fetchers.get(key)).toEqual({ - state: "idle", - formMethod: undefined, - formEncType: undefined, - formData: undefined, - data: "DATA", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("DATA"); expect(router._internalFetchControllers.size).toBe(0); }); @@ -148,8 +148,6 @@ describe("fetchers", () => { await B.loaders.foo.resolve("B DATA"); expect(B.fetcher.state).toBe("idle"); expect(B.fetcher.data).toBe("B DATA"); - - expect(A.fetcher).toBe(B.fetcher); }); it("loader submission fetch", async () => { @@ -261,20 +259,14 @@ describe("fetchers", () => { }); describe("fetcher removal", () => { - it("gives an idle fetcher before submission", async () => { - let t = initializeTest(); - let fetcher = t.router.getFetcher("randomKey"); - expect(fetcher).toBe(IDLE_FETCHER); - }); - it("removes fetchers", async () => { let t = initializeTest(); let A = await t.fetch("/foo"); await A.loaders.foo.resolve("A"); - expect(t.router.getFetcher(A.key).data).toBe("A"); + expect(t.fetcherData.get(A.key)).toBe("A"); t.router.deleteFetcher(A.key); - expect(t.router.getFetcher(A.key)).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get(A.key)).toBeUndefined(); }); it("cleans up abort controllers", async () => { @@ -323,7 +315,7 @@ describe("fetchers", () => { let t = initializeTest(); let A = await t.fetch("/foo"); await A.loaders.foo.reject(new Response(null, { status: 400 })); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new ErrorResponseImpl(400, undefined, ""), }); @@ -336,7 +328,7 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); await A.loaders.foo.reject(new Response(null, { status: 400 })); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new ErrorResponseImpl(400, undefined, ""), }); @@ -349,7 +341,7 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); await A.actions.foo.reject(new Response(null, { status: 400 })); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new ErrorResponseImpl(400, undefined, ""), }); @@ -375,7 +367,7 @@ describe("fetchers", () => { formMethod: "post", formData: createFormData({ key: "value" }), }); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new ErrorResponseImpl( 405, @@ -404,7 +396,7 @@ describe("fetchers", () => { body: "not json", formEncType: "application/json", }); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new ErrorResponseImpl( 400, @@ -447,13 +439,13 @@ describe("fetchers", () => { // If the routeId is not an active match, errors bubble to the root let A = await t.fetch("/error", "key1", "wit"); await A.loaders.error.reject(new Error("Kaboom!")); - expect(t.router.getFetcher("key1")).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get("key1")).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("Kaboom!"), }); await t.fetch("/not-found", "key2", "wit"); - expect(t.router.getFetcher("key2")).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get("key2")).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new ErrorResponseImpl( 404, @@ -469,7 +461,7 @@ describe("fetchers", () => { let C = await t.fetch("/error", "key3", "wit"); await C.loaders.error.reject(new Error("Kaboom!")); - expect(t.router.getFetcher("key3")).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get("key3")).toBeUndefined(); expect(t.router.state.errors).toEqual({ wit: new Error("Kaboom!"), }); @@ -478,7 +470,7 @@ describe("fetchers", () => { formMethod: "post", formData: createFormData({ key: "value" }), }); - expect(t.router.getFetcher("key4")).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get("key4")).toBeUndefined(); expect(t.router.state.errors).toEqual({ wit: new ErrorResponseImpl( 404, @@ -489,7 +481,7 @@ describe("fetchers", () => { }); await t.fetch("/not-found", "key5", "wit"); - expect(t.router.getFetcher("key5")).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get("key5")).toBeUndefined(); expect(t.router.state.errors).toEqual({ wit: new ErrorResponseImpl( 404, @@ -505,13 +497,13 @@ describe("fetchers", () => { let E = await t.fetch("/error", "key6", "witout"); await E.loaders.error.reject(new Error("Kaboom!")); - expect(t.router.getFetcher("key6")).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get("key6")).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new Error("Kaboom!"), }); await t.fetch("/not-found", "key7", "witout"); - expect(t.router.getFetcher("key7")).toBe(IDLE_FETCHER); + expect(t.router.state.fetchers.get("key7")).toBeUndefined(); expect(t.router.state.errors).toEqual({ root: new ErrorResponseImpl( 404, @@ -528,7 +520,7 @@ describe("fetchers", () => { let t = initializeTest(); let A = await t.fetch("/foo"); await A.loaders.foo.reject(new Error("Kaboom!")); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new Error("Kaboom!"), }); @@ -541,7 +533,7 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); await A.loaders.foo.reject(new Error("Kaboom!")); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new Error("Kaboom!"), }); @@ -554,7 +546,7 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); await A.actions.foo.reject(new Error("Kaboom!")); - expect(A.fetcher).toBe(IDLE_FETCHER); + expect(A.fetcher.state).toBe("idle"); expect(t.router.state.errors).toEqual({ root: new Error("Kaboom!"), }); @@ -569,7 +561,7 @@ describe("fetchers", () => { let A = await t.fetch("/foo"); let B = await A.loaders.foo.redirect("/bar"); - expect(t.router.getFetcher(A.key)).toBe(A.fetcher); + expect(t.router.state.fetchers.get(A.key)?.state).toBe("loading"); expect(t.router.state.navigation.state).toBe("loading"); expect(t.router.state.navigation.location?.pathname).toBe("/bar"); @@ -595,7 +587,7 @@ describe("fetchers", () => { }); let B = await A.loaders.foo.redirect("/bar"); - expect(t.router.getFetcher(A.key)).toBe(A.fetcher); + expect(t.router.state.fetchers.get(A.key)?.state).toBe("loading"); expect(t.router.state.navigation.state).toBe("loading"); expect(t.router.state.navigation.location?.pathname).toBe("/bar"); @@ -748,14 +740,14 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); await A.actions.foo.resolve("A ACTION"); - expect(t.router.getFetcher(key).data).toBe("A ACTION"); + expect(t.fetcherData.get(key)).toBe("A ACTION"); let B = await t.fetch("/foo", key, { formMethod: "post", formData: createFormData({ key: "value" }), }); expect(A.loaders.foo.signal.aborted).toBe(true); - expect(t.router.getFetcher(key).data).toBe("A ACTION"); + expect(t.fetcherData.get(key)).toBe("A ACTION"); await A.loaders.root.resolve("A ROOT LOADER"); await A.loaders.foo.resolve("A LOADER"); @@ -768,10 +760,10 @@ describe("fetchers", () => { expect(B.actions.foo.signal.aborted).toBe(true); await B.actions.foo.resolve("B ACTION"); - expect(t.router.getFetcher(key).data).toBe("A ACTION"); + expect(t.fetcherData.get(key)).toBe("A ACTION"); await C.actions.foo.resolve("C ACTION"); - expect(t.router.getFetcher(key).data).toBe("C ACTION"); + expect(t.fetcherData.get(key)).toBe("C ACTION"); await B.loaders.root.resolve("B ROOT LOADER"); await B.loaders.foo.resolve("B LOADER"); @@ -779,7 +771,7 @@ describe("fetchers", () => { await C.loaders.root.resolve("C ROOT LOADER"); await C.loaders.foo.resolve("C LOADER"); - expect(t.router.getFetcher(key).data).toBe("C ACTION"); + expect(t.fetcherData.get(key)).toBe("C ACTION"); expect(t.router.state.loaderData.foo).toBe("C LOADER"); }); }); @@ -805,7 +797,7 @@ describe("fetchers", () => { await Ak1.actions.foo.resolve("A ACTION"); await Bk2.actions.foo.resolve("B ACTION"); - expect(t.router.getFetcher(k2).data).toBe("B ACTION"); + expect(t.fetcherData.get(k2)).toBe("B ACTION"); let Ck1 = await t.fetch("/foo", k1, { formMethod: "post", @@ -826,7 +818,7 @@ describe("fetchers", () => { await Ck1.loaders.root.resolve("C ROOT LOADER"); await Ck1.loaders.foo.resolve("C LOADER"); - expect(t.router.getFetcher(k1).data).toBe("C ACTION"); + expect(t.fetcherData.get(k1)).toBe("C ACTION"); expect(t.router.state.loaderData.foo).toBe("C LOADER"); }); }); @@ -1323,7 +1315,7 @@ describe("fetchers", () => { }); await A.actions.foo.resolve("A ACTION"); expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - expect(t.router.state.fetchers.get(key)?.data).toBe("A ACTION"); + expect(t.fetcherData.get(key)).toBe("A ACTION"); // Interrupting the actionReload should cause the next load to call all loaders let B = await t.navigate("/bar"); await B.loaders.root.resolve("ROOT*"); @@ -1337,8 +1329,8 @@ describe("fetchers", () => { bar: "BAR", }, }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("A ACTION"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("A ACTION"); }); it("forces all loaders to revalidate on interrupted fetcher submissionRedirect", async () => { @@ -1362,8 +1354,8 @@ describe("fetchers", () => { bar: "BAR", }, }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBeUndefined(); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBeUndefined(); }); }); @@ -1381,7 +1373,7 @@ describe("fetchers", () => { // The fetcher loader redirect should be ignored await A.loaders.foo.redirect("/baz"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); await B.loaders.bar.resolve("BAR"); expect(t.router.state).toMatchObject({ @@ -1392,7 +1384,7 @@ describe("fetchers", () => { bar: "BAR", }, }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.fetchers.get(key)?.data).toBeUndefined(); }); }); @@ -1412,7 +1404,7 @@ describe("fetchers", () => { // This redirect should be ignored await A.actions.foo.redirect("/baz"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); await B.loaders.root.resolve("ROOT*"); await B.loaders.bar.resolve("BAR"); @@ -1424,7 +1416,7 @@ describe("fetchers", () => { bar: "BAR", }, }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.fetchers.get(key)?.data).toBeUndefined(); }); @@ -1465,14 +1457,14 @@ describe("fetchers", () => { // This redirect should be ignored await A.actions.foo.redirect("/baz"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state).toMatchObject({ navigation: IDLE_NAVIGATION, location: { pathname: "/bar" }, loaderData: {}, }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.fetchers.get(key)?.data).toBeUndefined(); }); }); @@ -1513,8 +1505,8 @@ describe("fetchers", () => { bar: "BAR", }, }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("FOO"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("FOO"); }); it("processes second fetcher load redirect after interruption by normal POST navigation", async () => { @@ -1566,7 +1558,7 @@ describe("fetchers", () => { foobar: "FOOBAR", }, }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); expect(t.router.state.fetchers.get(key)?.data).toBe(undefined); }); }); @@ -1599,7 +1591,7 @@ describe("fetchers", () => { navigation: { location: { pathname: "/baz" } }, location: { pathname: "/" }, }); - expect(t.router.state.fetchers.get(keyA)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(keyA)).toBeUndefined(); expect(t.router.state.fetchers.get(keyB)?.state).toBe("loading"); // Resolve the navigation loader @@ -1612,8 +1604,8 @@ describe("fetchers", () => { baz: "BAZ", }, }); - expect(t.router.state.fetchers.get(keyA)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(keyB)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(keyA)).toBeUndefined(); + expect(t.router.state.fetchers.get(keyB)).toBeUndefined(); }); }); }); @@ -1650,10 +1642,8 @@ describe("fetchers", () => { await C.loaders.root.resolve("ROOT*"); await C.loaders.tasks.resolve("TASKS LOADER"); await C.loaders.tasksId.resolve("TASKS ID*"); - expect(t.router.state.fetchers.get(key1)).toMatchObject({ - state: "idle", - data: "TASKS ID*", - }); + expect(t.router.state.fetchers.get(key1)).toBeUndefined(); + expect(t.fetcherData.get(key1)).toEqual("TASKS ID*"); // If a fetcher does a submission, it unsets the revalidation aspect let D = await t.fetch("/tasks/3", key1, { @@ -1663,10 +1653,8 @@ describe("fetchers", () => { await D.actions.tasksId.resolve("TASKS 3"); await D.loaders.root.resolve("ROOT**"); await D.loaders.tasks.resolve("TASKS**"); - expect(t.router.state.fetchers.get(key1)).toMatchObject({ - state: "idle", - data: "TASKS 3", - }); + expect(t.router.state.fetchers.get(key1)).toBeUndefined(); + expect(t.fetcherData.get(key1)).toEqual("TASKS 3"); let E = await t.navigate("/tasks", { formMethod: "post", @@ -1677,10 +1665,8 @@ describe("fetchers", () => { await E.actions.tasks.resolve("TASKS***"); // Remains the same state as it was after the submission - expect(t.router.state.fetchers.get(key1)).toMatchObject({ - state: "idle", - data: "TASKS 3", - }); + expect(t.router.state.fetchers.get(key1)).toBeUndefined(); + expect(t.fetcherData.get(key1)).toEqual("TASKS 3"); }); it("revalidates fetchers on action redirects", async () => { @@ -1712,10 +1698,8 @@ describe("fetchers", () => { await D.loaders.root.resolve("ROOT*"); await D.loaders.index.resolve("INDEX*"); await D.loaders.tasksId.resolve("TASKS ID*"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS ID*", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS ID*"); }); it("revalidates fetchers on action errors", async () => { @@ -1745,10 +1729,8 @@ describe("fetchers", () => { // Resolve navigation loaders + fetcher loader await C.loaders.root.resolve("ROOT*"); await C.loaders.tasksId.resolve("TASKS ID*"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS ID*", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS ID*"); }); it("does not revalidate fetchers on searchParams changes", async () => { @@ -1766,10 +1748,8 @@ describe("fetchers", () => { let A = await t.fetch("/?index", key); await A.loaders.index.resolve("FETCH 1"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("FETCH 1"); let B = await t.navigate("/tasks/1?key=value", undefined, ["index"]); await B.loaders.root.resolve("ROOT 2"); @@ -1778,10 +1758,8 @@ describe("fetchers", () => { root: "ROOT 2", tasksId: "TASK 2", }); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("FETCH 1"); expect(B.loaders.index.stub).not.toHaveBeenCalled(); }); @@ -1800,10 +1778,8 @@ describe("fetchers", () => { let A = await t.fetch("/?index", key); await A.loaders.index.resolve("FETCH 1"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("FETCH 1"); let B = await t.navigate("/tasks/1", undefined, ["index"]); await B.loaders.root.resolve("ROOT 2"); @@ -1812,10 +1788,8 @@ describe("fetchers", () => { root: "ROOT 2", tasksId: "TASK 2", }); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("FETCH 1"); expect(B.loaders.index.stub).not.toHaveBeenCalled(); }); @@ -1829,10 +1803,8 @@ describe("fetchers", () => { let A = await t.fetch("/", key); await A.loaders.root.resolve("ROOT FETCH"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "ROOT FETCH", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("ROOT FETCH"); let B = await t.navigate("/tasks"); await B.loaders.tasks.resolve("TASKS"); @@ -1840,10 +1812,8 @@ describe("fetchers", () => { root: "ROOT", tasks: "TASKS", }); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "ROOT FETCH", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("ROOT FETCH"); }); it("respects shouldRevalidate for the fetcher route", async () => { @@ -1880,19 +1850,23 @@ describe("fetchers", () => { loaderData: { root: count }, }, }); + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get(key)?.data) { + fetcherData = state.fetchers.get(key)?.data; + } + }); expect(router.state.loaderData).toMatchObject({ root: 0, }); - expect(router.getFetcher(key)).toBe(IDLE_FETCHER); + expect(router.state.fetchers.get(key)).toBeUndefined(); // Fetch from a different route router.fetch(key, "root", "/fetch"); await tick(); - expect(router.getFetcher(key)).toMatchObject({ - state: "idle", - data: 1, - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe(1); // Post to the current route router.navigate("/two/three", { @@ -1903,10 +1877,8 @@ describe("fetchers", () => { expect(router.state.loaderData).toMatchObject({ root: 2, }); - expect(router.getFetcher(key)).toMatchObject({ - state: "idle", - data: 1, - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe(1); expect(shouldRevalidate.mock.calls[0][0]).toMatchInlineSnapshot(` { "actionResult": null, @@ -1951,10 +1923,8 @@ describe("fetchers", () => { let A = await t.fetch("/tasks/1", key); await A.loaders.tasksId.resolve("ROOT FETCH"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "ROOT FETCH", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("ROOT FETCH"); let B = await t.navigate("/tasks", { formMethod: "post", @@ -1992,10 +1962,8 @@ describe("fetchers", () => { // Load a fetcher let A = await t.fetch("/tasks/1", key); await A.loaders.tasksId.resolve("TASKS ID"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS ID", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS ID"); // Submit a fetcher, leaves loaded fetcher untouched let C = await t.fetch("/tasks", actionKey, { @@ -2003,10 +1971,8 @@ describe("fetchers", () => { formData: createFormData({}), }); t.shimHelper(C.loaders, "fetch", "loader", "tasksId"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS ID", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS ID"); expect(t.router.state.fetchers.get(actionKey)).toMatchObject({ state: "submitting", }); @@ -2016,8 +1982,8 @@ describe("fetchers", () => { await C.actions.tasks.resolve("TASKS ACTION"); expect(t.router.state.fetchers.get(key)).toMatchObject({ state: "loading", - data: "TASKS ID", }); + expect(t.fetcherData.get(key)).toBe("TASKS ID"); expect(t.router.state.fetchers.get(actionKey)).toMatchObject({ state: "loading", data: "TASKS ACTION", @@ -2032,14 +1998,10 @@ describe("fetchers", () => { root: "ROOT*", index: "INDEX*", }); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS ID*", - }); - expect(t.router.state.fetchers.get(actionKey)).toMatchObject({ - state: "idle", - data: "TASKS ACTION", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS ID*"); + expect(t.router.state.fetchers.get(actionKey)).toBeUndefined(); + expect(t.fetcherData.get(actionKey)).toEqual("TASKS ACTION"); }); it("does not revalidate fetchers initiated from removed routes", async () => { @@ -2054,10 +2016,8 @@ describe("fetchers", () => { // Trigger a fetch from the index route let A = await t.fetch("/tasks/1", key, "index"); await A.loaders.tasksId.resolve("TASKS"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS"); // Navigate such that the index route will be removed let B = await t.navigate("/tasks", { @@ -2070,10 +2030,8 @@ describe("fetchers", () => { // Fetcher should remain in an idle state since it's calling route is // being removed - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS"); // Resolve navigation loaders await B.loaders.root.resolve("ROOT*"); @@ -2082,10 +2040,8 @@ describe("fetchers", () => { expect(t.router.state.location.pathname).toBe("/tasks"); // Fetcher never got called - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "TASKS", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("TASKS"); }); it("cancels in-flight fetcher.loads on action submission and forces reload", async () => { @@ -2138,18 +2094,14 @@ describe("fetchers", () => { let keyA = "a"; let A = await t.fetch("/fetch-a", keyA); await A.loaders.fetchA.resolve("A"); - expect(t.router.state.fetchers.get(keyA)).toMatchObject({ - state: "idle", - data: "A", - }); + expect(t.router.state.fetchers.get(keyA)).toBeUndefined(); + expect(t.fetcherData.get(keyA)).toEqual("A"); let keyB = "b"; let B = await t.fetch("/fetch-b", keyB); await B.loaders.fetchB.resolve("B"); - expect(t.router.state.fetchers.get(keyB)).toMatchObject({ - state: "idle", - data: "B", - }); + expect(t.router.state.fetchers.get(keyB)).toBeUndefined(); + expect(t.fetcherData.get(keyB)).toEqual("B"); // Fetch again for B let B2 = await t.fetch("/fetch-b", keyB); @@ -2177,7 +2129,7 @@ describe("fetchers", () => { expect(B.loaders.fetchB.signal.aborted).toBe(false); expect(B2.loaders.fetchB.signal.aborted).toBe(true); expect(C.loaders.fetchC.signal.aborted).toBe(true); - expect(t.router.state.fetchers.get(keyA)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(keyA)).toBeUndefined(); expect(t.router.state.fetchers.get(keyB)?.state).toBe("loading"); expect(t.router.state.fetchers.get(keyC)?.state).toBe("loading"); await B.loaders.fetchB.resolve("B"); // ignored due to abort @@ -2186,7 +2138,7 @@ describe("fetchers", () => { // Resolve the action await D.actions.action.resolve("ACTION"); expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.fetchers.get(keyA)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(keyA)).toBeUndefined(); expect(t.router.state.fetchers.get(keyB)?.state).toBe("loading"); expect(t.router.state.fetchers.get(keyC)?.state).toBe("loading"); @@ -2194,18 +2146,12 @@ describe("fetchers", () => { await D.loaders.fetchB.resolve("B2"); await D.loaders.fetchC.resolve("C"); expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.fetchers.get(keyA)).toMatchObject({ - state: "idle", - data: "A", - }); - expect(t.router.state.fetchers.get(keyB)).toMatchObject({ - state: "idle", - data: "B2", - }); - expect(t.router.state.fetchers.get(keyC)).toMatchObject({ - state: "idle", - data: "C", - }); + expect(t.router.state.fetchers.get(keyA)).toBeUndefined(); + expect(t.fetcherData.get(keyA)).toEqual("A"); + expect(t.router.state.fetchers.get(keyB)).toBeUndefined(); + expect(t.fetcherData.get(keyB)).toEqual("B2"); + expect(t.router.state.fetchers.get(keyC)).toBeUndefined(); + expect(t.fetcherData.get(keyC)).toEqual("C"); }); it("does not cancel pending action navigation on deletion of revalidating fetcher", async () => { @@ -2233,8 +2179,8 @@ describe("fetchers", () => { // Fetcher should go back into a loading state expect(t.router.state.fetchers.get(key1)).toMatchObject({ state: "loading", - data: "TASKS 1", }); + expect(t.fetcherData.get(key1)).toBe("TASKS 1"); // Delete fetcher in the middle of the revalidation t.router.deleteFetcher(key1); @@ -2279,8 +2225,8 @@ describe("fetchers", () => { // Fetcher should go back into a loading state expect(t.router.state.fetchers.get(key1)).toMatchObject({ state: "loading", - data: "TASKS 1", }); + expect(t.fetcherData.get(key1)).toBe("TASKS 1"); // Delete fetcher in the middle of the revalidation t.router.deleteFetcher(key1); @@ -2320,10 +2266,8 @@ describe("fetchers", () => { let C = await t.revalidate(); // Fetcher should not go back into a loading state since it's a revalidation - expect(t.router.state.fetchers.get(key1)).toMatchObject({ - state: "idle", - data: "TASKS 1", - }); + expect(t.router.state.fetchers.get(key1)).toBeUndefined(); + expect(t.fetcherData.get(key1)).toEqual("TASKS 1"); // Delete fetcher in the middle of the revalidation t.router.deleteFetcher(key1); @@ -2367,8 +2311,8 @@ describe("fetchers", () => { await C.actions.tasks.resolve("TASKS ACTION"); expect(t.router.state.fetchers.get(key)).toMatchObject({ state: "loading", - data: "TASKS ID", }); + expect(t.fetcherData.get(key)).toBe("TASKS ID"); expect(t.router.state.fetchers.get(actionKey)).toMatchObject({ state: "loading", data: "TASKS ACTION", @@ -2387,10 +2331,8 @@ describe("fetchers", () => { index: "INDEX*", }); expect(t.router.state.fetchers.get(key)).toBe(undefined); - expect(t.router.state.fetchers.get(actionKey)).toMatchObject({ - state: "idle", - data: "TASKS ACTION", - }); + expect(t.router.state.fetchers.get(actionKey)).toBeUndefined(); + expect(t.fetcherData.get(actionKey)).toEqual("TASKS ACTION"); }); it("handles revalidating fetcher when the triggering fetcher is deleted", async () => { @@ -2446,10 +2388,8 @@ describe("fetchers", () => { await B.loaders.fetch.resolve("FETCH*"); expect(t.router.state.loaderData).toEqual({ home: "HOME*" }); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH*", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("FETCH*"); expect(t.router.state.fetchers.get(actionKey)).toBeUndefined(); }); @@ -2508,10 +2448,8 @@ describe("fetchers", () => { // Complete the navigation await B.loaders.fetch.resolve("B"); expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "B", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("B"); expect(spy).not.toHaveBeenCalled(); }); @@ -2551,15 +2489,13 @@ describe("fetchers", () => { expect(A.loaders.fetch.signal.aborted).toBe(false); await A.loaders.fetch.resolve("A"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); // Complete the navigation await B.loaders.page.resolve("PAGE"); expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "A", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toEqual("A"); expect(spy).not.toHaveBeenCalled(); }); }); @@ -2596,11 +2532,11 @@ describe("fetchers", () => { // fetcher.load() let A = await t.fetch("/parent", key); await A.loaders.parent.resolve("PARENT LOADER"); - expect(t.router.getFetcher(key).data).toBe("PARENT LOADER"); + expect(t.fetcherData.get(key)).toBe("PARENT LOADER"); let B = await t.fetch("/parent?index", key); await B.loaders.index.resolve("INDEX LOADER"); - expect(t.router.getFetcher(key).data).toBe("INDEX LOADER"); + expect(t.fetcherData.get(key)).toBe("INDEX LOADER"); // fetcher.submit({}, { method: 'get' }) let C = await t.fetch("/parent", key, { @@ -2608,14 +2544,14 @@ describe("fetchers", () => { formData: createFormData({}), }); await C.loaders.parent.resolve("PARENT LOADER"); - expect(t.router.getFetcher(key).data).toBe("PARENT LOADER"); + expect(t.fetcherData.get(key)).toBe("PARENT LOADER"); let D = await t.fetch("/parent?index", key, { formMethod: "get", formData: createFormData({}), }); await D.loaders.index.resolve("INDEX LOADER"); - expect(t.router.getFetcher(key).data).toBe("INDEX LOADER"); + expect(t.fetcherData.get(key)).toBe("INDEX LOADER"); // fetcher.submit({}, { method: 'post' }) let E = await t.fetch("/parent", key, { @@ -2623,14 +2559,14 @@ describe("fetchers", () => { formData: createFormData({}), }); await E.actions.parent.resolve("PARENT ACTION"); - expect(t.router.getFetcher(key).data).toBe("PARENT ACTION"); + expect(t.fetcherData.get(key)).toBe("PARENT ACTION"); let F = await t.fetch("/parent?index", key, { formMethod: "post", formData: createFormData({}), }); await F.actions.index.resolve("INDEX ACTION"); - expect(t.router.getFetcher(key).data).toBe("INDEX ACTION"); + expect(t.fetcherData.get(key)).toBe("INDEX ACTION"); }); it("throws a 404 ErrorResponse without ?index and parent route has no loader", async () => { @@ -2665,7 +2601,7 @@ describe("fetchers", () => { }, } `); - expect(t.router.getFetcher(key).data).toBe(undefined); + expect(t.fetcherData.get(key)).toBe(undefined); }); it("throws a 404 ErrorResponse with ?index and index route has no loader", async () => { @@ -2700,7 +2636,7 @@ describe("fetchers", () => { }, } `); - expect(t.router.getFetcher(key).data).toBe(undefined); + expect(t.fetcherData.get(key)).toBe(undefined); }); it("throws a 405 ErrorResponse without ?index and parent route has no action", async () => { @@ -2737,7 +2673,7 @@ describe("fetchers", () => { }, } `); - expect(t.router.getFetcher(key).data).toBe(undefined); + expect(t.fetcherData.get(key)).toBe(undefined); }); it("throws a 405 ErrorResponse with ?index and index route has no action", async () => { @@ -2774,7 +2710,7 @@ describe("fetchers", () => { }, } `); - expect(t.router.getFetcher(key).data).toBe(undefined); + expect(t.fetcherData.get(key)).toBe(undefined); }); }); diff --git a/packages/router/__tests__/lazy-test.ts b/packages/router/__tests__/lazy-test.ts index aa7f7ad53f..6007c0cc49 100644 --- a/packages/router/__tests__/lazy-test.ts +++ b/packages/router/__tests__/lazy-test.ts @@ -119,8 +119,8 @@ describe("lazily loaded route modules", () => { expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); await loaderDfd.resolve("LAZY LOADER"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("LAZY LOADER"); }); it("fetches lazy route modules on fetcher.submit", async () => { @@ -140,8 +140,8 @@ describe("lazily loaded route modules", () => { expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDfd.resolve("LAZY ACTION"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY ACTION"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("LAZY ACTION"); }); it("fetches lazy route modules on staticHandler.query()", async () => { @@ -574,8 +574,8 @@ describe("lazily loaded route modules", () => { await loaderDfdA.resolve("LAZY LOADER A"); await loaderDfdB.resolve("LAZY LOADER B"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER B"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("LAZY LOADER B"); expect(lazyLoaderStubA).not.toHaveBeenCalled(); expect(lazyloaderStubB).toHaveBeenCalledTimes(2); }); @@ -615,8 +615,8 @@ describe("lazily loaded route modules", () => { await actionDfdA.resolve("LAZY ACTION A"); await actionDfdB.resolve("LAZY ACTION B"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY ACTION B"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("LAZY ACTION B"); expect(lazyActionStubA).not.toHaveBeenCalled(); expect(lazyActionStubB).toHaveBeenCalledTimes(2); }); @@ -740,8 +740,8 @@ describe("lazily loaded route modules", () => { await loaderDfdA.resolve("LAZY LOADER A"); await loaderDfdB.resolve("LAZY LOADER B"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER B"); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("LAZY LOADER B"); expect(lazyLoaderStubA).not.toHaveBeenCalled(); expect(lazyLoaderStubB).toHaveBeenCalledTimes(2); }); diff --git a/packages/router/__tests__/redirects-test.ts b/packages/router/__tests__/redirects-test.ts index d40929f821..6ef3bf706c 100644 --- a/packages/router/__tests__/redirects-test.ts +++ b/packages/router/__tests__/redirects-test.ts @@ -156,10 +156,7 @@ describe("redirects", () => { loaderData: {}, errors: null, }); - expect(t.router.state.fetchers.get("key")).toMatchObject({ - state: "idle", - data: undefined, - }); + expect(t.router.state.fetchers.get("key")).toBeUndefined(); }); it("supports relative routing in redirects (from child fetch loader)", async () => { diff --git a/packages/router/__tests__/revalidate-test.ts b/packages/router/__tests__/revalidate-test.ts index 8595ef4895..119d4664be 100644 --- a/packages/router/__tests__/revalidate-test.ts +++ b/packages/router/__tests__/revalidate-test.ts @@ -898,17 +898,13 @@ describe("router.revalidate", () => { let key = "key"; let F = await t.fetch("/", key); await F.loaders.root.resolve("ROOT_DATA*"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "ROOT_DATA*", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("ROOT_DATA*"); let R = await t.revalidate(); await R.loaders.root.resolve("ROOT_DATA**"); await R.loaders.index.resolve("INDEX_DATA"); - expect(t.router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "ROOT_DATA**", - }); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.fetcherData.get(key)).toBe("ROOT_DATA**"); }); }); diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index bbe7aeced5..3cdcafda5b 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -2511,10 +2511,17 @@ describe("a router", () => { }); router.initialize(); + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get("key")?.data) { + fetcherData = state.fetchers.get("key")?.data; + } + }); + let key = "key"; router.fetch(key, "root", "/foo"); await fooDfd.resolve("FOO"); - expect(router.state.fetchers.get("key")?.data).toBe("FOO"); + expect(fetcherData).toBe("FOO"); let rootDfd2 = createDeferred(); let newRoutes: AgnosticDataRouteObject[] = [ @@ -2615,10 +2622,17 @@ describe("a router", () => { }); router.initialize(); + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get("key")?.data) { + fetcherData = state.fetchers.get("key")?.data; + } + }); + let key = "key"; router.fetch(key, "root", "/foo"); await fooDfd.resolve("FOO"); - expect(router.state.fetchers.get("key")?.data).toBe("FOO"); + expect(fetcherData).toBe("FOO"); let rootDfd2 = createDeferred(); let newRoutes: AgnosticDataRouteObject[] = [ @@ -2659,7 +2673,7 @@ describe("a router", () => { expect(router.state.loaderData).toEqual({ root: "ROOT*", }); - // Fetcher should have been revalidated but theown a 404 wince the route was removed + // Fetcher should have been revalidated but thrown a 404 since the route was removed expect(router.state.fetchers.get("key")?.data).toBe(undefined); expect(router.state.errors).toEqual({ root: new ErrorResponseImpl( diff --git a/packages/router/__tests__/should-revalidate-test.ts b/packages/router/__tests__/should-revalidate-test.ts index 63801d4edb..2d9a4e8019 100644 --- a/packages/router/__tests__/should-revalidate-test.ts +++ b/packages/router/__tests__/should-revalidate-test.ts @@ -541,12 +541,18 @@ describe("shouldRevalidate", () => { await tick(); let key = "key"; + + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get(key)?.data) { + fetcherData = state.fetchers.get(key)?.data; + } + }); + router.fetch(key, "root", "/fetch"); await tick(); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("FETCH 1"); expect(shouldRevalidate.mock.calls.length).toBe(0); // Normal navigations should trigger fetcher shouldRevalidate with @@ -561,10 +567,8 @@ describe("shouldRevalidate", () => { nextUrl: expect.urlMatch("http://localhost/child"), defaultShouldRevalidate: false, }); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("FETCH 1"); router.navigate("/"); await tick(); @@ -576,10 +580,8 @@ describe("shouldRevalidate", () => { nextUrl: expect.urlMatch("http://localhost/"), defaultShouldRevalidate: false, }); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("FETCH 1"); // Submission navigations should trigger fetcher shouldRevalidate with // defaultShouldRevalidate=true @@ -588,10 +590,8 @@ describe("shouldRevalidate", () => { formData: createFormData({}), }); await tick(); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("FETCH 1"); expect(shouldRevalidate.mock.calls.length).toBe(3); expect(shouldRevalidate.mock.calls[2][0]).toMatchObject({ currentParams: {}, @@ -639,15 +639,21 @@ describe("shouldRevalidate", () => { await tick(); let key = "key"; + + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get(key)?.data) { + fetcherData = state.fetchers.get(key)?.data; + } + }); + router.fetch(key, "root", "/fetch", { formMethod: "post", formData: createFormData({ key: "value" }), }); await tick(); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("FETCH"); let arg = shouldRevalidate.mock.calls[0][0]; expect(arg).toMatchInlineSnapshot(` @@ -702,15 +708,20 @@ describe("shouldRevalidate", () => { await tick(); let key = "key"; + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get(key)?.data) { + fetcherData = state.fetchers.get(key)?.data; + } + }); + router.fetch(key, "root", "/fetch", { formMethod: "post", formData: createFormData({ key: "value" }), }); await tick(); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: undefined, - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe(undefined); let arg = shouldRevalidate.mock.calls[0][0]; expect(arg).toMatchInlineSnapshot(` @@ -817,16 +828,20 @@ describe("shouldRevalidate", () => { await tick(); let key = "key"; + let fetcherData; + router.subscribe((state) => { + if (state.fetchers.get(key)?.data) { + fetcherData = state.fetchers.get(key)?.data; + } + }); router.fetch(key, "root", "/fetch", { formMethod: "post", formData: createFormData({ key: "value" }), }); await tick(); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 1", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("FETCH 1"); expect(router.state.loaderData).toMatchObject({ index: "INDEX", }); @@ -836,10 +851,8 @@ describe("shouldRevalidate", () => { formData: createFormData({ key: "value" }), }); await tick(); - expect(router.state.fetchers.get(key)).toMatchObject({ - state: "idle", - data: "FETCH 2", - }); + expect(router.state.fetchers.get(key)).toBeUndefined(); + expect(fetcherData).toBe("FETCH 2"); expect(router.state.loaderData).toMatchObject({ index: "INDEX", }); diff --git a/packages/router/__tests__/utils/data-router-setup.ts b/packages/router/__tests__/utils/data-router-setup.ts index 9fae3d2cab..aea16b2924 100644 --- a/packages/router/__tests__/utils/data-router-setup.ts +++ b/packages/router/__tests__/utils/data-router-setup.ts @@ -16,6 +16,7 @@ import { matchRoutes, redirect, parsePath, + IDLE_FETCHER, } from "../../index"; // Private API @@ -320,6 +321,15 @@ export function setup({ window: testWindow, }).initialize(); + let fetcherData = new Map(); + currentRouter.subscribe((state) => { + state.fetchers.forEach((fetcher, key) => { + if (fetcher.data) { + fetcherData.set(key, fetcher.data); + } + }); + }); + function getRouteHelpers( routeId: string, navigationId: number, @@ -489,7 +499,11 @@ export function setup({ navigationId, get fetcher() { invariant(currentRouter, "No currentRouter available"); - return currentRouter.getFetcher(key); + let fetcher = currentRouter.state.fetchers.get(key) || IDLE_FETCHER; + return { + ...fetcher, + data: fetcherData.get(key), + }; }, lazy: {}, loaders: {}, @@ -544,7 +558,11 @@ export function setup({ navigationId, get fetcher() { invariant(currentRouter, "No currentRouter available"); - return currentRouter.getFetcher(key); + let fetcher = currentRouter.state.fetchers.get(key) || IDLE_FETCHER; + return { + ...fetcher, + data: fetcherData.get(key), + }; }, lazy: lazyHelpers, loaders: loaderHelpers, @@ -727,6 +745,7 @@ export function setup({ window: testWindow, history, router: currentRouter, + fetcherData, navigate, fetch, revalidate, diff --git a/packages/router/__tests__/utils/utils.ts b/packages/router/__tests__/utils/utils.ts index 1b6a5511e1..bc3e588372 100644 --- a/packages/router/__tests__/utils/utils.ts +++ b/packages/router/__tests__/utils/utils.ts @@ -28,11 +28,11 @@ export function isRedirect(result: any) { ); } -export function createDeferred() { +export function createDeferred() { let resolve: (val?: any) => Promise; let reject: (error?: Error) => Promise; - let promise = new Promise((res, rej) => { - resolve = async (val: any) => { + let promise = new Promise((res, rej) => { + resolve = async (val: T) => { res(val); try { await promise; diff --git a/packages/router/router.ts b/packages/router/router.ts index 2eb7bc8327..28e1b0192c 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -186,15 +186,6 @@ export interface Router { */ encodeLocation(to: To): Path; - /** - * @internal - * PRIVATE - DO NOT USE - * - * Get/create a fetcher for the given key - * @param key - */ - getFetcher(key?: string): Fetcher; - /** * @internal * PRIVATE - DO NOT USE @@ -202,7 +193,7 @@ export interface Router { * Delete the fetcher for a given key * @param key */ - deleteFetcher(key?: string): void; + deleteFetcher(key: string): void; /** * @internal @@ -1017,6 +1008,15 @@ export function createRouter(init: RouterInit): Router { subscribers.forEach((subscriber) => subscriber(state, { unstable_viewTransitionOpts: viewTransitionOpts }) ); + + // Remove idle fetchers from state since we only care about in-flight fetchers. + // We keep fetchLoadeMatches around for revalidations purposes. + // The React layer persists the data for completed fetchers. + state.fetchers.forEach((fetcher, key) => { + if (fetcher.state === "idle") { + state.fetchers.delete(key); + } + }); } // Complete a navigation returning the state.navigation back to the IDLE_NAVIGATION @@ -1724,10 +1724,6 @@ export function createRouter(init: RouterInit): Router { }; } - function getFetcher(key: string): Fetcher { - return state.fetchers.get(key) || IDLE_FETCHER; - } - // Trigger a fetcher load/submit for the given fetcher key function fetch( key: string, @@ -2009,7 +2005,7 @@ export function createRouter(init: RouterInit): Router { state.fetchers.set(key, doneFetcher); } - let didAbortFetchLoads = abortStaleFetchLoads(loadId); + abortStaleFetchLoads(loadId); // If we are currently in a navigation loading state and this fetcher is // more recent than the navigation, we want the newer data so abort the @@ -2039,9 +2035,7 @@ export function createRouter(init: RouterInit): Router { matches, errors ), - ...(didAbortFetchLoads || revalidatingFetchers.length > 0 - ? { fetchers: new Map(state.fetchers) } - : {}), + fetchers: new Map(state.fetchers), }); isRevalidationRequired = false; } @@ -2364,6 +2358,7 @@ export function createRouter(init: RouterInit): Router { function deleteFetcher(key: string): void { let fetcher = state.fetchers.get(key); + // Don't abort the controller if this is a deletion of a fetcher.submit() // in it's loading phase since - we don't want to abort the corresponding // revalidation and want them to complete and land @@ -2388,8 +2383,8 @@ export function createRouter(init: RouterInit): Router { function markFetchersDone(keys: string[]) { for (let key of keys) { - let fetcher = getFetcher(key); - let doneFetcher = getDoneFetcher(fetcher.data); + let fetcher = state.fetchers.get(key); + let doneFetcher = getDoneFetcher(fetcher ? fetcher.data : undefined); state.fetchers.set(key, doneFetcher); } } @@ -2612,7 +2607,6 @@ export function createRouter(init: RouterInit): Router { // hash-aware URLs in DOM paths createHref: (to: To) => init.history.createHref(to), encodeLocation: (to: To) => init.history.encodeLocation(to), - getFetcher, deleteFetcher, dispose, getBlocker,