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 (
+ <>
+ fetcher1.load("/fetch1")}>
+ Load 1
+
+ fetcher2.load("/fetch2")}>
+ Load 2
+
+
{`${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 (
+ <>
+ fetcher1.load("/fetch1")}>
+ Load 1
+
+ fetcher2.load("/fetch2")}>
+ Load 2
+
+ {`${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 (
+ fetcher.load("/fetch")}>
+ {`Load (${fetcher.state})`}
+
+ );
+ },
+ },
+ {
+ 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 (
+
+ fetcher.submit(
+ {},
+ { method: "post", action: "/fetch" }
+ )
+ }
+ >
+ {`Submit (${fetcher.state})`}
+
+ );
+ },
+ },
+ {
+ 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 (
+
+ fetcher.submit(
+ {},
+ { method: "post", action: "/fetch" }
+ )
+ }
+ >
+ {`Submit (${fetcher.state})`}
+
+ );
+ },
+ },
+ {
+ 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 (
+
+ fetcher.submit(
+ {},
+ { method: "post", action: "/fetch" }
+ )
+ }
+ >
+ {`Submit (${fetcher.state})`}
+
+ );
+ },
+ },
+ {
+ 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 (
+
+
+ {`Submit (${fetcher.state})`}
+
+
+ );
+ },
+ },
+ {
+ 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("', 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,