From d63bb5826596c09c16d9cbd653de98b3682bb20c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 25 Oct 2023 14:56:31 -0400 Subject: [PATCH 1/4] Fix router getFetcher/deleteFetcher types --- .changeset/fix-get-delete-fetcher-types.md | 5 +++++ packages/router/router.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-get-delete-fetcher-types.md diff --git a/.changeset/fix-get-delete-fetcher-types.md b/.changeset/fix-get-delete-fetcher-types.md new file mode 100644 index 0000000000..da8738e8ef --- /dev/null +++ b/.changeset/fix-get-delete-fetcher-types.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": patch +--- + +Fix `router.getFetcher`/`router.deleteFetcher` type definitions which incorrectly specified `key` as an optional parameter diff --git a/packages/router/router.ts b/packages/router/router.ts index 2eb7bc8327..0f8a0f2b62 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -193,7 +193,7 @@ export interface Router { * Get/create a fetcher for the given key * @param key */ - getFetcher(key?: string): Fetcher; + getFetcher(key: string): Fetcher; /** * @internal @@ -202,7 +202,7 @@ export interface Router { * Delete the fetcher for a given key * @param key */ - deleteFetcher(key?: string): void; + deleteFetcher(key: string): void; /** * @internal From e0b98713e618a1de54626e2af1587800918faf65 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 25 Oct 2023 15:00:34 -0400 Subject: [PATCH 2/4] Add useFetcher(key) and
--- .changeset/fetcher-key.md | 5 + .changeset/form-navigate-false.md | 8 + docs/hooks/use-fetcher.md | 58 ++- .../__tests__/data-browser-router-test.tsx | 335 +++++++++++++++++- packages/react-router-dom/dom.ts | 10 + packages/react-router-dom/index.tsx | 199 +++++------ packages/router/__tests__/utils/utils.ts | 6 +- 7 files changed, 497 insertions(+), 124 deletions(-) create mode 100644 .changeset/fetcher-key.md create mode 100644 .changeset/form-navigate-false.md 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/form-navigate-false.md b/.changeset/form-navigate-false.md new file mode 100644 index 0000000000..8a188bbd32 --- /dev/null +++ b/.changeset/form-navigate-false.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/docs/hooks/use-fetcher.md b/docs/hooks/use-fetcher.md index 5408c5ed19..ce28c5dcbf 100644 --- a/docs/hooks/use-fetcher.md +++ b/docs/hooks/use-fetcher.md @@ -60,15 +60,37 @@ Fetchers have a lot of built-in behavior: - Handles uncaught errors by rendering the nearest `errorElement` (just like a normal navigation from `` or ``) - Will redirect the app if your action/loader being called returns a redirect (just like a normal navigation from `` or ``) -## `fetcher.state` +## Options -You can know the state of the fetcher with `fetcher.state`. It will be one of: +### `key` -- **idle** - nothing is being fetched. -- **submitting** - A route action is being called due to a fetcher submission using POST, PUT, PATCH, or DELETE -- **loading** - The fetcher is calling a loader (from a `fetcher.load`) or is being revalidated after a separate submission or `useRevalidator` call +By default, `useFetcher` generate a unique fetcher scoped to that component (however, it may be looked up in [`useFetchers()`][use_fetchers] while in-flight). If you want to identify a fetcher with your own key such that you can access it from elsewhere in your app, you can do that with the `key` option: + +```tsx +function AddToBagButton() { + const fetcher = useFetcher({ key: "add-to-bag" }); + return ...; +} -## `fetcher.Form` +// Then, up in the header... +function CartCount({ count }) { + const fetcher = useFetcher({ key: "add-to-bag" }); + const inFlightCount = Number( + fetcher.formData?.get("quantity") || 0 + ); + const optimisticCount = count + inFlightCount; + return ( + <> + + {optimisticCount} + + ); +} +``` + +## Components + +### `fetcher.Form` Just like `` except it doesn't cause a navigation. (You'll get over the dot in JSX ... we hope!) @@ -83,6 +105,8 @@ function SomeComponent() { } ``` +## Methods + ## `fetcher.load()` Loads data from a route loader. @@ -140,7 +164,17 @@ If you want to submit to an index route, use the [`?index` param][indexsearchpar If you find yourself calling this function inside of click handlers, you can probably simplify your code by using `` instead. -## `fetcher.data` +## Properties + +### `fetcher.state` + +You can know the state of the fetcher with `fetcher.state`. It will be one of: + +- **idle** - nothing is being fetched. +- **submitting** - A route action is being called due to a fetcher submission using POST, PUT, PATCH, or DELETE +- **loading** - The fetcher is calling a loader (from a `fetcher.load`) or is being revalidated after a separate submission or `useRevalidator` call + +### `fetcher.data` The returned data from the loader or action is stored here. Once the data is set, it persists on the fetcher even through reloads and resubmissions. @@ -171,7 +205,7 @@ function ProductDetails({ product }) { } ``` -## `fetcher.formData` +### `fetcher.formData` When using `` or `fetcher.submit()`, the form data is available to build optimistic UI. @@ -204,15 +238,15 @@ function TaskCheckbox({ task }) { } ``` -## `fetcher.json` +### `fetcher.json` When using `fetcher.submit(data, { formEncType: "application/json" })`, the submitted JSON is available via `fetcher.json`. -## `fetcher.text` +### `fetcher.text` When using `fetcher.submit(data, { formEncType: "text/plain" })`, the submitted text is available via `fetcher.text`. -## `fetcher.formAction` +### `fetcher.formAction` Tells you the action url the form is being submitted to. @@ -223,7 +257,7 @@ Tells you the action url the form is being submitted to. fetcher.formAction; // "mark-as-read" ``` -## `fetcher.formMethod` +### `fetcher.formMethod` Tells you the method of the form being submitted: get, post, put, patch, or delete. 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 fdc3b09c1f..06255a674f 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -5089,6 +5089,326 @@ function testDomRouter( expect(html).toContain("fetcher count:1"); }); + describe("useFetcher({ key })", () => { + it("generates unique keys for fetchers by default", async () => { + let dfd1 = createDeferred(); + let dfd2 = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetcher1 = useFetcher(); + let fetcher2 = useFetcher(); + let fetchers = useFetchers(); + return ( + <> + + +
{`${fetchers.length}, ${fetcher1.state}/${fetcher1.data}, ${fetcher2.state}/${fetcher2.data}`}
+ + ); + }, + }, + { + path: "/fetch1", + loader: () => dfd1.promise, + }, + { + path: "/fetch2", + loader: () => dfd2.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(container.querySelector("pre")!.innerHTML).toBe( + "0, idle/undefined, idle/undefined" + ); + + fireEvent.click(screen.getByText("Load 1")); + await waitFor(() => + screen.getByText("1, loading/undefined, idle/undefined") + ); + + dfd1.resolve("FETCH 1"); + await waitFor(() => + screen.getByText("1, idle/FETCH 1, idle/undefined") + ); + + fireEvent.click(screen.getByText("Load 2")); + await waitFor(() => + screen.getByText("2, idle/FETCH 1, loading/undefined") + ); + + dfd2.resolve("FETCH 2"); + await waitFor(() => + screen.getByText("2, idle/FETCH 1, idle/FETCH 2") + ); + }); + + it("allows users to specify their own key to share fetchers", async () => { + let dfd1 = createDeferred(); + let dfd2 = createDeferred(); + let router = createTestRouter( + [ + { + path: "/", + Component() { + let fetcher1 = useFetcher({ key: "shared" }); + let fetcher2 = useFetcher({ key: "shared" }); + let fetchers = useFetchers(); + return ( + <> + + +
{`${fetchers.length}, ${fetcher1.state}/${fetcher1.data}, ${fetcher2.state}/${fetcher2.data}`}
+ + ); + }, + }, + { + path: "/fetch1", + loader: () => dfd1.promise, + }, + { + path: "/fetch2", + loader: () => dfd2.promise, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + + expect(container.querySelector("pre")!.innerHTML).toBe( + "0, idle/undefined, idle/undefined" + ); + + fireEvent.click(screen.getByText("Load 1")); + await waitFor(() => + screen.getByText("1, loading/undefined, loading/undefined") + ); + + dfd1.resolve("FETCH 1"); + await waitFor(() => + screen.getByText("1, 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("1, idle/FETCH 2, idle/FETCH 2") + ); + }); + }); + + describe("", () => { + function setupTest( + method: "get" | "post", + navigate: boolean, + renderFetcher = false + ) { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createTestRouter( + [ + { + path: "/", + async action({ request }) { + let resolvedValue = await actionDefer.promise; + let formData = await request.formData(); + return `${resolvedValue}:${formData.get("test")}`; + }, + loader: () => loaderDefer.promise, + Component() { + let data = useLoaderData() as string; + let actionData = useActionData() as string | undefined; + let location = useLocation(); + let navigation = useNavigation(); + let fetchers = useFetchers(); + return ( +
+ + + + +
+                        {[
+                          location.key,
+                          navigation.state,
+                          data,
+                          actionData,
+                          fetchers.map((f) => f.state),
+                        ].join(",")}
+                      
+ +
+ ); + }, + ...(renderFetcher + ? { + children: [ + { + index: true, + Component() { + let fetcher = useFetcher({ key: "my-key" }); + return ( +
{`fetcher:${fetcher.state}:${fetcher.data}`}
+ ); + }, + }, + ], + } + : {}), + }, + ], + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": "INIT" } }, + } + ); + + let { container } = render(); + + return { container, loaderDefer, actionDefer }; + } + + it('defaults to a navigation on
', async () => { + let { container, loaderDefer } = setupTest("get", true); + + // location key, nav state, loader data, action data, fetcher states + 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); + + // location key, nav state, loader data, action data, fetcher states + 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); + + // location.key,navigation.state + 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,,idle")); + }); + + 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,,idle")); + }); + + 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,,idle")); + 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,,idle")); + expect(getHtml(container)).toMatch("fetcher:idle:ACTION:value"); + }); + }); + describe("with a basename", () => { it("prepends the basename to fetcher.load paths", async () => { let router = createTestRouter( @@ -5859,7 +6179,12 @@ 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, + Component: Bar, + ErrorBoundary: BarError, + }; + let lazyDefer = createDeferred(); let barDefer = createDeferred(); let routes: RouteObject[] = [ @@ -5873,7 +6198,7 @@ function testDomRouter( }, { path: "bar", - lazy: async () => lazyDefer.promise as Promise, + lazy: () => lazyDefer.promise, }, ], }, @@ -5919,11 +6244,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 8c62c56893..4f83b88773 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1049,6 +1049,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. */ @@ -1072,23 +1082,6 @@ export interface FormProps extends FetcherFormProps { unstable_viewTransition?: boolean; } -/** - * A `@remix-run/router`-aware ``. It behaves like a normal form except - * that the interaction with the server is with `fetch` instead of new document - * requests, allowing components to add nicer UX to the page as the form is - * submitted and returns with data. - */ -export const Form = React.forwardRef( - (props, ref) => { - let submit = useSubmit(); - return ; - } -); - -if (__DEV__) { - Form.displayName = "Form"; -} - type HTMLSubmitEvent = React.BaseSyntheticEvent< SubmitEvent, Event, @@ -1097,20 +1090,23 @@ type HTMLSubmitEvent = React.BaseSyntheticEvent< type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement; -interface FormImplProps extends FormProps { - submit: SubmitFunction | FetcherSubmitFunction; -} - -const FormImpl = React.forwardRef( +/** + * A `@remix-run/router`-aware ``. It behaves like a normal form except + * that the interaction with the server is with `fetch` instead of new document + * requests, allowing components to add nicer UX to the page as the form is + * submitted and returns with data. + */ +export const Form = React.forwardRef( ( { + fetcherKey, + navigate, reloadDocument, replace, state, method = defaultMethod, action, onSubmit, - submit, relative, preventScrollReset, unstable_viewTransition, @@ -1118,9 +1114,11 @@ const FormImpl = React.forwardRef( }, forwardedRef ) => { + let submit = useSubmit(); + let formAction = useFormAction(action, { relative }); let formMethod: HTMLFormMethod = method.toLowerCase() === "get" ? "get" : "post"; - let formAction = useFormAction(action, { relative }); + let submitHandler: React.FormEventHandler = (event) => { onSubmit && onSubmit(event); if (event.defaultPrevented) return; @@ -1134,7 +1132,9 @@ const FormImpl = React.forwardRef( method; submit(submitter || event.currentTarget, { + fetcherKey, method: submitMethod, + navigate, replace, state, relative, @@ -1156,7 +1156,7 @@ const FormImpl = React.forwardRef( ); if (__DEV__) { - FormImpl.displayName = "FormImpl"; + Form.displayName = "Form"; } export interface ScrollRestorationProps { @@ -1379,6 +1379,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 +1400,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( @@ -1502,23 +1481,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; }; @@ -1529,9 +1495,10 @@ 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 route = React.useContext(RouteContext); invariant(route, `useFetcher must be used inside a RouteContext`); @@ -1541,28 +1508,56 @@ export function useFetcher(): FetcherWithComponents { `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 [fetcherKey, setFetcherKey] = React.useState(key || ""); + if (!fetcherKey) { + setFetcherKey(getUniqueFetcherId()); + } + + let load = React.useCallback( + (href: string) => { + invariant(router, "No router available for fetcher.load()"); + invariant(routeId, "No routeId available for fetcher.load()"); + router.fetch(fetcherKey, routeId, href); + }, + [fetcherKey, routeId, router] + ); + + // Fetcher additions (submit) + let submitImpl = useSubmit(); + let submit = React.useCallback( + (target, opts) => { + submitImpl(target, { + ...opts, + navigate: false, + fetcherKey, + }); + }, + [fetcherKey, submitImpl] + ); + let FetcherForm = React.useMemo(() => { + let FetcherForm = React.forwardRef( + (props, ref) => { + return ( + + ); + } + ); + if (__DEV__) { + FetcherForm.displayName = "fetcher.Form"; + } + return FetcherForm; + }, [fetcherKey]); let fetcher = router.getFetcher(fetcherKey); let fetcherWithComponents = React.useMemo( () => ({ - Form, + Form: FetcherForm, submit, load, ...fetcher, }), - [fetcher, Form, submit, load] + [fetcher, FetcherForm, submit, load] ); React.useEffect(() => { diff --git a/packages/router/__tests__/utils/utils.ts b/packages/router/__tests__/utils/utils.ts index 1b6a5511e1..bc123e9819 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; From f4462e52ca645a1a82da93a981d30e41b1b41262 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 25 Oct 2023 15:37:56 -0400 Subject: [PATCH 3/4] Expose keys on useFetchers --- .changeset/fetcher-key.md | 2 ++ .../__tests__/data-browser-router-test.tsx | 31 +++++++++++++++++++ packages/react-router-dom/index.tsx | 9 ++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.changeset/fetcher-key.md b/.changeset/fetcher-key.md index 3be4e924c8..b527111ac8 100644 --- a/.changeset/fetcher-key.md +++ b/.changeset/fetcher-key.md @@ -3,3 +3,5 @@ --- 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)) + +- Fetcher keys are now also exposed on the fetchers returned from `useFetchers` so that they can be looked up by `key` 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 06255a674f..ba06de48bc 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -5213,6 +5213,37 @@ function testDomRouter( screen.getByText("1, idle/FETCH 2, idle/FETCH 2") ); }); + + it("exposes fetcher keys via useFetchers", async () => { + let router = createTestRouter( + [ + { + path: "/", + loader: () => "FETCH", + Component() { + let fetcher1 = useFetcher(); + let fetcher2 = useFetcher({ key: "my-key" }); + let fetchers = useFetchers(); + React.useEffect(() => { + if (fetcher1.state === "idle" && !fetcher1.data) { + fetcher1.load("/"); + } + if (fetcher2.state === "idle" && !fetcher2.data) { + fetcher2.load("/"); + } + }, [fetcher1, fetcher2]); + return
{fetchers.map((f) => f.key).join(",")}
; + }, + }, + ], + { window: getWindow("/") } + ); + let { container } = render(); + expect(container.innerHTML).not.toMatch(/__\d+__,my-key/); + await waitFor(() => + expect(container.innerHTML).toMatch(/__\d+__,my-key/) + ); + }); }); describe("", () => { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 4f83b88773..f9aa56e0f4 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1380,7 +1380,7 @@ function validateClientSideSubmission() { } let fetcherId = 0; -let getUniqueFetcherId = () => String(++fetcherId); +let getUniqueFetcherId = () => `__${String(++fetcherId)}__`; /** * Returns a function that may be used to programmatically submit a form (or @@ -1580,9 +1580,12 @@ export function useFetcher({ * 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(): (Fetcher & { key: string })[] { let state = useDataRouterState(DataRouterStateHook.UseFetchers); - return [...state.fetchers.values()]; + return Array.from(state.fetchers.entries()).map(([key, fetcher]) => ({ + ...fetcher, + key, + })); } const SCROLL_RESTORATION_STORAGE_KEY = "react-router-scroll-positions"; From 2c6b70a8cd4cfc7cb284d93269df1816b2605511 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 25 Oct 2023 16:03:03 -0400 Subject: [PATCH 4/4] Add docs for form/useSubmit navigate/fetcherKey --- docs/components/form.md | 9 +++++++++ docs/hooks/use-submit.md | 10 +++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/components/form.md b/docs/components/form.md index 5d1c6bbc0b..5042c98700 100644 --- a/docs/components/form.md +++ b/docs/components/form.md @@ -197,6 +197,14 @@ function Project() { As you can see, both forms submit to the same route but you can use the `request.method` to branch on what you intend to do. After the actions completes, the `loader` will be revalidated and the UI will automatically synchronize with the new data. +## `navigate` + +You can tell the form to skip the navigation and use a [fetcher][usefetcher] internally by specifying ``. This is essentially a shorthand for `useFetcher()` + `` where you don't care about the resulting data and only want to kick off a submission and access the pending state via [`useFetchers()`][usefetchers]. + +## `fetcherKey` + +When using a non-navigating `Form`, you may also optionally specify your own fetcher key to use via ``. + ## `replace` Instructs the form to replace the current entry in the history stack, instead of pushing the new entry. @@ -367,6 +375,7 @@ You can access those values from the `request.url` [useactiondata]: ../hooks/use-action-data [formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData [usefetcher]: ../hooks/use-fetcher +[usefetchers]: ../hooks/use-fetchers [htmlform]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form [htmlformaction]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-action [htmlform-method]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-method diff --git a/docs/hooks/use-submit.md b/docs/hooks/use-submit.md index 7e0ba425c2..566745bf53 100644 --- a/docs/hooks/use-submit.md +++ b/docs/hooks/use-submit.md @@ -150,7 +150,15 @@ submit(null, { ; ``` -Because submissions are navigations, the options may also contain the other navigation related props from [``][form] such as `replace`, `state`, `preventScrollReset`, `relative`, `unstable_viewTransition` etc. +Because submissions are navigations, the options may also contain the other navigation related props from [``][form] such as: + +- `fetcherKey` +- `navigate` +- `preventScrollReset` +- `relative` +- `replace` +- `state` +- `unstable_viewTransition` [pickingarouter]: ../routers/picking-a-router [form]: ../components/form