diff --git a/.changeset/memory-submit-fetcher.md b/.changeset/memory-submit-fetcher.md new file mode 100644 index 0000000000..6d05071541 --- /dev/null +++ b/.changeset/memory-submit-fetcher.md @@ -0,0 +1,5 @@ +--- +"react-router": minor +--- + +Add `useSubmit`/`useFetcher` support to `createMemoryRouter` apps in `react-router` now that we can support non-DOM submissions diff --git a/docs/hooks/use-fetcher.md b/docs/hooks/use-fetcher.md index 9f5533ec52..361ccbfa40 100644 --- a/docs/hooks/use-fetcher.md +++ b/docs/hooks/use-fetcher.md @@ -82,6 +82,8 @@ function SomeComponent() { } ``` +`fetcher.Form` is only available in the DOM-based versions of React Router (`createBrowserRouter`and `createHashRouter`) but not in the memory-based versions (`createMemoryRouter`) + ## `fetcher.load()` Loads data from a route loader. diff --git a/docs/hooks/use-submit.md b/docs/hooks/use-submit.md index 3157066e32..363a6906b8 100644 --- a/docs/hooks/use-submit.md +++ b/docs/hooks/use-submit.md @@ -106,7 +106,9 @@ let text = "Plain ol' text"; submit(obj, { encType: "text/plain" }); // -> request.text() ``` -In future versions of React Router, the default behavior will not serialize raw JSON payloads. If you are submitting raw JSON today it's recommended to specify an explicit `encType`. +If you're using `createMemoryRouter`, then the `FormData` APIs of `useSubmit` aren't relevant, and all submissions are `payload` based. + +In future versions of React Router DOM, the default behavior will not serialize raw JSON payloads. If you are submitting raw JSON today it's recommended to specify an explicit `encType`. ### Opting out of serialization diff --git a/package.json b/package.json index 227697dde6..3acc238e8e 100644 --- a/package.json +++ b/package.json @@ -108,10 +108,10 @@ "none": "45.8 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "12.9 kB" + "none": "14.0 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "15.3 kB" + "none": "16.4 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { "none": "12 kB" diff --git a/packages/react-router-dom/__tests__/exports-test.tsx b/packages/react-router-dom/__tests__/exports-test.tsx index 46e24e5297..7ad1341859 100644 --- a/packages/react-router-dom/__tests__/exports-test.tsx +++ b/packages/react-router-dom/__tests__/exports-test.tsx @@ -3,16 +3,22 @@ import * as ReactRouterDOM from "react-router-dom"; let nonReExportedKeys = new Set(["UNSAFE_mapRouteProperties"]); +let exportedButDifferent = new Set(["useFetcher", "useFetchers", "useSubmit"]); + describe("react-router-dom", () => { for (let key in ReactRouter) { - if (!nonReExportedKeys.has(key)) { - it(`re-exports ${key} from react-router`, () => { - expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); - }); - } else { + if (nonReExportedKeys.has(key)) { it(`does not re-export ${key} from react-router`, () => { expect(ReactRouterDOM[key]).toBe(undefined); }); + } else if (exportedButDifferent.has(key)) { + it(`exports ${key} but not from react-router`, () => { + expect(ReactRouterDOM[key]).not.toBe(ReactRouter[key]); + }); + } else { + it(`re-exports ${key} from react-router`, () => { + expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); + }); } } }); diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index c11c0093c7..d9dbfdd2ac 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -128,13 +128,14 @@ export interface SubmitOptions { method?: HTMLFormMethod; /** - * The action URL path used to submit the form. Overrides `
`. - * Defaults to the path of the current route. + * The action URL path used to submit the form (or a direct action to be + * executed). Overrides ``. Defaults to the path of the current + * route. */ action?: string | ActionFunction; /** - * The action URL used to submit the form. Overrides ``. + * The encType to be used to encode the submission. Overrides ``. * Defaults to "application/x-www-form-urlencoded". Specifying `null` will * opt-out of serialization and will submit the data directly to your action * in the `payload` parameter. diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index a8351110e4..b69510c132 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -979,7 +979,7 @@ function useSubmitImpl( let path = typeof options.action === "function" ? null : options.action || action; let routerAction = - typeof options.action === "function" ? options.action : null; + typeof options.action === "function" ? options.action : undefined; // Base options shared between fetch() and navigate() let opts = { diff --git a/packages/react-router-native/__tests__/exports-test.tsx b/packages/react-router-native/__tests__/exports-test.tsx index d851649e80..5b53e28dfe 100644 --- a/packages/react-router-native/__tests__/exports-test.tsx +++ b/packages/react-router-native/__tests__/exports-test.tsx @@ -5,14 +5,14 @@ let nonReExportedKeys = new Set(["UNSAFE_mapRouteProperties"]); describe("react-router-native", () => { for (let key in ReactRouter) { - if (!nonReExportedKeys.has(key)) { - it(`re-exports ${key} from react-router`, () => { - expect(ReactRouterNative[key]).toBe(ReactRouter[key]); - }); - } else { + if (nonReExportedKeys.has(key)) { it(`does not re-export ${key} from react-router`, () => { expect(ReactRouterNative[key]).toBe(undefined); }); + } else { + it(`re-exports ${key} from react-router`, () => { + expect(ReactRouterNative[key]).toBe(ReactRouter[key]); + }); } } }); diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index 706539bd7e..19c5efba1a 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -27,6 +27,7 @@ export type { unstable_BlockerFunction, DataRouteMatch, DataRouteObject, + FetcherWithMethods, Fetcher, Hash, IndexRouteObject, @@ -62,6 +63,8 @@ export type { RoutesProps, Search, ShouldRevalidateFunction, + SubmitFunction, + SubmitOptions, To, } from "react-router"; export { @@ -92,6 +95,8 @@ export { useActionData, useAsyncError, useAsyncValue, + useFetcher, + useFetchers, unstable_useBlocker, useHref, useInRouterContext, @@ -110,6 +115,7 @@ export { useRouteError, useRouteLoaderData, useRoutes, + useSubmit, } from "react-router"; /////////////////////////////////////////////////////////////////////////////// diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index 303581da20..80408194c9 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { + act, render, fireEvent, waitFor, @@ -32,6 +33,10 @@ import { useNavigation, useRevalidator, UNSAFE_DataRouterContext as DataRouterContext, + useFetcher, + useFetchers, + useSubmit, + useNavigate, } from "react-router"; describe("createMemoryRouter", () => { @@ -615,8 +620,6 @@ describe("createMemoryRouter", () => { it("executes route actions/loaders on submission navigations", async () => { let barDefer = createDeferred(); let barActionDefer = createDeferred(); - let formData = new FormData(); - formData.append("test", "value"); let router = createMemoryRouter( createRoutesFromElements( @@ -638,7 +641,11 @@ describe("createMemoryRouter", () => { let navigation = useNavigation(); return (
- + Post to Bar

{navigation.state}

@@ -907,200 +914,2184 @@ describe("createMemoryRouter", () => { bar: undefined, child: "CHILD", }); - }); + }); + + it("reloads data using useRevalidator", async () => { + let count = 1; + let router = createMemoryRouter( + createRoutesFromElements( + }> + `count=${++count}`} + element={} + /> + + ), + { + initialEntries: ["/foo"], + hydrationData: { + loaderData: { + "0-0": "count=1", + }, + }, + } + ); + let { container } = render(); + + function Layout() { + let navigation = useNavigation(); + let { revalidate, state } = useRevalidator(); + return ( +
+ +

{navigation.state}

+

{state}

+ +
+ ); + } + + function Foo() { + let data = useLoaderData() as string; + return

{data}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+ +

+ idle +

+

+ idle +

+

+ count=1 +

+
+
" + `); + + fireEvent.click(screen.getByText("Revalidate")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+ +

+ idle +

+

+ loading +

+

+ count=1 +

+
+
" + `); + + await waitFor(() => screen.getByText("count=2")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+ +

+ idle +

+

+ idle +

+

+ count=2 +

+
+
" + `); + }); + + it("renders descendent routes inside a data router", () => { + let router = createMemoryRouter( + createRoutesFromElements( + + } /> + + ), + { + initialEntries: ["/deep/path/to/descendant/routes"], + } + ); + let { container } = render(); + + function GrandChild() { + return ( + + + 👋 Hello from the other side!} + /> + + + ); + } + + function Child() { + return ( + + } /> + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ 👋 Hello from the other side! +

+
" + `); + }); + + it("renders alongside a data router ErrorBoundary", () => { + let router = createMemoryRouter( + [ + { + path: "*", + Component() { + return ( + <> + + + Descendant} /> + + + ); + }, + children: [ + { + id: "index", + index: true, + Component: () =>

Child

, + ErrorBoundary() { + return

{(useRouteError() as Error).message}

; + }, + }, + ], + }, + ], + { + initialEntries: ["/"], + hydrationData: { + errors: { + index: new Error("Broken!"), + }, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Broken! +

+

+ Descendant +

+
" + `); + }); + + describe("useSubmit", () => { + it("executes route actions/loaders on useSubmit navigations", async () => { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createMemoryRouter( + createRoutesFromElements( + actionDefer.promise} + loader={() => loaderDefer.promise} + element={} + /> + ), + { + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Home() { + let data = useLoaderData() as string; + let actionData = useActionData() as string | undefined; + let navigation = useNavigation(); + let submit = useSubmit(); + return ( +
+ +
+

{navigation.state}

+

{data}

+

{actionData}

+
+ +
+ ); + } + + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+

+

" + `); + + fireEvent.click(screen.getByText("Submit Form")); + await waitFor(() => screen.getByText("submitting")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ submitting +

+

+

+

" + `); + + actionDefer.resolve("Action Data"); + await waitFor(() => screen.getByText("loading")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ loading +

+

+

+ Action Data +

+
" + `); + + loaderDefer.resolve("Loader Data"); + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+ Loader Data +

+

+ Action Data +

+
" + `); + }); + + it("executes lazy route actions/loaders on useSubmit navigations", async () => { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createMemoryRouter( + createRoutesFromElements( + }> + Home} /> + ({ + action: () => actionDefer.promise, + loader: () => loaderDefer.promise, + element:

Action

, + })} + /> +
+ ), + {} + ); + let { container } = render(); + + function Home() { + let data = useMatches().pop()?.data as string | undefined; + let actionData = useActionData() as string | undefined; + let navigation = useNavigation(); + let submit = useSubmit(); + return ( +
+ +
+

{navigation.state}

+

{data}

+

{actionData}

+ +
+
+ ); + } + + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+

+

+ Home +

+
" + `); + + fireEvent.click(screen.getByText("Submit Form")); + await waitFor(() => screen.getByText("submitting")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ submitting +

+

+

+

+ Home +

+
" + `); + + actionDefer.resolve("Action Data"); + await waitFor(() => screen.getByText("loading")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ loading +

+

+

+ Action Data +

+

+ Home +

+
" + `); + + loaderDefer.resolve("Loader Data"); + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+ Loader Data +

+

+ Action Data +

+

+ Action +

+
" + `); + }); + + it('defaults useSubmit({ method: "get" }) to be a PUSH navigation', async () => { + let router = createMemoryRouter( + createRoutesFromElements( + }> + "index"} element={

index

} /> + "1"} element={

Page 1

} /> + "2"} element={

Page 2

} /> +
+ ), + { + hydrationData: {}, + } + ); + let { container } = render(); + + function Layout() { + let navigate = useNavigate(); + let submit = useSubmit(); + let formData = new FormData(); + formData.append("test", "value"); + return ( + <> + + +
+ +
+ + ); + } + + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ index +

+
" + `); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => screen.getByText("Page 1")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ Page 1 +

+
" + `); + + fireEvent.click(screen.getByText("Go back")); + await waitFor(() => screen.getByText("index")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ index +

+
" + `); + }); + + it('defaults useSubmit({ method: "post" }) to a new location to be a PUSH navigation', async () => { + let router = createMemoryRouter( + createRoutesFromElements( + }> + "index"} element={

index

} /> + "1"} element={

Page 1

} /> + "action"} + loader={() => "2"} + element={

Page 2

} + /> +
+ ), + { + hydrationData: {}, + } + ); + let { container } = render(); + + function Layout() { + let navigate = useNavigate(); + let submit = useSubmit(); + let formData = new FormData(); + formData.append("test", "value"); + return ( + <> + Go to 1 + + +
+ +
+ + ); + } + + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ index +

+
" + `); + + fireEvent.click(screen.getByText("Go to 1")); + await waitFor(() => screen.getByText("Page 1")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ Page 1 +

+
" + `); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => screen.getByText("Page 2")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ Page 2 +

+
" + `); + + fireEvent.click(screen.getByText("Go back")); + await waitFor(() => screen.getByText("Page 1")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ Page 1 +

+
" + `); + }); + + it('defaults useSubmit({ method: "post" }) to the same location to be a REPLACE navigation', async () => { + let router = createMemoryRouter( + createRoutesFromElements( + }> + "index"} element={

index

} /> + "action"} + loader={() => "1"} + element={

Page 1

} + /> +
+ ), + { + hydrationData: {}, + } + ); + let { container } = render(); + + function Layout() { + let navigate = useNavigate(); + let submit = useSubmit(); + let actionData = useActionData() as string | undefined; + let formData = new FormData(); + formData.append("test", "value"); + return ( + <> + Go to 1 + + +
+ {actionData ?

{actionData}

: null} + +
+ + ); + } + + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ index +

+
" + `); + + fireEvent.click(screen.getByText("Go to 1")); + await waitFor(() => screen.getByText("Page 1")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ Page 1 +

+
" + `); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => screen.getByText("action")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ action +

+

+ Page 1 +

+
" + `); + + fireEvent.click(screen.getByText("Go back")); + await waitFor(() => screen.getByText("index")); + expect(getHtml(container.querySelector(".output")!)) + .toMatchInlineSnapshot(` + "
+

+ index +

+
" + `); + }); + + it("allows direct actions to be passed to useSubmit", async () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component() { + let actionData = useActionData() as string | undefined; + let submit = useSubmit(); + return ( + <> + +

{actionData || "empty"}

+ + ); + }, + }, + ], + {} + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("empty"); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => screen.getByText("ACTION")); + expect(getHtml(container)).toMatch("ACTION"); + }); + + it("does not serialize on submit(object) submissions", async () => { + let actionSpy = jest.fn(); + let payload = { a: "1", b: "2" }; + let navigation; + let router = createMemoryRouter([ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ]); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData).toBe(undefined); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toBeNull(); + expect(request.body).toBe(null); + expect(actionPayload).toBe(payload); + }); + + it("serializes formData on submit(object)/encType:application/x-www-form-urlencoded submissions", async () => { + let actionSpy = jest.fn(); + let payload = { a: "1", b: "2" }; + let navigation; + let router = createMemoryRouter([ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ]); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData).toBeUndefined(); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toMatchInlineSnapshot( + `"application/x-www-form-urlencoded;charset=UTF-8"` + ); + let actionFormData = await request.formData(); + expect(actionFormData.get("a")).toBe("1"); + expect(actionFormData.get("b")).toBe("2"); + expect(actionPayload).toBe(payload); + }); + + it("serializes JSON on submit(object)/encType:application/json submissions", async () => { + let actionSpy = jest.fn(); + let payload = { a: "1", b: "2" }; + let navigation; + let router = createMemoryRouter([ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ]); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData).toBe(undefined); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toBe("application/json"); + expect(await request.json()).toEqual({ a: "1", b: "2" }); + expect(actionPayload).toBe(payload); + }); + + it("serializes text on submit(object)/encType:text/plain submissions", async () => { + let actionSpy = jest.fn(); + let payload = "look ma, no formData!"; + let navigation; + let router = createMemoryRouter([ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ]); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData).toBe(undefined); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toBe("text/plain"); + expect(await request.text()).toEqual(payload); + expect(actionPayload).toBe(payload); + }); + }); + + describe("useFetcher(s)", () => { + it("handles fetcher.load and fetcher.submit", async () => { + let count = 0; + let router = createMemoryRouter( + createRoutesFromElements( + } + action={({ payload }) => ({ count: count + payload.increment })} + loader={async ({ request }) => { + // Need to add a domain on here in node unit testing so it's a + // valid URL. When running in the browser the domain is + // automatically added in new Request() + let increment = + new URL(`https://remix.test${request.url}`).searchParams.get( + "increment" + ) || "1"; + count = count + parseInt(increment, 10); + return { count }; + }} + /> + ), + { + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

+ {fetcher.state} + {fetcher.data ? JSON.stringify(fetcher.data) : null} +

+ + + + + ); + } + + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ idle +

" + `); + + fireEvent.click(screen.getByText("load 1")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ loading +

" + `); + + await waitFor(() => screen.getByText(/idle/)); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ idle + {"count":1} +

" + `); + + fireEvent.click(screen.getByText("load 5")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ loading + {"count":1} +

" + `); + + await waitFor(() => screen.getByText(/idle/)); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ idle + {"count":6} +

" + `); + + fireEvent.click(screen.getByText("submit 10")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ submitting + {"count":6} +

" + `); + + await waitFor(() => screen.getByText(/idle/)); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ idle + {"count":16} +

" + `); + }); + + it("handles fetcher ?index params", async () => { + let router = createMemoryRouter( + createRoutesFromElements( + } + action={() => "PARENT ACTION"} + loader={() => "PARENT LOADER"} + > + } + action={() => "INDEX ACTION"} + loader={() => "INDEX LOADER"} + /> + + ), + { + initialEntries: ["/parent"], + hydrationData: { loaderData: { parent: null, index: null } }, + } + ); + let { container } = render(); + + function Index() { + let fetcher = useFetcher(); + + return ( + <> +

{fetcher.data}

+ + + + + + + + + ); + } + + async function clickAndAssert(btnText: string, expectedOutput: string) { + fireEvent.click(screen.getByText(btnText)); + await new Promise((r) => setTimeout(r, 1)); + await waitFor(() => screen.getByText(new RegExp(expectedOutput))); + expect(getHtml(container.querySelector("#output")!)).toContain( + expectedOutput + ); + } + + await clickAndAssert("Load parent", "PARENT LOADER"); + await clickAndAssert("Load index", "INDEX LOADER"); + await clickAndAssert("Submit empty", "INDEX LOADER"); + await clickAndAssert("Submit parent get", "PARENT LOADER"); + await clickAndAssert("Submit index get", "INDEX LOADER"); + await clickAndAssert("Submit parent post", "PARENT ACTION"); + await clickAndAssert("Submit index post", "INDEX ACTION"); + }); + + it("handles fetcher.load errors", async () => { + let router = createMemoryRouter( + createRoutesFromElements( + } + errorElement={} + loader={async () => { + throw new Error("Kaboom!"); + }} + /> + ), + { + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

+ {fetcher.state} + {fetcher.data ? JSON.stringify(fetcher.data) : null} +

+ + + ); + } + + function ErrorElement() { + let error = useRouteError() as Error; + return

{error.message}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ idle +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ loading +

+ +
" + `); + + await waitFor(() => screen.getByText("Kaboom!")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Kaboom! +

+
" + `); + }); + + it("handles fetcher.load errors (defer)", async () => { + let dfd = createDeferred(); + let router = createMemoryRouter( + createRoutesFromElements( + } + errorElement={} + loader={() => defer({ value: dfd.promise })} + /> + ), + { + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

+ {fetcher.state} + {fetcher.data ? JSON.stringify(fetcher.data.value) : null} +

+ + + ); + } + + function ErrorElement() { + let error = useRouteError() as Error; + return

{error.message}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ idle +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ loading +

+ +
" + `); + + dfd.reject(new Error("Kaboom!")); + await waitFor(() => screen.getByText("Kaboom!")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Kaboom! +

+
" + `); + }); + + it("handles fetcher.submit errors", async () => { + let router = createMemoryRouter( + createRoutesFromElements( + } + errorElement={} + action={async () => { + throw new Error("Kaboom!"); + }} + /> + ), + { + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

+ {fetcher.state} + {fetcher.data ? JSON.stringify(fetcher.data) : null} +

+ + + ); + } + + function ErrorElement() { + let error = useRouteError() as Error; + return

{error.message}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ idle +

+ +
" + `); + + fireEvent.click(screen.getByText("submit")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ submitting +

+ +
" + `); + + await waitFor(() => screen.getByText("Kaboom!")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Kaboom! +

+
" + `); + }); + + it("does not serialize fetcher.submit(object) calls", async () => { + let actionSpy = jest.fn(); + let payload = { key: "value" }; + let router = createMemoryRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let fetcher = useFetcher(); + return ( + + ); + }, + }, + ], + {} + ); + + render(); + fireEvent.click(screen.getByText("Submit")); + expect(actionSpy.mock.calls[0][0].payload).toEqual(payload); + expect(actionSpy.mock.calls[0][0].request.body).toBe(null); + }); + + it("show all fetchers via useFetchers and cleans up fetchers on unmount", async () => { + let dfd1 = createDeferred(); + let dfd2 = createDeferred(); + let router = createMemoryRouter( + createRoutesFromElements( + }> + await dfd1.promise} + element={} + /> + await dfd2.promise} + element={} + /> + + ), + { + initialEntries: ["/1"], + hydrationData: { loaderData: { "0": null, "0-0": null } }, + } + ); + let { container } = render(); + + function Parent() { + let fetchers = useFetchers(); + return ( + <> + Link to 1 + Link to 2 +
+

{JSON.stringify(fetchers.map((f) => f.state))}

+ +
+ + ); + } + + function Comp1() { + let fetcher = useFetcher(); + return ( + <> +

+ 1{fetcher.state} + {fetcher.data || "null"} +

+ + + ); + } + + function Comp2() { + let fetcher = useFetcher(); + return ( + <> +

+ 2{fetcher.state} + {fetcher.data || "null"} +

+ + + ); + } + + // Initial state - no useFetchers reflected yet + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ [] +

+

+ 1 + idle + null +

+ +
" + `); + + // Activate Comp1 fetcher + fireEvent.click(screen.getByText("load")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ ["loading"] +

+

+ 1 + loading + null +

+ +
" + `); + + // Resolve Comp1 fetcher - UI updates + dfd1.resolve("data 1"); + await waitFor(() => screen.getByText(/data 1/)); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ ["idle"] +

+

+ 1 + idle + data 1 +

+ +
" + `); + + // Link to Comp2 - loaders run + fireEvent.click(screen.getByText("Link to 2")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ ["idle"] +

+

+ 1 + idle + data 1 +

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

+ ["idle"] +

+

+ 2 + idle + null +

+ +
" + `); + + // Activate Comp2 fetcher, which now officially kicks out Comp1's + // fetcher from useFetchers and reflects Comp2's fetcher + fireEvent.click(screen.getByText("load")); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ ["loading"] +

+

+ 2 + loading + null +

+ +
" + `); + + // Comp2 loader resolves with the same data, useFetchers reflects idle-done + await waitFor(() => screen.getByText(/2.*idle/)); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ ["idle"] +

+

+ 2 + idle + data 2 +

+ +
" + `); + }); + + it("handles revalidating fetchers", async () => { + let count = 0; + let fetchCount = 0; + let router = createMemoryRouter( + createRoutesFromElements( + <> + } + action={async ({ payload }) => { + count += payload.increment; + return { count }; + }} + loader={async () => ({ count: ++count })} + /> + ({ fetchCount: ++fetchCount })} + /> + + ), + { + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + let submit = useSubmit(); + return ( + <> +

+ {fetcher.state} + {fetcher.data ? JSON.stringify(fetcher.data) : null} +

+ + + + ); + } + + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ idle +

" + `); + + await act(async () => { + fireEvent.click(screen.getByText("load fetcher")); + await waitFor(() => screen.getByText(/idle/)); + }); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ idle + {"fetchCount":1} +

" + `); + + await act(async () => { + fireEvent.click(screen.getByText("submit")); + await waitFor(() => screen.getByText(/idle/)); + }); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "

+ idle + {"fetchCount":2} +

" + `); + }); + + it("handles fetcher 404 errors at the correct spot in the route hierarchy", async () => { + let router = createMemoryRouter( + createRoutesFromElements( + } errorElement={

Not I!

}> + } + errorElement={} + /> + + ), + { + initialEntries: ["/child"], + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ; + } + + function ErrorElement() { + let { status, statusText } = useRouteError() as ErrorResponse; + return

contextual error:{`${status} ${statusText}`}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/Not Found/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ contextual error: + 404 Not Found +

+
" + `); + }); + + it("handles fetcher.load errors at the correct spot in the route hierarchy", async () => { + let router = createMemoryRouter( + createRoutesFromElements( + } errorElement={

Not I!

}> + } + errorElement={} + /> + { + throw new Error("Kaboom!"); + }} + errorElement={

Not I!

} + /> +
+ ), + { + initialEntries: ["/child"], + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ; + } + + function ErrorElement() { + let error = useRouteError() as Error; + return

contextual error:{error.message}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/Kaboom!/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ contextual error: + Kaboom! +

+
" + `); + }); + + it("handles fetcher.submit errors at the correct spot in the route hierarchy", async () => { + let router = createMemoryRouter( + createRoutesFromElements( + } errorElement={

Not I!

}> + } + errorElement={} + /> + { + throw new Error("Kaboom!"); + }} + errorElement={

Not I!

} + /> +
+ ), + { + initialEntries: ["/child"], + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + + ); + } + + function ErrorElement() { + let error = useRouteError() as Error; + return

contextual error:{error.message}

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ +
" + `); + + fireEvent.click(screen.getByText("submit")); + await waitFor(() => screen.getByText(/Kaboom!/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ contextual error: + Kaboom! +

+
" + `); + }); - it("reloads data using useRevalidator", async () => { - let count = 1; - let router = createMemoryRouter( - createRoutesFromElements( - }> - `count=${++count}`} - element={} - /> - - ), - { - initialEntries: ["/foo"], - hydrationData: { - loaderData: { - "0-0": "count=1", + it("useFetcher is stable across across location changes", async () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component() { + let [count, setCount] = React.useState(0); + let location = useLocation(); + let navigate = useNavigate(); + let fetcher = useFetcher(); + let fetcherCount = React.useRef(0); + React.useEffect(() => { + fetcherCount.current++; + }, [fetcher.submit]); + return ( + <> + +

+ {`render count:${count}`} + {`fetcher count:${fetcherCount.current}`} +

+ + ); + }, }, - }, - } - ); - let { container } = render(); - - function Layout() { - let navigation = useNavigation(); - let { revalidate, state } = useRevalidator(); - return ( -
- -

{navigation.state}

-

{state}

- -
+ ], + {} ); - } - - function Foo() { - let data = useLoaderData() as string; - return

{data}

; - } - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
- -

- idle -

-

- idle -

-

- count=1 -

-
-
" - `); + let { container } = render(); - fireEvent.click(screen.getByText("Revalidate")); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
- -

- idle -

-

- loading -

-

- count=1 -

-
-
" - `); + let html = getHtml(container); + expect(html).toContain("render count:0"); + expect(html).toContain("fetcher count:0"); - await waitFor(() => screen.getByText("count=2")); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
- -

- idle -

-

- idle -

-

- count=2 -

-
-
" - `); - }); + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + await waitFor(() => screen.getByText(/render count:3/)); - it("renders descendent routes inside a data router", () => { - let router = createMemoryRouter( - createRoutesFromElements( - - } /> - - ), - { - initialEntries: ["/deep/path/to/descendant/routes"], - } - ); - let { container } = render(); + html = getHtml(container); + expect(html).toContain("render count:3"); + expect(html).toContain("fetcher count:1"); + }); - function GrandChild() { - return ( - - - 👋 Hello from the other side!} - /> - - + it("allows direct loaders to be passed to fetcher.load()", async () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component() { + let fetcher = useFetcher(); + return ( + <> + +

{fetcher.data || "empty"}

+ + ); + }, + }, + ], + {} ); - } + let { container } = render(); - function Child() { - return ( - - } /> - - ); - } + expect(getHtml(container)).toMatch("empty"); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-

- 👋 Hello from the other side! -

-
" - `); - }); + fireEvent.click(screen.getByText("Load")); + await waitFor(() => screen.getByText("LOADER")); + expect(getHtml(container)).toMatch("LOADER"); + }); - it("renders alongside a data router ErrorBoundary", () => { - let router = createMemoryRouter( - [ + it("allows direct loaders to override the fetch route loader", async () => { + let router = createMemoryRouter( + [ + { + path: "/", + loader: () => "LOADER ROUTE", + Component() { + let fetcher = useFetcher(); + return ( + <> + + +

{fetcher.data || "empty"}

+ + ); + }, + }, + ], { - path: "*", - Component() { - return ( - <> - - - Descendant} /> - - - ); + hydrationData: { loaderData: { "0": null } }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("empty"); + fireEvent.click(screen.getByText("Load Route")); + await waitFor(() => screen.getByText("LOADER ROUTE")); + expect(getHtml(container)).toMatch("LOADER ROUTE"); + + fireEvent.click(screen.getByText("Load Override")); + await waitFor(() => screen.getByText("LOADER OVERRIDE")); + expect(getHtml(container)).toMatch("LOADER OVERRIDE"); + }); + + it("allows direct actions to be passed to fetcher.submit()", async () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component() { + let fetcher = useFetcher(); + return ( + <> + +

{fetcher.data || "empty"}

+ + ); + }, }, - children: [ - { - id: "index", - index: true, - Component: () =>

Child

, - ErrorBoundary() { - return

{(useRouteError() as Error).message}

; - }, + ], + {} + ); + let { container } = render(); + + expect(getHtml(container)).toMatch("empty"); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => screen.getByText("ACTION")); + expect(getHtml(container)).toMatch("ACTION"); + }); + + it("allows direct actions to override the fetch route action", async () => { + let router = createMemoryRouter( + [ + { + path: "/", + action: () => "ACTION ROUTE", + Component() { + let fetcher = useFetcher(); + return ( + <> + + +

{fetcher.data || "empty"}

+ + ); }, - ], - }, - ], - { - initialEntries: ["/"], - hydrationData: { - errors: { - index: new Error("Broken!"), }, - }, - } - ); - let { container } = render(); + ], + {} + ); + let { container } = render(); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-

- Broken! -

-

- Descendant -

-
" - `); + expect(getHtml(container)).toMatch("empty"); + fireEvent.click(screen.getByText("Submit Route")); + await waitFor(() => screen.getByText("ACTION ROUTE")); + expect(getHtml(container)).toMatch("ACTION ROUTE"); + + fireEvent.click(screen.getByText("Submit Override")); + await waitFor(() => screen.getByText("ACTION OVERRIDE")); + expect(getHtml(container)).toMatch("ACTION OVERRIDE"); + }); + + describe("with a basename", () => { + it("prepends the basename to fetcher.load paths", async () => { + let router = createMemoryRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + initialEntries: ["/base"], + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + + it('prepends the basename to fetcher.submit({ method: "get" }) paths', async () => { + let router = createMemoryRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + initialEntries: ["/base"], + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + + it('prepends the basename to fetcher.submit({ method: "post" }) paths', async () => { + let router = createMemoryRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + initialEntries: ["/base"], + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("submit")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + }); }); describe("errors", () => { @@ -3375,11 +5366,13 @@ function MemoryNavigate({ to, formMethod, formData, + payload, children, }: { to: string; formMethod?: FormMethod; formData?: FormData; + payload?: NonNullable; children: React.ReactNode; }) { let dataRouterContext = React.useContext(DataRouterContext); @@ -3387,13 +5380,23 @@ function MemoryNavigate({ let onClickHandler = React.useCallback( async (event: React.MouseEvent) => { event.preventDefault(); - if (formMethod && formData) { - dataRouterContext?.router.navigate(to, { formMethod, formData }); + if (formMethod) { + if (formData) { + dataRouterContext?.router.navigate(to, { + formMethod, + formData, + }); + } else { + dataRouterContext?.router.navigate(to, { + formMethod, + payload, + }); + } } else { dataRouterContext?.router.navigate(to); } }, - [dataRouterContext, to, formMethod, formData] + [dataRouterContext, to, formMethod, formData, payload] ); // Only prepend the basename to the rendered href, send the non-prefixed `to` @@ -3404,7 +5407,7 @@ function MemoryNavigate({ href = to === "/" ? basename : joinPaths([basename, to]); } - return formData ? ( + return formData || payload ? ( ) : ( diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index cf1705bda2..b102d78a3c 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -85,7 +85,12 @@ import { NavigationContext, RouteContext, } from "./lib/context"; -import type { NavigateFunction } from "./lib/hooks"; +import type { + FetcherWithMethods, + NavigateFunction, + SubmitFunction, + SubmitOptions, +} from "./lib/hooks"; import { useBlocker, useHref, @@ -102,6 +107,8 @@ import { useActionData, useAsyncError, useAsyncValue, + useFetcher, + useFetchers, useRouteId, useLoaderData, useMatches, @@ -109,6 +116,7 @@ import { useRevalidator, useRouteError, useRouteLoaderData, + useSubmit, } from "./lib/hooks"; // Exported for backwards compatibility, but not being used internally anymore @@ -126,6 +134,7 @@ export type { DataRouteMatch, DataRouteObject, Fetcher, + FetcherWithMethods, Hash, IndexRouteObject, IndexRouteProps, @@ -160,6 +169,8 @@ export type { RoutesProps, Search, ShouldRevalidateFunction, + SubmitFunction, + SubmitOptions, To, }; export { @@ -190,6 +201,8 @@ export { useAsyncError, useAsyncValue, useBlocker as unstable_useBlocker, + useFetcher, + useFetchers, useHref, useInRouterContext, useLoaderData, @@ -207,6 +220,7 @@ export { useRouteError, useRouteLoaderData, useRoutes, + useSubmit, }; function mapRouteProperties(route: RouteObject) { diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index ce5353b68b..140de8d6ae 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1,7 +1,12 @@ import * as React from "react"; import type { + ActionFunction, Blocker, BlockerFunction, + Fetcher, + FormEncType, + HTMLFormMethod, + LoaderFunction, Location, ParamParseKey, Params, @@ -706,6 +711,8 @@ enum DataRouterHook { UseBlocker = "useBlocker", UseRevalidator = "useRevalidator", UseNavigateStable = "useNavigate", + UseFetcher = "useFetcher", + UseSubmitImpl = "useSubmit", } enum DataRouterStateHook { @@ -719,6 +726,7 @@ enum DataRouterStateHook { UseRevalidator = "useRevalidator", UseNavigateStable = "useNavigate", UseRouteId = "useRouteId", + UseFetchers = "useFetchers", } function getDataRouterConsoleError( @@ -941,6 +949,180 @@ function useNavigateStable(): NavigateFunction { return navigate; } +type SubmitPayload = NonNullable | null; + +/** + * Submits a payload to an action without reloading the page. + */ +export interface SubmitFunction { + ( + /** + * Data to be submitted to the action + */ + payload: SubmitPayload, + + /** + * Options that override the default submission behavior + */ + options?: SubmitOptions + ): void; +} + +export interface SubmitOptions { + /** + * The HTTP method used to submit. Defaults to "GET". + */ + method?: HTMLFormMethod; + + /** + * The action URL path used to submit the form, or the action function to + * execute. Defaults to the path of the current route. + */ + action?: string | ActionFunction; + + /** + * Optional encType to be used to encode the submission into the action `request` + */ + encType?: FormEncType | null; + + /** + * Set `true` to replace the current entry in the history stack instead of + * creating a new one (i.e. stay on "the same page"). Defaults to `false`. + */ + replace?: boolean; + + /** + * Determines whether the action is relative to the route hierarchy or + * the pathname. Use this if you want to opt out of navigating the route + * hierarchy and want to instead route based on /-delimited URL segments + */ + relative?: RelativeRoutingType; +} + +/** + * Returns a function that may be used to programmatically submit some + * arbitrary data to the server. + */ +export function useSubmit(): SubmitFunction { + return useSubmitImpl(); +} + +function useSubmitImpl( + fetcherKey?: string, + fetcherRouteId?: string +): SubmitFunction { + let { router } = useDataRouterContext(DataRouterHook.UseSubmitImpl); + let currentRouteId = useRouteId(); + + return React.useCallback( + (payload, options = {}) => { + let path = + typeof options.action === "function" ? null : options.action || null; + let routerAction = + typeof options.action === "function" ? options.action : undefined; + + // Base options shared between fetch() and navigate() + let opts = { + payload, + formMethod: options.method || "get", + formEncType: options.encType, + action: routerAction, + }; + + if (fetcherKey) { + invariant( + fetcherRouteId != null, + "No routeId available for useFetcher()" + ); + router.fetch(fetcherKey, fetcherRouteId, path, opts); + } else { + router.navigate(path, { + ...opts, + replace: options.replace, + fromRouteId: currentRouteId, + }); + } + }, + [router, fetcherKey, fetcherRouteId, currentRouteId] + ); +} + +let fetcherId = 0; + +export type FetcherWithMethods = Fetcher & { + submit: ( + target: SubmitPayload, + // Fetchers cannot replace/preventScrollReset because they are not + // navigation events + options?: Omit + ) => void; + load: (href: string | LoaderFunction) => void; +}; + +/** + * Interacts with route loaders and actions without causing a navigation. Great + * for any interaction that stays on the same page. + */ +export function useFetcher(): FetcherWithMethods { + let { router } = useDataRouterContext(DataRouterHook.UseFetcher); + + let route = React.useContext(RouteContext); + invariant(route, `useFetcher must be used inside a RouteContext`); + + let routeId = route.matches[route.matches.length - 1]?.route.id; + invariant( + routeId != null, + `useFetcher can only be used on routes that contain a unique "id"` + ); + + let [fetcherKey] = React.useState(() => String(++fetcherId)); + let [load] = React.useState(() => (href: string | LoaderFunction) => { + invariant(router, "No router available for fetcher.load()"); + invariant(routeId, "No routeId available for fetcher.load()"); + if (typeof href === "function") { + router.fetch(fetcherKey, routeId, null, { loader: href }); + } else { + router.fetch(fetcherKey, routeId, href); + } + }); + let submit = useSubmitImpl(fetcherKey, routeId); + + let fetcher = router.getFetcher(fetcherKey); + + let fetcherWithMethods = React.useMemo( + () => ({ + submit, + load, + ...fetcher, + }), + [fetcher, 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); + }; + }, [router, fetcherKey]); + + return fetcherWithMethods; +} + +/** + * 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[] { + let state = useDataRouterState(DataRouterStateHook.UseFetchers); + return [...state.fetchers.values()]; +} + const alreadyWarned: Record = {}; function warningOnce(key: string, cond: boolean, message: string) { diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 19f53849ef..ba90a230e0 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -9838,6 +9838,53 @@ describe("a router", () => { }); }); + it("updates router state with fetchers after action-driven revalidations", async () => { + let t = setup({ + routes: TASK_ROUTES, + initialEntries: ["/"], + hydrationData: { loaderData: { root: "ROOT", index: "INDEX" } }, + }); + + let key = "key"; + let A = await t.fetch("/tasks/1", key); + await A.loaders.tasksId.resolve("TASKS 1"); + expect(A.fetcher.state).toBe("idle"); + expect(A.fetcher.data).toBe("TASKS 1"); + + let C = await t.navigate("/tasks", { + formMethod: "post", + formData: createFormData({}), + }); + // Add a helper for the fetcher that will be revalidating + t.shimHelper(C.loaders, "navigation", "loader", "tasksId"); + + // Resolve the action + await C.actions.tasks.resolve("TASKS ACTION"); + + // Fetcher should go back into a loading state + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + let currentFetchers = t.router.state.fetchers; + let newFetchersInstance = false; + let unsub = t.router.subscribe((s) => { + if (currentFetchers !== s.fetchers) { + newFetchersInstance = true; + } + }); + + // Resolve navigation loaders + fetcher loader + 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(key)).toMatchObject({ + state: "idle", + data: "TASKS ID*", + }); + + expect(newFetchersInstance).toBe(true); + unsub(); + }); + it("revalidates fetchers on action redirects", async () => { let t = setup({ routes: TASK_ROUTES, diff --git a/packages/router/router.ts b/packages/router/router.ts index 8a124b7c1e..a63866d16c 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -610,6 +610,10 @@ interface HandleLoadersResult extends ShortCircuitable { * errors thrown from the current set of loaders */ errors?: RouterState["errors"]; + /** + * fetchers updates via revalidation + */ + fetchers?: RouterState["fetchers"]; } /** @@ -1335,7 +1339,7 @@ export function createRouter(init: RouterInit): Router { } // Call loaders - let { shortCircuited, loaderData, errors } = await handleLoaders( + let { shortCircuited, loaderData, errors, fetchers } = await handleLoaders( request, location, matches, @@ -1361,6 +1365,7 @@ export function createRouter(init: RouterInit): Router { ...(pendingActionData ? { actionData: pendingActionData } : {}), loaderData, errors, + ...(fetchers ? { fetchers } : {}), }); }