Skip to content

Commit d5b2560

Browse files
authored
feat: Add deferred support (#9002)
* feat: initial work on deferred() * Add Deferred component and useDeferred hook * Don't cancel reused routes * rename DeferredCollection -> DeferredData * fix lint warnings * handle revalidations + deferred cancellation * cancellation + fetcher flows * resolve deferred fetchers on revalidation * Fix TS/lint issue * Update bundle thresholds * Add spies and fix bug in revalidation cancellation * Add changeset * update changeset * Fix logic regarding 400 binary FormData error flows * Rename errorBoundary -> errorElement * Bump bundle size * Update API to leverage useDeferredData/useRouteError * Update example to use new deferred APIs * Switch Deferred from from data->value * Add types for Deferred * Change DeferredProps.value to use Data generic * fix some fetcher controller cleanup logic * await all deferreds during revalidation * Handle edge case of navigation -> revalidation -> navigation * Fix TS error * Bump bundle size * Fix test from merge conflict * move tick into defer * Adjust activeDeferred cleanup for better test assertions * Update changeset for updated API surface
1 parent d68d03e commit d5b2560

File tree

14 files changed

+3010
-254
lines changed

14 files changed

+3010
-254
lines changed

.changeset/light-months-argue.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"react-router": patch
3+
"@remix-run/router": patch
4+
---
5+
6+
Feat: adds `deferred` support to data routers
7+
8+
Returning a `deferred` from a `loader` allows you to separate _critical_ loader data that you want to wait for prior to rendering the destination page from _non-critical_ data that you are OK to show a spinner for until it loads.
9+
10+
```jsx
11+
// In your route loader, return a deferred() and choose per-key whether to
12+
// await the promise or not. As soon as the awaited promises resolve, the
13+
// page will be rendered.
14+
function loader() {
15+
return deferred({
16+
critical: await getCriticalData(),
17+
lazy1: getLazyData(),
18+
});
19+
};
20+
21+
// In your route element, grab the values from useLoaderData and render them
22+
// with <Deferred>
23+
function DeferredPage() {
24+
let data = useLoaderData();
25+
return (
26+
<>
27+
<p>Critical Data: {data.critical}</p>
28+
<Deferred
29+
value={data.lazy}
30+
fallback={<p>Loading...</p>}
31+
errorElement={<RenderDeferredError />}>
32+
<RenderDeferredData />
33+
</Deferred>
34+
</>
35+
);
36+
}
37+
38+
// Use separate components to render the data once it resolves, and access it
39+
// via the useDeferredData hook
40+
function RenderDeferredData() {
41+
let data = useDeferredData();
42+
return <p>Lazy: {data}</p>;
43+
}
44+
45+
function RenderDeferredError() {
46+
let data = useRouteError();
47+
return <p>Error! {data.message} {data.stack}</p>;
48+
}
49+
```
50+
51+
If you want to skip the separate components, you can use the Render Props
52+
pattern and handle the rendering of the deferred data inline:
53+
54+
```jsx
55+
function DeferredPage() {
56+
let data = useLoaderData();
57+
return (
58+
<>
59+
<p>Critical Data: {data.critical}</p>
60+
<Deferred value={data.lazy} fallback={<p>Loading...</p>}>
61+
{(data) => <p>{data}</p>}
62+
</Deferred>
63+
</>
64+
);
65+
}
66+
```

examples/data-router/src/App.tsx

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
import React from "react";
2-
import type { ActionFunction, LoaderFunction } from "react-router-dom";
32
import {
3+
type ActionFunction,
4+
type Deferrable,
5+
type LoaderFunction,
46
DataBrowserRouter,
7+
Deferred,
58
Form,
69
Link,
710
Route,
811
Outlet,
12+
deferred,
13+
useDeferredData,
914
useFetcher,
1015
useFetchers,
1116
useLoaderData,
1217
useNavigation,
1318
useParams,
19+
useRevalidator,
1420
useRouteError,
21+
json,
22+
useActionData,
1523
} from "react-router-dom";
1624

1725
import type { Todos } from "./todos";
1826
import { addTodo, deleteTodo, getTodos } from "./todos";
1927

20-
let sleep = () => new Promise((r) => setTimeout(r, 500));
28+
let sleep = (n: number = 500) => new Promise((r) => setTimeout(r, n));
2129

2230
function Fallback() {
2331
return <p>Performing initial data "load"</p>;
@@ -26,6 +34,7 @@ function Fallback() {
2634
// Layout
2735
function Layout() {
2836
let navigation = useNavigation();
37+
let { revalidate } = useRevalidator();
2938
let fetchers = useFetchers();
3039
let fetcherInProgress = fetchers.some((f) =>
3140
["loading", "submitting"].includes(f.state)
@@ -37,7 +46,15 @@ function Layout() {
3746
&nbsp;|&nbsp;
3847
<Link to="/todos">Todos</Link>
3948
&nbsp;|&nbsp;
49+
<Link to="/deferred">Deferred</Link>
50+
&nbsp;|&nbsp;
51+
<Link to="/deferred/child">Deferred Child</Link>
52+
&nbsp;|&nbsp;
53+
<Link to="/long-load">Long Load</Link>
54+
&nbsp;|&nbsp;
4055
<Link to="/404">404 Link</Link>
56+
&nbsp;&nbsp;
57+
<button onClick={() => revalidate()}>Revalidate</button>
4158
</nav>
4259
<div style={{ position: "fixed", top: 0, right: 0 }}>
4360
{navigation.state !== "idle" && <p>Navigation in progress...</p>}
@@ -47,6 +64,10 @@ function Layout() {
4764
Click on over to <Link to="/todos">/todos</Link> and check out these
4865
data loading APIs!{" "}
4966
</p>
67+
<p>
68+
Or, checkout <Link to="/deferred">/deferred</Link> to see how to
69+
separate critical and lazily loaded data in your loaders.
70+
</p>
5071
<p>
5172
We've introduced some fake async-aspects of routing here, so Keep an eye
5273
on the top-right hand corner to see when we're actively navigating.
@@ -192,6 +213,9 @@ function TodoItem({ id, todo }: TodoItemProps) {
192213
const todoLoader: LoaderFunction = async ({ params }) => {
193214
await sleep();
194215
let todos = getTodos();
216+
if (!params.id) {
217+
throw new Error("Expected params.id");
218+
}
195219
let todo = todos[params.id];
196220
if (!todo) {
197221
throw new Error(`Uh oh, I couldn't find a todo with id "${params.id}"`);
@@ -211,11 +235,133 @@ function Todo() {
211235
);
212236
}
213237

238+
interface DeferredRouteLoaderData {
239+
critical1: string;
240+
critical2: string;
241+
lazyResolved: Deferrable<string>;
242+
lazy1: Deferrable<string>;
243+
lazy2: Deferrable<string>;
244+
lazy3: Deferrable<string>;
245+
lazyError: Deferrable<string>;
246+
}
247+
248+
const rand = () => Math.round(Math.random() * 100);
249+
const resolve = (d: string, ms: number) =>
250+
new Promise((r) => setTimeout(() => r(`${d} - ${rand()}`), ms));
251+
const reject = (d: string, ms: number) =>
252+
new Promise((_, r) => setTimeout(() => r(`${d} - ${rand()}`), ms));
253+
254+
const deferredLoader: LoaderFunction = async ({ request }) => {
255+
return deferred({
256+
critical1: await resolve("Critical 1", 250),
257+
critical2: await resolve("Critical 2", 500),
258+
lazyResolved: Promise.resolve("Lazy Data immediately resolved - " + rand()),
259+
lazy1: resolve("Lazy 1", 1000),
260+
lazy2: resolve("Lazy 2", 1500),
261+
lazy3: resolve("Lazy 3", 2000),
262+
lazyError: reject("Kaboom!", 2500),
263+
});
264+
};
265+
266+
function DeferredPage() {
267+
let data = useLoaderData() as DeferredRouteLoaderData;
268+
269+
return (
270+
<div>
271+
<p>{data.critical1}</p>
272+
<p>{data.critical2}</p>
273+
<Deferred value={data.lazyResolved} fallback={<p>should not see me!</p>}>
274+
<RenderDeferredData />
275+
</Deferred>
276+
<Deferred value={data.lazy1} fallback={<p>loading 1...</p>}>
277+
<RenderDeferredData />
278+
</Deferred>
279+
<Deferred value={data.lazy2} fallback={<p>loading 2...</p>}>
280+
<RenderDeferredData />
281+
</Deferred>
282+
<Deferred value={data.lazy3} fallback={<p>loading 3...</p>}>
283+
{(data) => <p>{data}</p>}
284+
</Deferred>
285+
<Deferred
286+
value={data.lazyError}
287+
fallback={<p>loading (error)...</p>}
288+
errorElement={<RenderDeferredError />}
289+
>
290+
<RenderDeferredData />
291+
</Deferred>
292+
<Outlet />
293+
</div>
294+
);
295+
}
296+
297+
const deferredChildLoader: LoaderFunction = async ({ request }) => {
298+
return deferred({
299+
critical: await resolve("Critical Child Data", 500),
300+
lazy: resolve("Lazy Child Data", 1000),
301+
});
302+
};
303+
304+
const deferredChildAction: ActionFunction = async ({ request }) => {
305+
return json({ ok: true });
306+
};
307+
308+
function DeferredChild() {
309+
let data = useLoaderData();
310+
let actionData = useActionData();
311+
return (
312+
<div>
313+
<p>{data.critical}</p>
314+
<Deferred value={data.lazy} fallback={<p>loading child...</p>}>
315+
<RenderDeferredData />
316+
</Deferred>
317+
<Form method="post">
318+
<button type="submit" name="key" value="value">
319+
Submit
320+
</button>
321+
</Form>
322+
{actionData ? <p>Action data:{JSON.stringify(actionData)}</p> : null}
323+
</div>
324+
);
325+
}
326+
327+
function RenderDeferredData() {
328+
let data = useDeferredData<string>();
329+
return <p>{data}</p>;
330+
}
331+
332+
function RenderDeferredError() {
333+
let error = useRouteError();
334+
return (
335+
<p style={{ color: "red" }}>
336+
Error (errorElement)!
337+
<br />
338+
{error.message} {error.stack}
339+
</p>
340+
);
341+
}
342+
214343
function App() {
215344
return (
216345
<DataBrowserRouter fallbackElement={<Fallback />}>
217346
<Route path="/" element={<Layout />}>
218347
<Route index loader={homeLoader} element={<Home />} />
348+
<Route
349+
path="deferred"
350+
loader={deferredLoader}
351+
element={<DeferredPage />}
352+
>
353+
<Route
354+
path="child"
355+
loader={deferredChildLoader}
356+
action={deferredChildAction}
357+
element={<DeferredChild />}
358+
/>
359+
</Route>
360+
<Route
361+
path="long-load"
362+
loader={() => sleep(3000)}
363+
element={<h1>👋</h1>}
364+
/>
219365
<Route
220366
path="todos"
221367
action={todosAction}

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,13 @@
100100
},
101101
"filesize": {
102102
"packages/router/dist/router.js": {
103-
"none": "78 kB"
103+
"none": "87 kB"
104104
},
105105
"packages/react-router/dist/react-router.production.min.js": {
106-
"none": "10 kB"
106+
"none": "11 kB"
107107
},
108108
"packages/react-router/dist/umd/react-router.production.min.js": {
109-
"none": "12 kB"
109+
"none": "13 kB"
110110
},
111111
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
112112
"none": "10 kB"

packages/react-router-dom/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export type {
6363
ActionFunctionArgs,
6464
DataMemoryRouterProps,
6565
DataRouteMatch,
66+
Deferrable,
67+
DeferredProps,
6668
Fetcher,
6769
Hash,
6870
IndexRouteProps,
@@ -97,6 +99,7 @@ export type {
9799
} from "react-router";
98100
export {
99101
DataMemoryRouter,
102+
Deferred,
100103
MemoryRouter,
101104
Navigate,
102105
NavigationType,
@@ -106,6 +109,8 @@ export {
106109
Routes,
107110
createPath,
108111
createRoutesFromChildren,
112+
deferred,
113+
isDeferredError,
109114
isRouteErrorResponse,
110115
generatePath,
111116
json,
@@ -116,6 +121,7 @@ export {
116121
renderMatches,
117122
resolvePath,
118123
useActionData,
124+
useDeferredData,
119125
useHref,
120126
useInRouterContext,
121127
useLoaderData,

packages/react-router-native/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export type {
2727
ActionFunctionArgs,
2828
DataMemoryRouterProps,
2929
DataRouteMatch,
30+
Deferrable,
31+
DeferredProps,
3032
Fetcher,
3133
Hash,
3234
IndexRouteProps,
@@ -61,6 +63,7 @@ export type {
6163
} from "react-router";
6264
export {
6365
DataMemoryRouter,
66+
Deferred,
6467
MemoryRouter,
6568
Navigate,
6669
NavigationType,
@@ -70,6 +73,8 @@ export {
7073
Routes,
7174
createPath,
7275
createRoutesFromChildren,
76+
deferred,
77+
isDeferredError,
7378
isRouteErrorResponse,
7479
generatePath,
7580
json,
@@ -80,6 +85,7 @@ export {
8085
renderMatches,
8186
resolvePath,
8287
useActionData,
88+
useDeferredData,
8389
useHref,
8490
useInRouterContext,
8591
useLoaderData,

0 commit comments

Comments
 (0)