Skip to content

Commit 5ba67d8

Browse files
authored
fix: preserve loaderData for non-revalidating loaders (#8973)
* fix: preserve loaderData for non-revalidating loaders * add changeset * chore: refactor implementation to leverage mergeLoaderData
1 parent 0bb4410 commit 5ba67d8

File tree

3 files changed

+198
-23
lines changed

3 files changed

+198
-23
lines changed

.changeset/unlucky-cows-rhyme.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: preserve loader data for loaders that opted out of revalidation (#8973)

packages/router/__tests__/router-test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,6 +1601,120 @@ describe("a router", () => {
16011601

16021602
router.dispose();
16031603
});
1604+
1605+
it("preserves non-revalidated loaderData on navigations", async () => {
1606+
let count = 0;
1607+
let history = createMemoryHistory();
1608+
let router = createRouter({
1609+
history,
1610+
routes: [
1611+
{
1612+
path: "",
1613+
id: "root",
1614+
loader: () => `ROOT ${++count}`,
1615+
element: {},
1616+
children: [
1617+
{
1618+
path: "/",
1619+
id: "index",
1620+
loader: (args) => "SHOULD NOT GET CALLED",
1621+
shouldRevalidate: () => false,
1622+
},
1623+
],
1624+
},
1625+
],
1626+
hydrationData: {
1627+
loaderData: {
1628+
root: "ROOT 0",
1629+
index: "INDEX",
1630+
},
1631+
},
1632+
});
1633+
router.initialize();
1634+
await tick();
1635+
1636+
// Navigating to the same link would normally cause all loaders to re-run
1637+
router.navigate("/");
1638+
await tick();
1639+
expect(router.state.loaderData).toEqual({
1640+
root: "ROOT 1",
1641+
index: "INDEX",
1642+
});
1643+
1644+
router.navigate("/");
1645+
await tick();
1646+
expect(router.state.loaderData).toEqual({
1647+
root: "ROOT 2",
1648+
index: "INDEX",
1649+
});
1650+
1651+
router.dispose();
1652+
});
1653+
1654+
it("preserves non-revalidated loaderData on fetches", async () => {
1655+
let count = 0;
1656+
let history = createMemoryHistory();
1657+
let router = createRouter({
1658+
history,
1659+
routes: [
1660+
{
1661+
path: "",
1662+
id: "root",
1663+
element: {},
1664+
children: [
1665+
{
1666+
path: "/",
1667+
id: "index",
1668+
loader: () => "SHOULD NOT GET CALLED",
1669+
shouldRevalidate: () => false,
1670+
},
1671+
{
1672+
path: "/fetch",
1673+
id: "fetch",
1674+
action: () => `FETCH ${++count}`,
1675+
},
1676+
],
1677+
},
1678+
],
1679+
hydrationData: {
1680+
loaderData: {
1681+
index: "INDEX",
1682+
},
1683+
},
1684+
});
1685+
router.initialize();
1686+
await tick();
1687+
1688+
let key = "key";
1689+
1690+
router.fetch(key, "root", "/fetch", {
1691+
formMethod: "post",
1692+
formData: createFormData({ key: "value" }),
1693+
});
1694+
await tick();
1695+
expect(router.state.fetchers.get(key)).toMatchObject({
1696+
state: "idle",
1697+
data: "FETCH 1",
1698+
});
1699+
expect(router.state.loaderData).toMatchObject({
1700+
index: "INDEX",
1701+
});
1702+
1703+
router.fetch(key, "root", "/fetch", {
1704+
formMethod: "post",
1705+
formData: createFormData({ key: "value" }),
1706+
});
1707+
await tick();
1708+
expect(router.state.fetchers.get(key)).toMatchObject({
1709+
state: "idle",
1710+
data: "FETCH 2",
1711+
});
1712+
expect(router.state.loaderData).toMatchObject({
1713+
index: "INDEX",
1714+
});
1715+
1716+
router.dispose();
1717+
});
16041718
});
16051719

16061720
describe("no route match", () => {
@@ -1633,6 +1747,57 @@ describe("a router", () => {
16331747
},
16341748
]);
16351749
});
1750+
1751+
it("clears prior loader/action data", async () => {
1752+
let t = initializeTmTest();
1753+
expect(t.router.state.loaderData).toEqual({
1754+
root: "ROOT",
1755+
index: "INDEX",
1756+
});
1757+
1758+
let A = await t.navigate("/foo", {
1759+
formMethod: "post",
1760+
formData: createFormData({ key: "value" }),
1761+
});
1762+
await A.actions.foo.resolve("ACTION");
1763+
await A.loaders.root.resolve("ROOT*");
1764+
await A.loaders.foo.resolve("LOADER");
1765+
expect(t.router.state.actionData).toEqual({
1766+
foo: "ACTION",
1767+
});
1768+
expect(t.router.state.loaderData).toEqual({
1769+
root: "ROOT*",
1770+
foo: "LOADER",
1771+
});
1772+
1773+
t.navigate("/not-found");
1774+
expect(t.router.state.actionData).toBe(null);
1775+
expect(t.router.state.loaderData).toEqual({
1776+
root: "ROOT*",
1777+
});
1778+
expect(t.router.state.errors).toEqual({
1779+
root: {
1780+
status: 404,
1781+
statusText: "Not Found",
1782+
data: null,
1783+
},
1784+
});
1785+
expect(t.router.state.matches).toMatchObject([
1786+
{
1787+
params: {},
1788+
pathname: "",
1789+
route: {
1790+
errorElement: true,
1791+
children: expect.any(Array),
1792+
element: {},
1793+
id: "root",
1794+
loader: expect.any(Function),
1795+
module: "",
1796+
path: "",
1797+
},
1798+
},
1799+
]);
1800+
});
16361801
});
16371802

16381803
describe("errors on navigation", () => {

packages/router/router.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -597,19 +597,29 @@ export function createRouter(init: RouterInit): Router {
597597
state.navigation.formMethod != null &&
598598
state.navigation.state === "loading";
599599

600+
// Always preserve any existing loaderData from re-used routes
601+
let newLoaderData = newState.loaderData
602+
? {
603+
loaderData: mergeLoaderData(
604+
state.loaderData,
605+
newState.loaderData,
606+
newState.matches || []
607+
),
608+
}
609+
: {};
610+
600611
updateState({
601612
// Clear existing actionData on any completed navigation beyond the original
602613
// action, unless we're currently finishing the loading/actionReload state.
603614
// Do this prior to spreading in newState in case we got back to back actions
604615
...(isActionReload ? {} : { actionData: null }),
605616
...newState,
617+
...newLoaderData,
606618
historyAction,
607619
location,
608620
initialized: true,
609621
navigation: IDLE_NAVIGATION,
610622
revalidation: "idle",
611-
// Always preserve any existing loaderData from re-used routes
612-
loaderData: mergeLoaderData(state, newState),
613623
// Don't restore on submission navigations
614624
restoreScrollPosition: state.navigation.formData
615625
? false
@@ -749,6 +759,7 @@ export function createRouter(init: RouterInit): Router {
749759
} = getNotFoundMatches(dataRoutes);
750760
completeNavigation(historyAction, location, {
751761
matches: notFoundMatches,
762+
loaderData: {},
752763
errors: {
753764
[route.id]: error,
754765
},
@@ -1292,10 +1303,12 @@ export function createRouter(init: RouterInit): Router {
12921303
fetchers: new Map(state.fetchers),
12931304
});
12941305
} else {
1295-
// otherwise just update with the fetcher data
1306+
// otherwise just update with the fetcher data, preserving any existing
1307+
// loaderData for loaders that did not need to reload. We have to
1308+
// manually merge here since we aren't going through completeNavigation
12961309
updateState({
12971310
errors,
1298-
loaderData,
1311+
loaderData: mergeLoaderData(state.loaderData, loaderData, matches),
12991312
...(didAbortFetchLoads ? { fetchers: new Map(state.fetchers) } : {}),
13001313
});
13011314
isRevalidationRequired = false;
@@ -1937,26 +1950,18 @@ function processLoaderData(
19371950
}
19381951

19391952
function mergeLoaderData(
1940-
state: RouterState,
1941-
newState: Partial<RouterState>
1953+
loaderData: RouteData,
1954+
newLoaderData: RouteData,
1955+
matches: DataRouteMatch[]
19421956
): RouteData {
1943-
// Identify active routes that have current loaderData and didn't receive new
1944-
// loaderData
1945-
let reusedRoutesWithData = (newState.matches || state.matches).filter(
1946-
(match) =>
1947-
state.loaderData[match.route.id] !== undefined &&
1948-
newState.loaderData?.[match.route.id] === undefined
1949-
);
1950-
return {
1951-
...newState.loaderData,
1952-
...reusedRoutesWithData.reduce(
1953-
(acc, match) =>
1954-
Object.assign(acc, {
1955-
[match.route.id]: state.loaderData[match.route.id],
1956-
}),
1957-
{}
1958-
),
1959-
};
1957+
let mergedLoaderData = { ...newLoaderData };
1958+
matches.forEach((match) => {
1959+
let id = match.route.id;
1960+
if (newLoaderData[id] === undefined && loaderData[id] !== undefined) {
1961+
mergedLoaderData[id] = loaderData[id];
1962+
}
1963+
});
1964+
return mergedLoaderData;
19601965
}
19611966

19621967
// Find the nearest error boundary, looking upwards from the leaf route (or the

0 commit comments

Comments
 (0)