Skip to content

Commit 2b26bf6

Browse files
authored
Fix fetcher data cleanup (#12681)
1 parent 44a4568 commit 2b26bf6

File tree

5 files changed

+121
-2
lines changed

5 files changed

+121
-2
lines changed

.changeset/beige-ants-pay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
Fix issue with fetcher data cleanup in the data layer on fetcher unmount

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5768,6 +5768,74 @@ function testDomRouter(
57685768
expect(container.innerHTML).toMatch(/(:r[0-9]?[a-z]:),my-key/)
57695769
);
57705770
});
5771+
5772+
it("cleans up keyed fetcher data on unmount", async () => {
5773+
let count = 0;
5774+
let router = createTestRouter(
5775+
[
5776+
{
5777+
path: "/",
5778+
loader() {
5779+
return ++count;
5780+
},
5781+
Component() {
5782+
let [shown, setShown] = React.useState(false);
5783+
return (
5784+
<div>
5785+
<button onClick={() => setShown(!shown)}>
5786+
{shown ? "Unmount" : "Mount"}
5787+
</button>
5788+
{shown ? <FetcherComponent /> : null}
5789+
</div>
5790+
);
5791+
},
5792+
ErrorBoundary() {
5793+
let error = useRouteError();
5794+
return <pre>{JSON.stringify(error)}</pre>;
5795+
},
5796+
},
5797+
],
5798+
{
5799+
window: getWindow("/"),
5800+
}
5801+
);
5802+
5803+
render(<RouterProvider router={router} />);
5804+
5805+
function FetcherComponent() {
5806+
let fetcher = useFetcher({ key: "shared" });
5807+
return (
5808+
<div>
5809+
<p>{`Fetcher state:${fetcher.state}`}</p>
5810+
{fetcher.data != null ? (
5811+
<p data-testid="value">{fetcher.data}</p>
5812+
) : null}
5813+
<button onClick={() => fetcher.load(".")}>Fetch</button>
5814+
</div>
5815+
);
5816+
}
5817+
5818+
await waitFor(() => screen.getByText("Mount"));
5819+
5820+
fireEvent.click(screen.getByText("Mount"));
5821+
await waitFor(() => screen.getByText("Fetcher state:idle"));
5822+
5823+
fireEvent.click(screen.getByText("Fetch"));
5824+
await waitFor(() => screen.getByTestId("value"));
5825+
let value = screen.getByTestId("value").innerHTML;
5826+
5827+
fireEvent.click(screen.getByText("Unmount"));
5828+
await waitFor(() => screen.getByText("Mount"));
5829+
5830+
fireEvent.click(screen.getByText("Mount"));
5831+
await waitFor(() => screen.getByText("Fetcher state:idle"));
5832+
expect(screen.queryByTestId("value")).toBe(null);
5833+
5834+
fireEvent.click(screen.getByText("Fetch"));
5835+
await waitFor(() => screen.getByTestId("value"));
5836+
let value2 = screen.getByTestId("value").innerHTML;
5837+
expect(value2).not.toBe(value);
5838+
});
57715839
});
57725840

57735841
describe("fetcher persistence", () => {

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,43 @@ describe("fetchers", () => {
354354
expect(t.router.state.fetchers.size).toBe(0);
355355
});
356356

357+
it("fetchers removed from data layer upon unmount", async () => {
358+
let t = initializeTest();
359+
360+
let subscriber = jest.fn();
361+
t.router.subscribe(subscriber);
362+
363+
let key = "key";
364+
t.router.getFetcher(key); // mount
365+
expect(t.router.state.fetchers.size).toBe(0);
366+
367+
let A = await t.fetch("/foo", key);
368+
expect(t.router.state.fetchers.size).toBe(1);
369+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
370+
expect(subscriber.mock.calls.length).toBe(1);
371+
expect(subscriber.mock.calls[0][0].fetchers.get("key").state).toBe(
372+
"loading"
373+
);
374+
subscriber.mockReset();
375+
376+
await A.loaders.foo.resolve("FOO");
377+
expect(t.router.state.fetchers.size).toBe(0);
378+
expect(subscriber.mock.calls.length).toBe(1);
379+
// Fetcher removed from router state upon return to idle
380+
expect(subscriber.mock.calls[0][0].fetchers.size).toBe(0);
381+
// But still mounted so not deleted from data layer yet
382+
expect(subscriber.mock.calls[0][1].deletedFetchers.length).toBe(0);
383+
subscriber.mockReset();
384+
385+
t.router.deleteFetcher(key); // unmount
386+
expect(t.router.state.fetchers.size).toBe(0);
387+
expect(subscriber.mock.calls.length).toBe(1);
388+
expect(subscriber.mock.calls[0][0].fetchers.size).toBe(0);
389+
// Unmounted so can be deleted from data layer
390+
expect(subscriber.mock.calls[0][1].deletedFetchers).toEqual(["key"]);
391+
subscriber.mockReset();
392+
});
393+
357394
it("submitting fetchers persist until completion when removed during submitting phase", async () => {
358395
let t = initializeTest();
359396

packages/react-router/lib/components.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,12 @@ export function RouterProvider({
217217
newState: RouterState,
218218
{ deletedFetchers, flushSync, viewTransitionOpts }
219219
) => {
220-
deletedFetchers.forEach((key) => fetcherData.current.delete(key));
221220
newState.fetchers.forEach((fetcher, key) => {
222221
if (fetcher.data !== undefined) {
223222
fetcherData.current.set(key, fetcher.data);
224223
}
225224
});
225+
deletedFetchers.forEach((key) => fetcherData.current.delete(key));
226226

227227
warnOnce(
228228
flushSync === false || reactDomFlushSyncImpl != null,

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,7 @@ export function createRouter(init: RouterInit): Router {
11351135
// care about in-flight fetchers
11361136
// - If it's been unmounted then we can completely delete it
11371137
// - If it's still mounted we can remove it from `state.fetchers`, but we
1138-
// need to keep it around in thing like `fetchLoadMatches`, etc. since
1138+
// need to keep it around in things like `fetchLoadMatches`, etc. since
11391139
// it may be called again
11401140
let unmountedFetchers: string[] = [];
11411141
let mountedFetchers: string[] = [];
@@ -1150,6 +1150,15 @@ export function createRouter(init: RouterInit): Router {
11501150
}
11511151
});
11521152

1153+
// Delete any other `idle` fetchers unmounted in the UI that were previously
1154+
// removed from state.fetchers. Check `fetchControllers` in case this
1155+
// fetcher is actively revalidating and we want to let that finish
1156+
fetchersQueuedForDeletion.forEach((key) => {
1157+
if (!state.fetchers.has(key) && !fetchControllers.has(key)) {
1158+
unmountedFetchers.push(key);
1159+
}
1160+
});
1161+
11531162
// Iterate over a local copy so that if flushSync is used and we end up
11541163
// removing and adding a new subscriber due to the useCallback dependencies,
11551164
// we don't get ourselves into a loop calling the new subscriber immediately

0 commit comments

Comments
 (0)