Skip to content

Commit 26e8b8e

Browse files
authored
fix: Await should fallback on route params navigations (#9181)
* fix: fallback on route params navigations * add changeset * update changeset * bundle bump
1 parent a1bed3e commit 26e8b8e

File tree

5 files changed

+153
-16
lines changed

5 files changed

+153
-16
lines changed

.changeset/wet-readers-mate.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: Await should fallback on route params navigations (#9181)

docs/guides/deferred.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,13 @@ It's all trade-offs, and what's neat about the API design is that it's well suit
194194
195195
### When does the `<Suspense/>` fallback render?
196196
197-
The `<Await />` component will only throw the promise up the `<Suspense>` boundary on the initial render of the `<Await />` component with an unsettled promise. It will not re-render the fallback if props change. Effectively, this means that you will not get a fallback rendered when a user submits a form and loader data is revalidated and you will not get a fallback rendered when the user navigates to the same route with different params (in the context of our above example, if the user selects from a list of packages on the left to find their location on the right).
197+
The `<Await />` component will only throw the promise up the `<Suspense>` boundary on the initial render of the `<Await />` component with an unsettled promise. It will not re-render the fallback if props change. Effectively, this means that you _will not_ get a fallback rendered when a user submits a form and loader data is revalidated. You _will_ get a fallback rendered when the user navigates to the same route with different params (in the context of our above example, if the user selects from a list of packages on the left to find their location on the right).
198198
199-
This may feel counter-intuitive at first, but stay with us, we really thought this through and it's important that it works this way. Let's imagine a world without the deferred API. For those scenarios you're probably going to want to implement Optimistic UI for form submissions/revalidation and some Pending UI for sibling route navigations.
199+
This may feel counter-intuitive at first, but stay with us, we really thought this through and it's important that it works this way. Let's imagine a world without the deferred API. For those scenarios you're probably going to want to implement Optimistic UI for form submissions/revalidation.
200200
201-
When you decide you'd like to try the trade-offs of `defer`, we don't want you to have to change or remove those optimizations because we want you to be able to easily switch between deferring some data and not deferring it. So we ensure that your existing pending states work the same way. If we didn't do this, then you could experience what we call "Popcorn UI" where submissions of data trigger the fallback loading state instead of the optimistic UI you'd worked hard on.
201+
When you decide you'd like to try the trade-offs of `defer`, we don't want you to have to change or remove those optimizations because we want you to be able to easily switch between deferring some data and not deferring it. So we ensure that your existing optimistic states work the same way. If we didn't do this, then you could experience what we call "Popcorn UI" where submissions of data trigger the fallback loading state instead of the optimistic UI you'd worked hard on.
202202
203-
So just keep this in mind: **Deferred is 100% only about the initial load of a route.**
203+
So just keep this in mind: **Deferred is 100% only about the initial load of a route and it's params.**
204204
205205
[link]: ../components/link
206206
[usefetcher]: ../hooks/use-fetcher

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
},
102102
"filesize": {
103103
"packages/router/dist/router.js": {
104-
"none": "98 kB"
104+
"none": "99 kB"
105105
},
106106
"packages/react-router/dist/react-router.production.min.js": {
107107
"none": "12 kB"

packages/router/__tests__/router-test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8635,6 +8635,118 @@ describe("a router", () => {
86358635
expect(shouldRevalidateSpy).not.toHaveBeenCalled();
86368636
});
86378637

8638+
it("triggers fallbacks on new dynamic route instances", async () => {
8639+
let t = setup({
8640+
routes: [
8641+
{
8642+
id: "index",
8643+
index: true,
8644+
loader: true,
8645+
},
8646+
{
8647+
id: "invoice",
8648+
path: "invoices/:id",
8649+
loader: true,
8650+
},
8651+
],
8652+
hydrationData: { loaderData: { index: "INDEX" } },
8653+
initialEntries: ["/"],
8654+
});
8655+
8656+
let A = await t.navigate("/invoices/1");
8657+
let dfd1 = createDeferred();
8658+
await A.loaders.invoice.resolve(defer({ lazy: dfd1.promise }));
8659+
expect(t.router.state.loaderData).toEqual({
8660+
invoice: {
8661+
lazy: expect.trackedPromise(),
8662+
},
8663+
});
8664+
8665+
await dfd1.resolve("DATA 1");
8666+
expect(t.router.state.loaderData).toEqual({
8667+
invoice: {
8668+
lazy: expect.trackedPromise("DATA 1"),
8669+
},
8670+
});
8671+
8672+
// Goes back into a loading state since this is a new instance of the
8673+
// invoice route
8674+
let B = await t.navigate("/invoices/2");
8675+
let dfd2 = createDeferred();
8676+
await B.loaders.invoice.resolve(defer({ lazy: dfd2.promise }));
8677+
expect(t.router.state.loaderData).toEqual({
8678+
invoice: {
8679+
lazy: expect.trackedPromise(),
8680+
},
8681+
});
8682+
8683+
await dfd2.resolve("DATA 2");
8684+
expect(t.router.state.loaderData).toEqual({
8685+
invoice: {
8686+
lazy: expect.trackedPromise("DATA 2"),
8687+
},
8688+
});
8689+
});
8690+
8691+
it("triggers fallbacks on new splat route instances", async () => {
8692+
let t = setup({
8693+
routes: [
8694+
{
8695+
id: "index",
8696+
index: true,
8697+
loader: true,
8698+
},
8699+
{
8700+
id: "invoices",
8701+
path: "invoices",
8702+
children: [
8703+
{
8704+
id: "invoice",
8705+
path: "*",
8706+
loader: true,
8707+
},
8708+
],
8709+
},
8710+
],
8711+
hydrationData: { loaderData: { index: "INDEX" } },
8712+
initialEntries: ["/"],
8713+
});
8714+
8715+
let A = await t.navigate("/invoices/1");
8716+
let dfd1 = createDeferred();
8717+
await A.loaders.invoice.resolve(defer({ lazy: dfd1.promise }));
8718+
expect(t.router.state.loaderData).toEqual({
8719+
invoice: {
8720+
lazy: expect.trackedPromise(),
8721+
},
8722+
});
8723+
8724+
await dfd1.resolve("DATA 1");
8725+
expect(t.router.state.loaderData).toEqual({
8726+
invoice: {
8727+
lazy: expect.trackedPromise("DATA 1"),
8728+
},
8729+
});
8730+
8731+
// Goes back into a loading state since this is a new instance of the
8732+
// invoice route
8733+
let B = await t.navigate("/invoices/2");
8734+
let dfd2 = createDeferred();
8735+
await B.loaders.invoice.resolve(defer({ lazy: dfd2.promise }));
8736+
expect(t.router.state.loaderData).toEqual({
8737+
invoice: {
8738+
lazy: expect.trackedPromise(),
8739+
},
8740+
});
8741+
8742+
await dfd2.resolve("DATA 2");
8743+
expect(t.router.state.loaderData).toEqual({
8744+
invoice: {
8745+
lazy: expect.trackedPromise("DATA 2"),
8746+
},
8747+
});
8748+
});
8749+
86388750
it("cancels awaited reused deferreds on subsequent navigations", async () => {
86398751
let shouldRevalidateSpy = jest.fn(() => false);
86408752
let t = setup({

packages/router/router.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,7 @@ export function createRouter(init: RouterInit): Router {
10251025

10261026
let { results, loaderResults, fetcherResults } =
10271027
await callLoadersAndMaybeResolveData(
1028+
state.matches,
10281029
matchesToLoad,
10291030
revalidatingFetchers,
10301031
request
@@ -1256,6 +1257,7 @@ export function createRouter(init: RouterInit): Router {
12561257

12571258
let { results, loaderResults, fetcherResults } =
12581259
await callLoadersAndMaybeResolveData(
1260+
state.matches,
12591261
matchesToLoad,
12601262
revalidatingFetchers,
12611263
revalidationRequest
@@ -1461,6 +1463,7 @@ export function createRouter(init: RouterInit): Router {
14611463
}
14621464

14631465
async function callLoadersAndMaybeResolveData(
1466+
currentMatches: AgnosticDataRouteMatch[],
14641467
matchesToLoad: AgnosticDataRouteMatch[],
14651468
fetchersToLoad: RevalidatingFetcher[],
14661469
request: Request
@@ -1479,13 +1482,15 @@ export function createRouter(init: RouterInit): Router {
14791482

14801483
await Promise.all([
14811484
resolveDeferredResults(
1485+
currentMatches,
14821486
matchesToLoad,
14831487
loaderResults,
14841488
request.signal,
14851489
false,
14861490
state.loaderData
14871491
),
14881492
resolveDeferredResults(
1493+
currentMatches,
14891494
fetchersToLoad.map(([, , match]) => match),
14901495
fetcherResults,
14911496
request.signal,
@@ -2197,6 +2202,20 @@ function isNewLoader(
21972202
return isNew || isMissingData;
21982203
}
21992204

2205+
function isNewRouteInstance(
2206+
currentMatch: AgnosticDataRouteMatch,
2207+
match: AgnosticDataRouteMatch
2208+
) {
2209+
return (
2210+
// param change for this match, /users/123 -> /users/456
2211+
currentMatch.pathname !== match.pathname ||
2212+
// splat param changed, which is not present in match.path
2213+
// e.g. /files/images/avatar.jpg -> files/finances.xls
2214+
(currentMatch.route.path?.endsWith("*") &&
2215+
currentMatch.params["*"] !== match.params["*"])
2216+
);
2217+
}
2218+
22002219
function shouldRevalidateLoader(
22012220
currentLocation: string | Location,
22022221
currentMatch: AgnosticDataRouteMatch,
@@ -2218,12 +2237,7 @@ function shouldRevalidateLoader(
22182237
// Note that fetchers always provide the same current/next locations so the
22192238
// URL-based checks here don't apply to fetcher shouldRevalidate calls
22202239
let defaultShouldRevalidate =
2221-
// param change for this match, /users/123 -> /users/456
2222-
currentMatch.pathname !== match.pathname ||
2223-
// splat param changed, which is not present in match.path
2224-
// e.g. /files/images/avatar.jpg -> files/finances.xls
2225-
(currentMatch.route.path?.endsWith("*") &&
2226-
currentMatch.params["*"] !== match.params["*"]) ||
2240+
isNewRouteInstance(currentMatch, match) ||
22272241
// Clicked the same link, resubmitted a GET form
22282242
currentUrl.toString() === nextUrl.toString() ||
22292243
// Search params affect all loaders
@@ -2629,6 +2643,7 @@ function isRedirectResult(result?: DataResult): result is RedirectResult {
26292643
}
26302644

26312645
async function resolveDeferredResults(
2646+
currentMatches: AgnosticDataRouteMatch[],
26322647
matchesToLoad: AgnosticDataRouteMatch[],
26332648
results: DataResult[],
26342649
signal: AbortSignal,
@@ -2637,11 +2652,16 @@ async function resolveDeferredResults(
26372652
) {
26382653
for (let index = 0; index < results.length; index++) {
26392654
let result = results[index];
2640-
let id = matchesToLoad[index].route.id;
2641-
if (
2642-
isDeferredResult(result) &&
2643-
(isFetcher || currentLoaderData?.[id] !== undefined)
2644-
) {
2655+
let match = matchesToLoad[index];
2656+
let currentMatch = currentMatches.find(
2657+
(m) => m.route.id === match.route.id
2658+
);
2659+
let isRevalidatingLoader =
2660+
currentMatch != null &&
2661+
!isNewRouteInstance(currentMatch, match) &&
2662+
currentLoaderData?.[match.route.id] !== undefined;
2663+
2664+
if (isDeferredResult(result) && (isFetcher || isRevalidatingLoader)) {
26452665
// Note: we do not have to touch activeDeferreds here since we race them
26462666
// against the signal in resolveDeferredData and they'll get aborted
26472667
// there if needed

0 commit comments

Comments
 (0)