Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fetcher-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

[UNSTABLE] Add `fetcher.unstable_reset()` API
70 changes: 70 additions & 0 deletions packages/react-router/__tests__/dom/data-browser-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5487,6 +5487,76 @@ function testDomRouter(
expect(html).toContain("fetcher count:1");
});

it("resets a fetcher", async () => {
let router = createTestRouter(
[
{
path: "/",
Component() {
let fetcher = useFetcher();
return (
<>
<p id="output">{`${fetcher.state}-${fetcher.data}`}</p>
<button onClick={() => fetcher.load("/")}>load</button>
<button onClick={() => fetcher.unstable_reset()}>
reset
</button>
</>
);
},
async loader() {
return "FETCH";
},
},
],
{
window: getWindow("/"),
hydrationData: { loaderData: { "0": null } },
},
);
let { container } = render(<RouterProvider router={router} />);

expect(getHtml(container.querySelector("#output")!))
.toMatchInlineSnapshot(`
"<p
id="output"
>
idle-undefined
</p>"
`);

fireEvent.click(screen.getByText("load"));
expect(getHtml(container.querySelector("#output")!))
.toMatchInlineSnapshot(`
"<p
id="output"
>
loading-undefined
</p>"
`);

await waitFor(() => screen.getByText(/idle/));
expect(getHtml(container.querySelector("#output")!))
.toMatchInlineSnapshot(`
"<p
id="output"
>
idle-FETCH
</p>"
`);

fireEvent.click(screen.getByText("reset"));
await waitFor(() => screen.getByText(/idle/));
expect(getHtml(container.querySelector("#output")!))
.toMatchInlineSnapshot(`
"<p
id="output"
>
idle-null
</p>"
`);
});

describe("useFetcher({ key })", () => {
it("generates unique keys for fetchers by default", async () => {
let dfd1 = createDeferred();
Expand Down
112 changes: 112 additions & 0 deletions packages/react-router/__tests__/router/fetchers-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3555,4 +3555,116 @@ describe("fetchers", () => {
expect((await request.formData()).get("a")).toBe("1");
});
});

describe("resetFetcher", () => {
it("resets fetcher data", async () => {
let t = setup({
routes: [
{ id: "root", path: "/" },
{ id: "fetch", path: "/fetch", loader: true },
],
});

let A = await t.fetch("/fetch", "a", "root");
expect(t.fetchers["a"]).toMatchObject({
state: "loading",
data: undefined,
});

await A.loaders.fetch.resolve("FETCH");
expect(t.fetchers["a"]).toMatchObject({
state: "idle",
data: "FETCH",
});

t.router.resetFetcher("a");
expect(t.fetchers["a"]).toMatchObject({
state: "idle",
data: null,
});
});

it("aborts in-flight fetchers (first call)", async () => {
let t = setup({
routes: [
{ id: "root", path: "/" },
{ id: "fetch", path: "/fetch", loader: true },
],
});

let A = await t.fetch("/fetch", "a", "root");
expect(t.fetchers["a"]).toMatchObject({
state: "loading",
data: undefined,
});

t.router.resetFetcher("a");
expect(t.fetchers["a"]).toMatchObject({
state: "idle",
data: null,
});

// no-op
await A.loaders.fetch.resolve("FETCH");
expect(t.fetchers["a"]).toMatchObject({
state: "idle",
data: null,
});
expect(A.loaders.fetch.signal.aborted).toBe(true);
});

it("aborts in-flight fetchers (subsequent call)", async () => {
let t = setup({
routes: [
{ id: "root", path: "/" },
{ id: "fetch", path: "/fetch", loader: true },
],
});

let A = await t.fetch("/fetch", "a", "root");
expect(t.fetchers["a"]).toMatchObject({
state: "loading",
data: undefined,
});

await A.loaders.fetch.resolve("FETCH");
expect(t.fetchers["a"]).toMatchObject({
state: "idle",
data: "FETCH",
});

let B = await t.fetch("/fetch", "a", "root");
expect(t.fetchers["a"]).toMatchObject({
state: "loading",
data: "FETCH",
});

t.router.resetFetcher("a");
expect(t.fetchers["a"]).toMatchObject({
state: "idle",
data: null,
});

// no-op
await B.loaders.fetch.resolve("FETCH*");
expect(t.fetchers["a"]).toMatchObject({
state: "idle",
data: null,
});
expect(B.loaders.fetch.signal.aborted).toBe(true);
});

it("passes along the `reason` to the abort controller", async () => {
let t = setup({
routes: [
{ id: "root", path: "/" },
{ id: "fetch", path: "/fetch", loader: true },
],
});

let A = await t.fetch("/fetch", "a", "root");
t.router.resetFetcher("a", { reason: "BECAUSE I SAID SO" });
expect(A.loaders.fetch.signal.reason).toBe("BECAUSE I SAID SO");
});
});
});
74 changes: 47 additions & 27 deletions packages/react-router/lib/dom/lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2632,6 +2632,44 @@ export type FetcherWithComponents<TData> = Fetcher<TData> & {
FetcherFormProps & React.RefAttributes<HTMLFormElement>
>;

/**
* Loads data from a route. Useful for loading data imperatively inside user
* events outside a normal button or form, like a combobox or search input.
*
* ```tsx
* let fetcher = useFetcher()
*
* <input onChange={e => {
* fetcher.load(`/search?q=${e.target.value}`)
* }} />
* ```
*/
load: (
href: string,
opts?: {
/**
* Wraps the initial state update for this `fetcher.load` in a
* [`ReactDOM.flushSync`](https://react.dev/reference/react-dom/flushSync)
* call instead of the default [`React.startTransition`](https://react.dev/reference/react/startTransition).
* This allows you to perform synchronous DOM actions immediately after the
* update is flushed to the DOM.
*/
flushSync?: boolean;
},
) => Promise<void>;

/**
* Reset a fetcher back to an empty/idle state.
*
* If the fetcher is currently in-flight, the
* [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
* will be aborted with the `reason`, if provided.
*
* @param reason Optional `reason` to provide to [`AbortController.abort()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort)
* @returns void
*/
unstable_reset: (opts?: { reason?: unknown }) => void;

/**
* Submits form data to a route. While multiple nested routes can match a URL, only the leaf route will be called.
*
Expand Down Expand Up @@ -2685,32 +2723,6 @@ export type FetcherWithComponents<TData> = Fetcher<TData> & {
* ```
*/
submit: FetcherSubmitFunction;

/**
* Loads data from a route. Useful for loading data imperatively inside user
* events outside a normal button or form, like a combobox or search input.
*
* ```tsx
* let fetcher = useFetcher()
*
* <input onChange={e => {
* fetcher.load(`/search?q=${e.target.value}`)
* }} />
* ```
*/
load: (
href: string,
opts?: {
/**
* Wraps the initial state update for this `fetcher.load` in a
* [`ReactDOM.flushSync`](https://react.dev/reference/react-dom/flushSync)
* call instead of the default [`React.startTransition`](https://react.dev/reference/react/startTransition).
* This allows you to perform synchronous DOM actions immediately after the
* update is flushed to the DOM.
*/
flushSync?: boolean;
},
) => Promise<void>;
};

// TODO: (v7) Change the useFetcher generic default from `any` to `unknown`
Expand Down Expand Up @@ -2745,6 +2757,9 @@ export type FetcherWithComponents<TData> = Fetcher<TData> & {
* method: "post",
* encType: "application/json"
* })
*
* // reset fetcher
* fetcher.unstable_reset()
* }
*
* @public
Expand Down Expand Up @@ -2826,6 +2841,10 @@ export function useFetcher<T = any>({
[fetcherKey, submitImpl],
);

let unstable_reset = React.useCallback<
FetcherWithComponents<T>["unstable_reset"]
>((opts) => router.resetFetcher(fetcherKey, opts), [router, fetcherKey]);

let FetcherForm = React.useMemo(() => {
let FetcherForm = React.forwardRef<HTMLFormElement, FetcherFormProps>(
(props, ref) => {
Expand All @@ -2846,10 +2865,11 @@ export function useFetcher<T = any>({
Form: FetcherForm,
submit,
load,
unstable_reset,
...fetcher,
data,
}),
[FetcherForm, submit, load, fetcher, data],
[FetcherForm, submit, load, unstable_reset, fetcher, data],
);

return fetcherWithComponents;
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/lib/dom/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,9 @@ export function createStaticRouter(
deleteFetcher() {
throw msg("deleteFetcher");
},
resetFetcher() {
throw msg("resetFetcher");
},
dispose() {
throw msg("dispose");
},
Expand Down
19 changes: 17 additions & 2 deletions packages/react-router/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ export interface Router {
*/
getFetcher<TData = any>(key: string): Fetcher<TData>;

/**
* @internal
* PRIVATE - DO NOT USE
*
* Reset the fetcher for a given key
* @param key
*/
resetFetcher(key: string, opts?: { reason?: unknown }): void;

/**
* @private
* PRIVATE - DO NOT USE
Expand Down Expand Up @@ -3059,6 +3068,11 @@ export function createRouter(init: RouterInit): Router {
return state.fetchers.get(key) || IDLE_FETCHER;
}

function resetFetcher(key: string, opts?: { reason?: unknown }) {
abortFetcher(key, opts?.reason);
updateFetcherState(key, getDoneFetcher(null));
}

function deleteFetcher(key: string): void {
let fetcher = state.fetchers.get(key);
// Don't abort the controller if this is a deletion of a fetcher.submit()
Expand Down Expand Up @@ -3089,10 +3103,10 @@ export function createRouter(init: RouterInit): Router {
updateState({ fetchers: new Map(state.fetchers) });
}

function abortFetcher(key: string) {
function abortFetcher(key: string, reason?: unknown) {
let controller = fetchControllers.get(key);
if (controller) {
controller.abort();
controller.abort(reason);
fetchControllers.delete(key);
}
}
Expand Down Expand Up @@ -3472,6 +3486,7 @@ export function createRouter(init: RouterInit): Router {
createHref: (to: To) => init.history.createHref(to),
encodeLocation: (to: To) => init.history.encodeLocation(to),
getFetcher,
resetFetcher,
deleteFetcher: queueFetcherForDeletion,
dispose,
getBlocker,
Expand Down