Skip to content

Commit b2c7ad5

Browse files
authored
[Segment Cache] use loading from dynamic response for unprefetched navigations (#83305)
If we're navigating to a page without a prefetch, we obviously can't show an instant loading state. However, if the page has a `loading.tsx`, then the loading component will still stream in as part of the dynamic response, and we should use it to render a loading boundary around the page content. If we don't, then the navigation will block until the content resolves into something renderable (either finishes or manages to render a suspense boundary of its own). This wasn't being done because `createPendingCacheNode` (used for unprefetched navigations) was always setting `loading` to `null`, and then was never updated with the `loading` received from the dynamic response. With this PR, if we don't already have a prefetched loading state, we'll now handle `loading` the same as we do `rsc` (the segment's content) -- we'll create a deferred promise for `loading` and resolve it when the relevant segment streams in. Note that before the deferred promise is resolved, the relevant `LoadingBoundary` itself will suspend on it, but since it sits right above `rsc` (and is in a new subtree) we won't hide any more contents than we would when suspending on the `rsc` promise -- we don't have a prefetch, so we won't have an existing loading state to display there anyway. Also I had to do some typescript tweaks to `createDeferredRsc` to allow it to contain a more specific type than `ReactNode`. it's a bit noisy, so that's pulled out into a separate commit for ease of review.
1 parent 544f521 commit b2c7ad5

File tree

8 files changed

+126
-24
lines changed

8 files changed

+126
-24
lines changed

packages/next/src/client/components/router-reducer/ppr-navigations.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -973,24 +973,28 @@ function createPendingCacheNode(
973973
}
974974

975975
const maybePrefetchRsc = prefetchData !== null ? prefetchData[1] : null
976-
const maybePrefetchLoading = prefetchData !== null ? prefetchData[3] : null
977976
return {
978977
lazyData: null,
979978
parallelRoutes: parallelRoutes,
980979

981980
prefetchRsc: maybePrefetchRsc !== undefined ? maybePrefetchRsc : null,
982981
prefetchHead: isLeafSegment ? prefetchHead : [null, null],
983982

984-
// TODO: Technically, a loading boundary could contain dynamic data. We must
985-
// have separate `loading` and `prefetchLoading` fields to handle this, like
986-
// we do for the segment data and head.
987-
loading: maybePrefetchLoading !== undefined ? maybePrefetchLoading : null,
988-
989983
// Create a deferred promise. This will be fulfilled once the dynamic
990984
// response is received from the server.
991985
rsc: createDeferredRsc() as React.ReactNode,
992986
head: isLeafSegment ? (createDeferredRsc() as React.ReactNode) : null,
993987

988+
// TODO: Technically, a loading boundary could contain dynamic data. We must
989+
// have separate `loading` and `prefetchLoading` fields to handle this, like
990+
// we do for the segment data and head.
991+
loading:
992+
prefetchData !== null
993+
? (prefetchData[3] ?? null)
994+
: // If we don't have a prefetch, then we don't know if there's a loading component.
995+
// We'll fulfill it based on the dynamic response, just like `rsc` and `head`.
996+
createDeferredRsc<LoadingModuleData>(),
997+
994998
navigatedAt,
995999
}
9961000
}
@@ -1089,6 +1093,14 @@ function finishPendingCacheNode(
10891093
// been populated by a different navigation. We must not overwrite it.
10901094
}
10911095

1096+
// If we navigated without a prefetch, then `loading` will be a deferred promise too.
1097+
// Fulfill it using the dynamic response so that we can display the loading boundary.
1098+
const loading = cacheNode.loading
1099+
if (isDeferredRsc(loading)) {
1100+
const dynamicLoading = dynamicData[3]
1101+
loading.resolve(dynamicLoading)
1102+
}
1103+
10921104
// Check if this is a leaf segment. If so, it will have a `head` property with
10931105
// a pending promise that needs to be resolved with the dynamic head from
10941106
// the server.
@@ -1153,6 +1165,7 @@ function abortPendingCacheNode(
11531165
// used to construct the cache nodes in the first place.
11541166
}
11551167
}
1168+
11561169
const rsc = cacheNode.rsc
11571170
if (isDeferredRsc(rsc)) {
11581171
if (error === null) {
@@ -1164,6 +1177,11 @@ function abortPendingCacheNode(
11641177
}
11651178
}
11661179

1180+
const loading = cacheNode.loading
1181+
if (isDeferredRsc(loading)) {
1182+
loading.resolve(null)
1183+
}
1184+
11671185
// Check if this is a leaf segment. If so, it will have a `head` property with
11681186
// a pending promise that needs to be resolved. If an error was provided, we
11691187
// will not resolve it with an error, since this is rendered at the root of
@@ -1240,61 +1258,63 @@ export function updateCacheNodeOnPopstateRestoration(
12401258

12411259
const DEFERRED = Symbol()
12421260

1243-
type PendingDeferredRsc = Promise<React.ReactNode> & {
1261+
type PendingDeferredRsc<T> = Promise<T> & {
12441262
status: 'pending'
1245-
resolve: (value: React.ReactNode) => void
1263+
resolve: (value: T) => void
12461264
reject: (error: any) => void
12471265
tag: Symbol
12481266
}
12491267

1250-
type FulfilledDeferredRsc = Promise<React.ReactNode> & {
1268+
type FulfilledDeferredRsc<T> = Promise<T> & {
12511269
status: 'fulfilled'
1252-
value: React.ReactNode
1253-
resolve: (value: React.ReactNode) => void
1270+
value: T
1271+
resolve: (value: T) => void
12541272
reject: (error: any) => void
12551273
tag: Symbol
12561274
}
12571275

1258-
type RejectedDeferredRsc = Promise<React.ReactNode> & {
1276+
type RejectedDeferredRsc<T> = Promise<T> & {
12591277
status: 'rejected'
12601278
reason: any
1261-
resolve: (value: React.ReactNode) => void
1279+
resolve: (value: T) => void
12621280
reject: (error: any) => void
12631281
tag: Symbol
12641282
}
12651283

1266-
type DeferredRsc =
1267-
| PendingDeferredRsc
1268-
| FulfilledDeferredRsc
1269-
| RejectedDeferredRsc
1284+
type DeferredRsc<T extends React.ReactNode = React.ReactNode> =
1285+
| PendingDeferredRsc<T>
1286+
| FulfilledDeferredRsc<T>
1287+
| RejectedDeferredRsc<T>
12701288

12711289
// This type exists to distinguish a DeferredRsc from a Flight promise. It's a
12721290
// compromise to avoid adding an extra field on every Cache Node, which would be
12731291
// awkward because the pre-PPR parts of codebase would need to account for it,
12741292
// too. We can remove it once type Cache Node type is more settled.
12751293
function isDeferredRsc(value: any): value is DeferredRsc {
1276-
return value && value.tag === DEFERRED
1294+
return value && typeof value === 'object' && value.tag === DEFERRED
12771295
}
12781296

1279-
function createDeferredRsc(): PendingDeferredRsc {
1297+
function createDeferredRsc<
1298+
T extends React.ReactNode = React.ReactNode,
1299+
>(): PendingDeferredRsc<T> {
12801300
let resolve: any
12811301
let reject: any
1282-
const pendingRsc = new Promise<React.ReactNode>((res, rej) => {
1302+
const pendingRsc = new Promise<T>((res, rej) => {
12831303
resolve = res
12841304
reject = rej
1285-
}) as PendingDeferredRsc
1305+
}) as PendingDeferredRsc<T>
12861306
pendingRsc.status = 'pending'
1287-
pendingRsc.resolve = (value: React.ReactNode) => {
1307+
pendingRsc.resolve = (value: T) => {
12881308
if (pendingRsc.status === 'pending') {
1289-
const fulfilledRsc: FulfilledDeferredRsc = pendingRsc as any
1309+
const fulfilledRsc: FulfilledDeferredRsc<T> = pendingRsc as any
12901310
fulfilledRsc.status = 'fulfilled'
12911311
fulfilledRsc.value = value
12921312
resolve(value)
12931313
}
12941314
}
12951315
pendingRsc.reject = (error: any) => {
12961316
if (pendingRsc.status === 'pending') {
1297-
const rejectedRsc: RejectedDeferredRsc = pendingRsc as any
1317+
const rejectedRsc: RejectedDeferredRsc<T> = pendingRsc as any
12981318
rejectedRsc.status = 'rejected'
12991319
rejectedRsc.reason = error
13001320
reject(error)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function RootLayout({ children }: LayoutProps<'/'>) {
2+
return (
3+
<html>
4+
<head></head>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Link from 'next/link'
2+
3+
export default function Page() {
4+
return (
5+
<main>
6+
<h1>Home</h1>
7+
<Link href="/with-loading" prefetch={false}>
8+
Go to /with-loading (no prefetch)
9+
</Link>
10+
</main>
11+
)
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'use client'
2+
3+
import { use } from 'react'
4+
5+
const infinitePromise = new Promise<never>(() => {})
6+
7+
export function SuspendForeverOnClient() {
8+
use(infinitePromise)
9+
return null
10+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Loading() {
2+
return <div id="loading-component">Loading...</div>
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { connection } from 'next/server'
2+
import { SuspendForeverOnClient } from './client'
3+
4+
export default async function Page() {
5+
await connection()
6+
return (
7+
<main>
8+
{/* Block on the client to make sure that the loading boundary works correctly. */}
9+
<SuspendForeverOnClient />
10+
</main>
11+
)
12+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** @type {import('next').NextConfig} */
2+
module.exports = {
3+
experimental: {
4+
clientSegmentCache: true,
5+
clientParamParsing: true,
6+
},
7+
productionBrowserSourceMaps: true,
8+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { createRouterAct } from '../router-act'
3+
4+
describe('navigating without a prefetch', () => {
5+
const { next } = nextTestSetup({
6+
files: __dirname,
7+
})
8+
9+
it('can show a loading boundary from the dynamic response', async () => {
10+
let act: ReturnType<typeof createRouterAct>
11+
const browser = await next.browser('/', {
12+
beforePageLoad(page) {
13+
act = createRouterAct(page)
14+
},
15+
})
16+
17+
// Navigate to a dynamic page with a `loading.tsx` without a prefetch.
18+
await act(async () => {
19+
await browser.elementByCss('a[href="/with-loading"]').click()
20+
})
21+
22+
// The page suspends on the client, so we should display the `loading` that we got from the dynamic response.
23+
expect(
24+
await browser
25+
.elementByCss('#loading-component', { state: 'visible' })
26+
.text()
27+
).toContain('Loading...')
28+
})
29+
})

0 commit comments

Comments
 (0)