Skip to content

Commit f99fec6

Browse files
Add fetcher.unstable_reset() API (#14206)
* Add fetcher.unstable_reset API * Alphebetize FetcherWithComponents * Update .changeset/fetcher-reset.md Co-authored-by: Mark Dalgleish <[email protected]> * Update to options API --------- Co-authored-by: Mark Dalgleish <[email protected]>
1 parent 943736f commit f99fec6

File tree

6 files changed

+254
-29
lines changed

6 files changed

+254
-29
lines changed

.changeset/fetcher-reset.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
[UNSTABLE] Add `fetcher.unstable_reset()` API

packages/react-router/__tests__/dom/data-browser-router-test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5487,6 +5487,76 @@ function testDomRouter(
54875487
expect(html).toContain("fetcher count:1");
54885488
});
54895489

5490+
it("resets a fetcher", async () => {
5491+
let router = createTestRouter(
5492+
[
5493+
{
5494+
path: "/",
5495+
Component() {
5496+
let fetcher = useFetcher();
5497+
return (
5498+
<>
5499+
<p id="output">{`${fetcher.state}-${fetcher.data}`}</p>
5500+
<button onClick={() => fetcher.load("/")}>load</button>
5501+
<button onClick={() => fetcher.unstable_reset()}>
5502+
reset
5503+
</button>
5504+
</>
5505+
);
5506+
},
5507+
async loader() {
5508+
return "FETCH";
5509+
},
5510+
},
5511+
],
5512+
{
5513+
window: getWindow("/"),
5514+
hydrationData: { loaderData: { "0": null } },
5515+
},
5516+
);
5517+
let { container } = render(<RouterProvider router={router} />);
5518+
5519+
expect(getHtml(container.querySelector("#output")!))
5520+
.toMatchInlineSnapshot(`
5521+
"<p
5522+
id="output"
5523+
>
5524+
idle-undefined
5525+
</p>"
5526+
`);
5527+
5528+
fireEvent.click(screen.getByText("load"));
5529+
expect(getHtml(container.querySelector("#output")!))
5530+
.toMatchInlineSnapshot(`
5531+
"<p
5532+
id="output"
5533+
>
5534+
loading-undefined
5535+
</p>"
5536+
`);
5537+
5538+
await waitFor(() => screen.getByText(/idle/));
5539+
expect(getHtml(container.querySelector("#output")!))
5540+
.toMatchInlineSnapshot(`
5541+
"<p
5542+
id="output"
5543+
>
5544+
idle-FETCH
5545+
</p>"
5546+
`);
5547+
5548+
fireEvent.click(screen.getByText("reset"));
5549+
await waitFor(() => screen.getByText(/idle/));
5550+
expect(getHtml(container.querySelector("#output")!))
5551+
.toMatchInlineSnapshot(`
5552+
"<p
5553+
id="output"
5554+
>
5555+
idle-null
5556+
</p>"
5557+
`);
5558+
});
5559+
54905560
describe("useFetcher({ key })", () => {
54915561
it("generates unique keys for fetchers by default", async () => {
54925562
let dfd1 = createDeferred();

packages/react-router/__tests__/router/fetchers-test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3555,4 +3555,116 @@ describe("fetchers", () => {
35553555
expect((await request.formData()).get("a")).toBe("1");
35563556
});
35573557
});
3558+
3559+
describe("resetFetcher", () => {
3560+
it("resets fetcher data", async () => {
3561+
let t = setup({
3562+
routes: [
3563+
{ id: "root", path: "/" },
3564+
{ id: "fetch", path: "/fetch", loader: true },
3565+
],
3566+
});
3567+
3568+
let A = await t.fetch("/fetch", "a", "root");
3569+
expect(t.fetchers["a"]).toMatchObject({
3570+
state: "loading",
3571+
data: undefined,
3572+
});
3573+
3574+
await A.loaders.fetch.resolve("FETCH");
3575+
expect(t.fetchers["a"]).toMatchObject({
3576+
state: "idle",
3577+
data: "FETCH",
3578+
});
3579+
3580+
t.router.resetFetcher("a");
3581+
expect(t.fetchers["a"]).toMatchObject({
3582+
state: "idle",
3583+
data: null,
3584+
});
3585+
});
3586+
3587+
it("aborts in-flight fetchers (first call)", async () => {
3588+
let t = setup({
3589+
routes: [
3590+
{ id: "root", path: "/" },
3591+
{ id: "fetch", path: "/fetch", loader: true },
3592+
],
3593+
});
3594+
3595+
let A = await t.fetch("/fetch", "a", "root");
3596+
expect(t.fetchers["a"]).toMatchObject({
3597+
state: "loading",
3598+
data: undefined,
3599+
});
3600+
3601+
t.router.resetFetcher("a");
3602+
expect(t.fetchers["a"]).toMatchObject({
3603+
state: "idle",
3604+
data: null,
3605+
});
3606+
3607+
// no-op
3608+
await A.loaders.fetch.resolve("FETCH");
3609+
expect(t.fetchers["a"]).toMatchObject({
3610+
state: "idle",
3611+
data: null,
3612+
});
3613+
expect(A.loaders.fetch.signal.aborted).toBe(true);
3614+
});
3615+
3616+
it("aborts in-flight fetchers (subsequent call)", async () => {
3617+
let t = setup({
3618+
routes: [
3619+
{ id: "root", path: "/" },
3620+
{ id: "fetch", path: "/fetch", loader: true },
3621+
],
3622+
});
3623+
3624+
let A = await t.fetch("/fetch", "a", "root");
3625+
expect(t.fetchers["a"]).toMatchObject({
3626+
state: "loading",
3627+
data: undefined,
3628+
});
3629+
3630+
await A.loaders.fetch.resolve("FETCH");
3631+
expect(t.fetchers["a"]).toMatchObject({
3632+
state: "idle",
3633+
data: "FETCH",
3634+
});
3635+
3636+
let B = await t.fetch("/fetch", "a", "root");
3637+
expect(t.fetchers["a"]).toMatchObject({
3638+
state: "loading",
3639+
data: "FETCH",
3640+
});
3641+
3642+
t.router.resetFetcher("a");
3643+
expect(t.fetchers["a"]).toMatchObject({
3644+
state: "idle",
3645+
data: null,
3646+
});
3647+
3648+
// no-op
3649+
await B.loaders.fetch.resolve("FETCH*");
3650+
expect(t.fetchers["a"]).toMatchObject({
3651+
state: "idle",
3652+
data: null,
3653+
});
3654+
expect(B.loaders.fetch.signal.aborted).toBe(true);
3655+
});
3656+
3657+
it("passes along the `reason` to the abort controller", async () => {
3658+
let t = setup({
3659+
routes: [
3660+
{ id: "root", path: "/" },
3661+
{ id: "fetch", path: "/fetch", loader: true },
3662+
],
3663+
});
3664+
3665+
let A = await t.fetch("/fetch", "a", "root");
3666+
t.router.resetFetcher("a", { reason: "BECAUSE I SAID SO" });
3667+
expect(A.loaders.fetch.signal.reason).toBe("BECAUSE I SAID SO");
3668+
});
3669+
});
35583670
});

packages/react-router/lib/dom/lib.tsx

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2632,6 +2632,44 @@ export type FetcherWithComponents<TData> = Fetcher<TData> & {
26322632
FetcherFormProps & React.RefAttributes<HTMLFormElement>
26332633
>;
26342634

2635+
/**
2636+
* Loads data from a route. Useful for loading data imperatively inside user
2637+
* events outside a normal button or form, like a combobox or search input.
2638+
*
2639+
* ```tsx
2640+
* let fetcher = useFetcher()
2641+
*
2642+
* <input onChange={e => {
2643+
* fetcher.load(`/search?q=${e.target.value}`)
2644+
* }} />
2645+
* ```
2646+
*/
2647+
load: (
2648+
href: string,
2649+
opts?: {
2650+
/**
2651+
* Wraps the initial state update for this `fetcher.load` in a
2652+
* [`ReactDOM.flushSync`](https://react.dev/reference/react-dom/flushSync)
2653+
* call instead of the default [`React.startTransition`](https://react.dev/reference/react/startTransition).
2654+
* This allows you to perform synchronous DOM actions immediately after the
2655+
* update is flushed to the DOM.
2656+
*/
2657+
flushSync?: boolean;
2658+
},
2659+
) => Promise<void>;
2660+
2661+
/**
2662+
* Reset a fetcher back to an empty/idle state.
2663+
*
2664+
* If the fetcher is currently in-flight, the
2665+
* [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
2666+
* will be aborted with the `reason`, if provided.
2667+
*
2668+
* @param reason Optional `reason` to provide to [`AbortController.abort()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort)
2669+
* @returns void
2670+
*/
2671+
unstable_reset: (opts?: { reason?: unknown }) => void;
2672+
26352673
/**
26362674
* Submits form data to a route. While multiple nested routes can match a URL, only the leaf route will be called.
26372675
*
@@ -2685,32 +2723,6 @@ export type FetcherWithComponents<TData> = Fetcher<TData> & {
26852723
* ```
26862724
*/
26872725
submit: FetcherSubmitFunction;
2688-
2689-
/**
2690-
* Loads data from a route. Useful for loading data imperatively inside user
2691-
* events outside a normal button or form, like a combobox or search input.
2692-
*
2693-
* ```tsx
2694-
* let fetcher = useFetcher()
2695-
*
2696-
* <input onChange={e => {
2697-
* fetcher.load(`/search?q=${e.target.value}`)
2698-
* }} />
2699-
* ```
2700-
*/
2701-
load: (
2702-
href: string,
2703-
opts?: {
2704-
/**
2705-
* Wraps the initial state update for this `fetcher.load` in a
2706-
* [`ReactDOM.flushSync`](https://react.dev/reference/react-dom/flushSync)
2707-
* call instead of the default [`React.startTransition`](https://react.dev/reference/react/startTransition).
2708-
* This allows you to perform synchronous DOM actions immediately after the
2709-
* update is flushed to the DOM.
2710-
*/
2711-
flushSync?: boolean;
2712-
},
2713-
) => Promise<void>;
27142726
};
27152727

27162728
// TODO: (v7) Change the useFetcher generic default from `any` to `unknown`
@@ -2745,6 +2757,9 @@ export type FetcherWithComponents<TData> = Fetcher<TData> & {
27452757
* method: "post",
27462758
* encType: "application/json"
27472759
* })
2760+
*
2761+
* // reset fetcher
2762+
* fetcher.unstable_reset()
27482763
* }
27492764
*
27502765
* @public
@@ -2826,6 +2841,10 @@ export function useFetcher<T = any>({
28262841
[fetcherKey, submitImpl],
28272842
);
28282843

2844+
let unstable_reset = React.useCallback<
2845+
FetcherWithComponents<T>["unstable_reset"]
2846+
>((opts) => router.resetFetcher(fetcherKey, opts), [router, fetcherKey]);
2847+
28292848
let FetcherForm = React.useMemo(() => {
28302849
let FetcherForm = React.forwardRef<HTMLFormElement, FetcherFormProps>(
28312850
(props, ref) => {
@@ -2846,10 +2865,11 @@ export function useFetcher<T = any>({
28462865
Form: FetcherForm,
28472866
submit,
28482867
load,
2868+
unstable_reset,
28492869
...fetcher,
28502870
data,
28512871
}),
2852-
[FetcherForm, submit, load, fetcher, data],
2872+
[FetcherForm, submit, load, unstable_reset, fetcher, data],
28532873
);
28542874

28552875
return fetcherWithComponents;

packages/react-router/lib/dom/server.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,9 @@ export function createStaticRouter(
472472
deleteFetcher() {
473473
throw msg("deleteFetcher");
474474
},
475+
resetFetcher() {
476+
throw msg("resetFetcher");
477+
},
475478
dispose() {
476479
throw msg("dispose");
477480
},

packages/react-router/lib/router/router.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ export interface Router {
215215
*/
216216
getFetcher<TData = any>(key: string): Fetcher<TData>;
217217

218+
/**
219+
* @internal
220+
* PRIVATE - DO NOT USE
221+
*
222+
* Reset the fetcher for a given key
223+
* @param key
224+
*/
225+
resetFetcher(key: string, opts?: { reason?: unknown }): void;
226+
218227
/**
219228
* @private
220229
* PRIVATE - DO NOT USE
@@ -3061,6 +3070,11 @@ export function createRouter(init: RouterInit): Router {
30613070
return state.fetchers.get(key) || IDLE_FETCHER;
30623071
}
30633072

3073+
function resetFetcher(key: string, opts?: { reason?: unknown }) {
3074+
abortFetcher(key, opts?.reason);
3075+
updateFetcherState(key, getDoneFetcher(null));
3076+
}
3077+
30643078
function deleteFetcher(key: string): void {
30653079
let fetcher = state.fetchers.get(key);
30663080
// Don't abort the controller if this is a deletion of a fetcher.submit()
@@ -3091,10 +3105,10 @@ export function createRouter(init: RouterInit): Router {
30913105
updateState({ fetchers: new Map(state.fetchers) });
30923106
}
30933107

3094-
function abortFetcher(key: string) {
3108+
function abortFetcher(key: string, reason?: unknown) {
30953109
let controller = fetchControllers.get(key);
30963110
if (controller) {
3097-
controller.abort();
3111+
controller.abort(reason);
30983112
fetchControllers.delete(key);
30993113
}
31003114
}
@@ -3474,6 +3488,7 @@ export function createRouter(init: RouterInit): Router {
34743488
createHref: (to: To) => init.history.createHref(to),
34753489
encodeLocation: (to: To) => init.history.encodeLocation(to),
34763490
getFetcher,
3491+
resetFetcher,
34773492
deleteFetcher: queueFetcherForDeletion,
34783493
dispose,
34793494
getBlocker,

0 commit comments

Comments
 (0)