Skip to content

Commit e88adb6

Browse files
authored
Fix fetcher data persistence/cleanup issues (#12674)
1 parent 97fed11 commit e88adb6

File tree

5 files changed

+220
-17
lines changed

5 files changed

+220
-17
lines changed

.changeset/beige-ants-pay.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
- Fix issue with fetcher data cleanup in the data layer on fetcher unmount
6+
- Fix behavior of manual fetcher keys when not opted into `future.v7_fetcherPersist`

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5649,6 +5649,146 @@ function testDomRouter(
56495649
expect(container.innerHTML).toMatch(/(:r[0-9]?[a-z]:),my-key/)
56505650
);
56515651
});
5652+
5653+
it("cleans up keyed fetcher data on unmount (w/o fetcherPersist", async () => {
5654+
let count = 0;
5655+
let router = createTestRouter(
5656+
[
5657+
{
5658+
path: "/",
5659+
loader() {
5660+
return ++count;
5661+
},
5662+
Component() {
5663+
let [shown, setShown] = React.useState(false);
5664+
return (
5665+
<div>
5666+
<button onClick={() => setShown(!shown)}>
5667+
{shown ? "Unmount" : "Mount"}
5668+
</button>
5669+
{shown ? <FetcherComponent /> : null}
5670+
</div>
5671+
);
5672+
},
5673+
ErrorBoundary() {
5674+
let error = useRouteError();
5675+
return <pre>{JSON.stringify(error)}</pre>;
5676+
},
5677+
},
5678+
],
5679+
{
5680+
window: getWindow("/"),
5681+
}
5682+
);
5683+
5684+
render(<RouterProvider router={router} />);
5685+
5686+
function FetcherComponent() {
5687+
let fetcher = useFetcher({ key: "shared" });
5688+
return (
5689+
<div>
5690+
<p>{`Fetcher state:${fetcher.state}`}</p>
5691+
{fetcher.data != null ? (
5692+
<p data-testid="value">{fetcher.data}</p>
5693+
) : null}
5694+
<button onClick={() => fetcher.load(".")}>Fetch</button>
5695+
</div>
5696+
);
5697+
}
5698+
5699+
await waitFor(() => screen.getByText("Mount"));
5700+
5701+
fireEvent.click(screen.getByText("Mount"));
5702+
await waitFor(() => screen.getByText("Fetcher state:idle"));
5703+
5704+
fireEvent.click(screen.getByText("Fetch"));
5705+
await waitFor(() => screen.getByTestId("value"));
5706+
let value = screen.getByTestId("value").innerHTML;
5707+
5708+
fireEvent.click(screen.getByText("Unmount"));
5709+
await waitFor(() => screen.getByText("Mount"));
5710+
5711+
debugger;
5712+
fireEvent.click(screen.getByText("Mount"));
5713+
await waitFor(() => screen.getByText("Fetcher state:idle"));
5714+
expect(screen.queryByTestId("value")).toBe(null);
5715+
5716+
fireEvent.click(screen.getByText("Fetch"));
5717+
await waitFor(() => screen.getByTestId("value"));
5718+
let value2 = screen.getByTestId("value").innerHTML;
5719+
expect(value2).not.toBe(value);
5720+
});
5721+
5722+
it("cleans up keyed fetcher data on unmount (w/fetcherPersist", async () => {
5723+
let count = 0;
5724+
let router = createTestRouter(
5725+
[
5726+
{
5727+
path: "/",
5728+
loader() {
5729+
return ++count;
5730+
},
5731+
Component() {
5732+
let [shown, setShown] = React.useState(false);
5733+
return (
5734+
<div>
5735+
<button onClick={() => setShown(!shown)}>
5736+
{shown ? "Unmount" : "Mount"}
5737+
</button>
5738+
{shown ? <FetcherComponent /> : null}
5739+
</div>
5740+
);
5741+
},
5742+
ErrorBoundary() {
5743+
let error = useRouteError();
5744+
return <pre>{JSON.stringify(error)}</pre>;
5745+
},
5746+
},
5747+
],
5748+
{
5749+
window: getWindow("/"),
5750+
future: {
5751+
v7_fetcherPersist: true,
5752+
},
5753+
}
5754+
);
5755+
5756+
render(<RouterProvider router={router} />);
5757+
5758+
function FetcherComponent() {
5759+
let fetcher = useFetcher({ key: "shared" });
5760+
return (
5761+
<div>
5762+
<p>{`Fetcher state:${fetcher.state}`}</p>
5763+
{fetcher.data != null ? (
5764+
<p data-testid="value">{fetcher.data}</p>
5765+
) : null}
5766+
<button onClick={() => fetcher.load(".")}>Fetch</button>
5767+
</div>
5768+
);
5769+
}
5770+
5771+
await waitFor(() => screen.getByText("Mount"));
5772+
5773+
fireEvent.click(screen.getByText("Mount"));
5774+
await waitFor(() => screen.getByText("Fetcher state:idle"));
5775+
5776+
fireEvent.click(screen.getByText("Fetch"));
5777+
await waitFor(() => screen.getByTestId("value"));
5778+
let value = screen.getByTestId("value").innerHTML;
5779+
5780+
fireEvent.click(screen.getByText("Unmount"));
5781+
await waitFor(() => screen.getByText("Mount"));
5782+
5783+
fireEvent.click(screen.getByText("Mount"));
5784+
await waitFor(() => screen.getByText("Fetcher state:idle"));
5785+
expect(screen.queryByTestId("value")).toBe(null);
5786+
5787+
fireEvent.click(screen.getByText("Fetch"));
5788+
await waitFor(() => screen.getByTestId("value"));
5789+
let value2 = screen.getByTestId("value").innerHTML;
5790+
expect(value2).not.toBe(value);
5791+
});
56525792
});
56535793

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

packages/react-router-dom/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,12 +525,12 @@ export function RouterProvider({
525525
viewTransitionOpts: viewTransitionOpts,
526526
}
527527
) => {
528-
deletedFetchers.forEach((key) => fetcherData.current.delete(key));
529528
newState.fetchers.forEach((fetcher, key) => {
530529
if (fetcher.data !== undefined) {
531530
fetcherData.current.set(key, fetcher.data);
532531
}
533532
});
533+
deletedFetchers.forEach((key) => fetcherData.current.delete(key));
534534

535535
let isViewTransitionUnavailable =
536536
router.window == null ||

packages/router/__tests__/fetchers-test.ts

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

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

packages/router/router.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,14 @@ export function createRouter(init: RouterInit): Router {
11621162
});
11631163
}
11641164

1165+
// Remove any lingering deleted fetchers that have already been removed
1166+
// from state.fetchers
1167+
deletedFetchers.forEach((key) => {
1168+
if (!state.fetchers.has(key) && !fetchControllers.has(key)) {
1169+
deletedFetchersKeys.push(key);
1170+
}
1171+
});
1172+
11651173
// Iterate over a local copy so that if flushSync is used and we end up
11661174
// removing and adding a new subscriber due to the useCallback dependencies,
11671175
// we don't get ourselves into a loop calling the new subscriber immediately
@@ -1177,6 +1185,10 @@ export function createRouter(init: RouterInit): Router {
11771185
if (future.v7_fetcherPersist) {
11781186
completedFetchers.forEach((key) => state.fetchers.delete(key));
11791187
deletedFetchersKeys.forEach((key) => deleteFetcher(key));
1188+
} else {
1189+
// We already called deleteFetcher() on these, can remove them from this
1190+
// Set now that we've handed the keys off to the data layer
1191+
deletedFetchersKeys.forEach((key) => deletedFetchers.delete(key));
11801192
}
11811193
}
11821194

@@ -2942,13 +2954,11 @@ export function createRouter(init: RouterInit): Router {
29422954
}
29432955

29442956
function getFetcher<TData = any>(key: string): Fetcher<TData> {
2945-
if (future.v7_fetcherPersist) {
2946-
activeFetchers.set(key, (activeFetchers.get(key) || 0) + 1);
2947-
// If this fetcher was previously marked for deletion, unmark it since we
2948-
// have a new instance
2949-
if (deletedFetchers.has(key)) {
2950-
deletedFetchers.delete(key);
2951-
}
2957+
activeFetchers.set(key, (activeFetchers.get(key) || 0) + 1);
2958+
// If this fetcher was previously marked for deletion, unmark it since we
2959+
// have a new instance
2960+
if (deletedFetchers.has(key)) {
2961+
deletedFetchers.delete(key);
29522962
}
29532963
return state.fetchers.get(key) || IDLE_FETCHER;
29542964
}
@@ -2967,23 +2977,33 @@ export function createRouter(init: RouterInit): Router {
29672977
fetchLoadMatches.delete(key);
29682978
fetchReloadIds.delete(key);
29692979
fetchRedirectIds.delete(key);
2970-
deletedFetchers.delete(key);
2980+
2981+
// If we opted into the flag we can clear this now since we're calling
2982+
// deleteFetcher() at the end of updateState() and we've already handed the
2983+
// deleted fetcher keys off to the data layer.
2984+
// If not, we're eagerly calling deleteFetcher() and we need to keep this
2985+
// Set populated until the next updateState call, and we'll clear
2986+
// `deletedFetchers` then
2987+
if (future.v7_fetcherPersist) {
2988+
deletedFetchers.delete(key);
2989+
}
2990+
29712991
cancelledFetcherLoads.delete(key);
29722992
state.fetchers.delete(key);
29732993
}
29742994

29752995
function deleteFetcherAndUpdateState(key: string): void {
2976-
if (future.v7_fetcherPersist) {
2977-
let count = (activeFetchers.get(key) || 0) - 1;
2978-
if (count <= 0) {
2979-
activeFetchers.delete(key);
2980-
deletedFetchers.add(key);
2981-
} else {
2982-
activeFetchers.set(key, count);
2996+
let count = (activeFetchers.get(key) || 0) - 1;
2997+
if (count <= 0) {
2998+
activeFetchers.delete(key);
2999+
deletedFetchers.add(key);
3000+
if (!future.v7_fetcherPersist) {
3001+
deleteFetcher(key);
29833002
}
29843003
} else {
2985-
deleteFetcher(key);
3004+
activeFetchers.set(key, count);
29863005
}
3006+
29873007
updateState({ fetchers: new Map(state.fetchers) });
29883008
}
29893009

0 commit comments

Comments
 (0)