Skip to content

Feat: Add deferred support #9002

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
Jul 1, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f592b7e
feat: initial work on deferred()
brophdawg11 Jun 15, 2022
2b9f073
Add Deferred component and useDeferred hook
brophdawg11 Jun 16, 2022
b226c18
Don't cancel reused routes
brophdawg11 Jun 16, 2022
5659ff5
rename DeferredCollection -> DeferredData
brophdawg11 Jun 16, 2022
a65f06e
fix lint warnings
brophdawg11 Jun 16, 2022
44b9632
handle revalidations + deferred cancellation
brophdawg11 Jun 16, 2022
73269c4
cancellation + fetcher flows
brophdawg11 Jun 17, 2022
b201ec7
resolve deferred fetchers on revalidation
brophdawg11 Jun 17, 2022
3a9d812
Fix TS/lint issue
brophdawg11 Jun 20, 2022
88ad127
Update bundle thresholds
brophdawg11 Jun 20, 2022
ee97229
Add spies and fix bug in revalidation cancellation
brophdawg11 Jun 21, 2022
896dbd3
Add changeset
brophdawg11 Jun 21, 2022
a596abe
update changeset
brophdawg11 Jun 21, 2022
0bd125b
Merge branch 'dev' into brophdawg11/deferred
brophdawg11 Jun 22, 2022
c482f7d
Fix logic regarding 400 binary FormData error flows
brophdawg11 Jun 22, 2022
7504d99
Rename errorBoundary -> errorElement
brophdawg11 Jun 22, 2022
e090d66
Bump bundle size
brophdawg11 Jun 22, 2022
11ad3c1
Update API to leverage useDeferredData/useRouteError
brophdawg11 Jun 22, 2022
7f38497
Update example to use new deferred APIs
brophdawg11 Jun 22, 2022
0dfa170
Switch Deferred from from data->value
brophdawg11 Jun 23, 2022
d059832
Add types for Deferred
brophdawg11 Jun 23, 2022
d19efea
Change DeferredProps.value to use Data generic
brophdawg11 Jun 23, 2022
2b3dbb9
fix some fetcher controller cleanup logic
brophdawg11 Jun 24, 2022
6d38996
await all deferreds during revalidation
brophdawg11 Jun 24, 2022
3b17591
Merge branch 'dev' into brophdawg11/deferred
brophdawg11 Jun 24, 2022
38236df
Handle edge case of navigation -> revalidation -> navigation
brophdawg11 Jun 24, 2022
3d3498f
Fix TS error
brophdawg11 Jun 24, 2022
ba480dc
Bump bundle size
brophdawg11 Jun 24, 2022
d6711e7
Merge branch 'dev' into brophdawg11/deferred
brophdawg11 Jun 28, 2022
4fde4c5
Fix test from merge conflict
brophdawg11 Jun 28, 2022
ba6d260
move tick into defer
brophdawg11 Jun 28, 2022
2327800
Adjust activeDeferred cleanup for better test assertions
brophdawg11 Jun 29, 2022
57e1fe7
Update changeset for updated API surface
brophdawg11 Jul 1, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .changeset/light-months-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
"react-router": patch
"@remix-run/router": patch
---

Feat: adds `deferred` support to data routers

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.

```jsx
// In your route loader, return a deferred() and choose per-key whether to
// await the promise or not. As soon as the awaited promises resolve, the
// page will be rendered.
function loader() {
return deferred({
critical: await getCriticalData(),
lazy1: getLazyData(),
});
};

// In your route element, grab the values from useLoaderData and render them
// with <Deferred>
function DeferredPage() {
let data = useLoaderData();
return (
<>
<p>Critical Data: {data.critical}</p>
<Deferred
data={data.lazy}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value={data.lazy}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙌

fallback={<p>Loading...</p>}
errorBoundary={<RenderDeferredError />}>
<RenderDeferredData />
</Deferred>
</>
);
}

// Use separate components to render the data once it resolves, and access it
// via the useDeferred hook
function RenderDeferredData() {
let data = useDeferred();
return <p>Lazy: {data}</p>;
}

function RenderDeferredError() {
let data = useDeferred();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we call this useDeferredValue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale changeset again :/. It's useDeferredData now - aligning with useLoaderData as its happy path. We didn't want to use useDeferredValue since <Deferred value="..."> accepts a Promise/DeferredError or the resolved "data". useDeferredData will only ever give you the resolved data.

return (<p>Error! {data.message} {data.stack}</p>;
}
```

If you want to skip the separate components, you can use the Render Props
pattern and handle the rendering inline:

```jsx
function DeferredPage() {
let data = useLoaderData();
return (
<>
<p>Critical Data: {data.critical}</p>
<Deferred data={data.lazy} fallback={<p>Loading...</p>}>
{({ data }) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing else for us to pass in here, so let's not put it on a named key and then force them to rename or double-destructure it:

// 😕
<Deferred>
  {({ data: whatever }) => ()}
</Deferred>

// 😕
<Deferred>
  {({ data: { stuff } }) => ()}
</Deferred>

Instead just pass the value into the child function:

<Deferred>
  {value => ()}
</Deferred>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops - that's a stale changeset 😬 We do just pass the resolved data now, will get that updated

isDeferredError(data) ? <p>Error! {data.message}</p> : <p>{data}</p>
}
</Deferred>
</>
);
}
```
138 changes: 136 additions & 2 deletions examples/data-router/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import React from "react";
import type { ActionFunction, LoaderFunction } from "react-router-dom";
import {
ActionFunction,
isDeferredError,
LoaderFunction,
useRevalidator,
} from "react-router-dom";
import {
DataBrowserRouter,
Deferred,
Form,
Link,
Route,
Outlet,
deferred,
useDeferred,
useFetcher,
useFetchers,
useLoaderData,
Expand All @@ -17,7 +25,7 @@ import {
import type { Todos } from "./todos";
import { addTodo, deleteTodo, getTodos } from "./todos";

let sleep = () => new Promise((r) => setTimeout(r, 500));
let sleep = (n: number = 500) => new Promise((r) => setTimeout(r, n));

function Fallback() {
return <p>Performing initial data "load"</p>;
Expand All @@ -26,6 +34,7 @@ function Fallback() {
// Layout
function Layout() {
let navigation = useNavigation();
let { revalidate } = useRevalidator();
let fetchers = useFetchers();
let fetcherInProgress = fetchers.some((f) =>
["loading", "submitting"].includes(f.state)
Expand All @@ -37,7 +46,15 @@ function Layout() {
&nbsp;|&nbsp;
<Link to="/todos">Todos</Link>
&nbsp;|&nbsp;
<Link to="/deferred">Deferred</Link>
&nbsp;|&nbsp;
<Link to="/deferred/child">Deferred Child</Link>
&nbsp;|&nbsp;
<Link to="/long-load">Long Load</Link>
&nbsp;|&nbsp;
<Link to="/404">404 Link</Link>
&nbsp;&nbsp;
<button onClick={() => revalidate()}>Revalidate</button>
</nav>
<div style={{ position: "fixed", top: 0, right: 0 }}>
{navigation.state !== "idle" && <p>Navigation in progress...</p>}
Expand All @@ -47,6 +64,10 @@ function Layout() {
Click on over to <Link to="/todos">/todos</Link> and check out these
data loading APIs!{" "}
</p>
<p>
Or, checkout <Link to="/deferred">/deferred</Link> to see how to
separate critical and lazily loaded data in your loaders.
</p>
<p>
We've introduced some fake async-aspects of routing here, so Keep an eye
on the top-right hand corner to see when we're actively navigating.
Expand Down Expand Up @@ -192,6 +213,9 @@ function TodoItem({ id, todo }: TodoItemProps) {
const todoLoader: LoaderFunction = async ({ params }) => {
await sleep();
let todos = getTodos();
if (!params.id) {
throw new Error("Expected params.id");
}
let todo = todos[params.id];
if (!todo) {
throw new Error(`Uh oh, I couldn't find a todo with id "${params.id}"`);
Expand All @@ -211,11 +235,121 @@ function Todo() {
);
}

const deferredLoader: LoaderFunction = async ({ request }) => {
return deferred({
critical1: await new Promise((r) =>
setTimeout(() => r("Critical Data 1"), 250)
),
critical2: await new Promise((r) =>
setTimeout(() => r("Critical Data 2"), 500)
),
lazyResolved: Promise.resolve("Lazy Data immediately resolved"),
lazy1: new Promise((r) => setTimeout(() => r("Lazy Data 1"), 1000)),
lazy2: new Promise((r) => setTimeout(() => r("Lazy Data 2"), 1500)),
lazy3: new Promise((r) => setTimeout(() => r("Lazy Data 3"), 2000)),
lazyError1: new Promise((_, r) => setTimeout(() => r("Kaboom!"), 2500)),
lazyError2: new Promise((_, r) => setTimeout(() => r("Kaboom!"), 3000)),
});
};

function DeferredPage() {
let data = useLoaderData();
return (
<div>
<p>{data.critical1}</p>
<p>{data.critical2}</p>
<Deferred data={data.lazyResolved} fallback={<p>should not see me!</p>}>
<RenderDeferredData />
</Deferred>
<Deferred data={data.lazy1} fallback={<p>loading 1...</p>}>
<RenderDeferredData />
</Deferred>
<Deferred data={data.lazy2} fallback={<p>loading 2...</p>}>
<RenderDeferredData />
</Deferred>
<Deferred data={data.lazy3} fallback={<p>loading 3...</p>}>
{({ data }: { data: any }) => <p>{data}</p>}
</Deferred>
<Deferred data={data.lazyError1} fallback={<p>loading (error 1)...</p>}>
<RenderDeferredData />
</Deferred>
<Deferred
data={data.lazyError2}
fallback={<p>loading (error 2)...</p>}
errorBoundary={<RenderDeferredError />}
>
<RenderDeferredData />
</Deferred>
<Outlet />
</div>
);
}

const deferredChildLoader: LoaderFunction = async ({ request }) => {
return deferred({
critical: await new Promise((r) =>
setTimeout(() => r("Critical Child Data"), 500)
),
lazy: new Promise((r) => setTimeout(() => r("Lazy Child Data"), 1000)),
});
};

function DeferredChild() {
let data = useLoaderData();
return (
<div>
<p>{data.critical}</p>
<Deferred data={data.lazy} fallback={<p>loading child...</p>}>
<RenderDeferredData />
</Deferred>
</div>
);
}

function RenderDeferredData() {
let data = useDeferred();

if (isDeferredError(data)) {
return (
<p style={{ color: "red" }}>
Error! {data.message} {data.stack}
</p>
);
}

return <p>{data}</p>;
}

function RenderDeferredError() {
let error = useDeferred() as Error;
return (
<p style={{ color: "red" }}>
Error! {error.message} {error.stack}
</p>
);
}

function App() {
return (
<DataBrowserRouter fallbackElement={<Fallback />}>
<Route path="/" element={<Layout />}>
<Route index loader={homeLoader} element={<Home />} />
<Route
path="deferred"
loader={deferredLoader}
element={<DeferredPage />}
>
<Route
path="child"
loader={deferredChildLoader}
element={<DeferredChild />}
/>
</Route>
<Route
path="long-load"
loader={() => sleep(3000)}
element={<h1>👋</h1>}
/>
<Route
path="todos"
action={todosAction}
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@
},
"filesize": {
"packages/router/dist/router.production.min.js": {
"none": "21 kB"
"none": "23 kB"
},
"packages/router/dist//umd/router.production.min.js": {
"none": "23 kB"
"none": "25 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
"none": "10 kB"
"none": "11 kB"
},
"packages/react-router/dist/umd/react-router.production.min.js": {
"none": "12 kB"
Expand Down
4 changes: 4 additions & 0 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export type {
} from "react-router";
export {
DataMemoryRouter,
Deferred,
MemoryRouter,
Navigate,
NavigationType,
Expand All @@ -106,6 +107,8 @@ export {
Routes,
createPath,
createRoutesFromChildren,
deferred,
isDeferredError,
isRouteErrorResponse,
generatePath,
json,
Expand All @@ -116,6 +119,7 @@ export {
renderMatches,
resolvePath,
useActionData,
useDeferred,
useHref,
useInRouterContext,
useLoaderData,
Expand Down
4 changes: 4 additions & 0 deletions packages/react-router-native/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type {
} from "react-router";
export {
DataMemoryRouter,
Deferred,
MemoryRouter,
Navigate,
NavigationType,
Expand All @@ -70,6 +71,8 @@ export {
Routes,
createPath,
createRoutesFromChildren,
deferred,
isDeferredError,
isRouteErrorResponse,
generatePath,
json,
Expand All @@ -80,6 +83,7 @@ export {
renderMatches,
resolvePath,
useActionData,
useDeferred,
useHref,
useInRouterContext,
useLoaderData,
Expand Down
10 changes: 9 additions & 1 deletion packages/react-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import type {
import {
Action as NavigationType,
createPath,
deferred,
generatePath,
isDeferredError,
isRouteErrorResponse,
json,
matchPath,
Expand All @@ -47,8 +49,9 @@ import type {
import {
createRoutesFromChildren,
renderMatches,
MemoryRouter,
DataMemoryRouter,
Deferred,
MemoryRouter,
Navigate,
Outlet,
Route,
Expand Down Expand Up @@ -78,6 +81,7 @@ import {
useResolvedPath,
useRoutes,
useActionData,
useDeferred,
useLoaderData,
useMatches,
useRouteLoaderData,
Expand Down Expand Up @@ -131,6 +135,7 @@ export type {
};
export {
DataMemoryRouter,
Deferred,
MemoryRouter,
Navigate,
NavigationType,
Expand All @@ -140,6 +145,8 @@ export {
Routes,
createPath,
createRoutesFromChildren,
deferred,
isDeferredError,
isRouteErrorResponse,
generatePath,
json,
Expand All @@ -150,6 +157,7 @@ export {
renderMatches,
resolvePath,
useActionData,
useDeferred,
useHref,
useInRouterContext,
useLoaderData,
Expand Down
Loading