Skip to content

Commit 9682004

Browse files
committed
offline navigations: reconstruct prefetched routes offline (11/13)
1 parent 5bdc788 commit 9682004

5 files changed

Lines changed: 535 additions & 59 deletions

File tree

packages/next/src/build/offline-navigation-fallback.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ function getScriptAttributes(
3535

3636
// Generate the build-scoped HTML entrypoint used by offline document fallback
3737
// handling. It intentionally contains only the app bootstrap, not route HTML;
38-
// exact-URL route data is restored by the client after this document loads.
38+
// route data is restored by the client from exact-URL RSC responses or
39+
// persisted router-cache records after this document loads.
3940
export function createOfflineNavigationFallbackDocument({
4041
assetPrefix,
4142
buildId,

packages/next/src/build/offline-navigation-service-worker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export function getOfflineNavigationServiceWorkerFilePath(): string {
1010

1111
// The first version of offline navigations uses a generated, app-local service
1212
// worker as a document fallback only. It is network-first for regular loads and
13-
// only serves the fallback document after the network request fails.
13+
// only serves the fallback document after the network request fails; route data
14+
// is restored later by the client from the durable router cache.
1415
export function createOfflineNavigationServiceWorker({
1516
buildId,
1617
cacheNamespace,

packages/next/src/client/app-index.tsx

Lines changed: 154 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
createMutableActionQueue,
2121
} from './components/app-router-instance'
2222
import AppRouter from './components/app-router'
23+
import DefaultGlobalError from './components/builtin/global-error'
2324
import type { InitialRSCPayload } from '../shared/lib/app-router-types'
2425
import { createInitialRouterState } from './components/router-reducer/create-initial-router-state'
2526
import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runtime'
@@ -63,12 +64,17 @@ type OfflineNavigationCacheMissReason =
6364
| 'unsupported-request-kind'
6465
| 'read-error'
6566

67+
type OfflineNavigationFallbackRequestKind =
68+
| 'client-resume'
69+
| 'initial-load'
70+
| 'router-cache'
71+
6672
type OfflineNavigationFallbackDiagnostic =
6773
| {
6874
type: 'cache-hit'
6975
url: string
7076
buildId: string | undefined
71-
requestKind: OfflineNavigationFallbackResponse['requestKind']
77+
requestKind: OfflineNavigationFallbackRequestKind
7278
}
7379
| {
7480
type: 'cache-miss'
@@ -89,6 +95,12 @@ type OfflineNavigationFallbackDiagnostic =
8995
skipped: number
9096
}
9197
}
98+
| {
99+
type: 'router-cache-reconstruction-miss'
100+
url: string
101+
buildId: string | undefined
102+
reason: string
103+
}
92104

93105
declare global {
94106
interface Window {
@@ -109,7 +121,7 @@ function reportOfflineNavigationFallbackDiagnostic(
109121
}
110122

111123
function showOfflineNavigationCacheHit(
112-
requestKind: OfflineNavigationFallbackResponse['requestKind'],
124+
requestKind: OfflineNavigationFallbackRequestKind,
113125
buildId: string | undefined
114126
): void {
115127
document.documentElement.setAttribute(
@@ -157,24 +169,43 @@ function showOfflineNavigationCacheMiss(
157169
})
158170
}
159171

160-
function neverResolveOfflineNavigationResponse(): Promise<Response> {
161-
return new Promise<Response>(() => {})
172+
function neverResolveInitialRSCPayload(): Promise<InitialRSCPayload> {
173+
return new Promise<InitialRSCPayload>(() => {})
162174
}
163175

164176
type OfflineNavigationFallbackResponse = {
165177
requestKind: 'client-resume' | 'initial-load'
166178
response: Response
167179
}
168180

169-
function createOfflineNavigationFallbackResponse():
170-
| Promise<OfflineNavigationFallbackResponse>
181+
type OfflineNavigationFallbackBootstrap =
182+
| {
183+
kind: 'rsc-response'
184+
response: OfflineNavigationFallbackResponse
185+
buildId: string | undefined
186+
}
187+
| {
188+
kind: 'router-cache'
189+
initialRSCPayload: InitialRSCPayload
190+
buildId: string | undefined
191+
}
192+
| {
193+
kind: 'cache-miss'
194+
buildId: string | undefined
195+
}
196+
197+
// The generated fallback document has no inline Flight data for the current
198+
// URL. Bootstrap from a durable exact-URL RSC response first, then fall back to
199+
// reconstructing the route from persisted segment-cache records.
200+
function createOfflineNavigationFallbackBootstrap():
201+
| Promise<OfflineNavigationFallbackBootstrap>
171202
| undefined {
172203
if (process.env.__NEXT_OFFLINE_NAVIGATIONS) {
173204
if (!isOfflineNavigationFallbackDocument()) {
174205
return undefined
175206
}
176207

177-
return (async (): Promise<OfflineNavigationFallbackResponse> => {
208+
return (async (): Promise<OfflineNavigationFallbackBootstrap> => {
178209
const {
179210
createOfflineNavigationRSCResponse,
180211
isOfflineNavigationRSCResponsePayload,
@@ -192,13 +223,54 @@ function createOfflineNavigationFallbackResponse():
192223
const payload = entry?.payload
193224

194225
if (!isOfflineNavigationRSCResponsePayload(payload)) {
226+
if (payload === undefined) {
227+
const {
228+
createOfflineNavigationInitialRSCPayloadFromRouterCache,
229+
hydrateOfflineNavigationRouterCache,
230+
} =
231+
require('./components/segment-cache/cache') as typeof import('./components/segment-cache/cache')
232+
233+
const hydrationResult = await hydrateOfflineNavigationRouterCache({
234+
buildId,
235+
})
236+
reportOfflineNavigationFallbackDiagnostic({
237+
type: 'router-cache-hydration',
238+
url: location.href,
239+
buildId,
240+
routes: hydrationResult.routes,
241+
segments: hydrationResult.segments,
242+
})
243+
244+
const reconstruction =
245+
createOfflineNavigationInitialRSCPayloadFromRouterCache({
246+
buildId,
247+
globalErrorState: [DefaultGlobalError, undefined],
248+
now: Date.now(),
249+
url: location.href,
250+
})
251+
if (reconstruction.status === 'fulfilled') {
252+
showOfflineNavigationCacheHit('router-cache', buildId)
253+
return {
254+
kind: 'router-cache',
255+
initialRSCPayload: reconstruction.initialRSCPayload,
256+
buildId,
257+
}
258+
}
259+
reportOfflineNavigationFallbackDiagnostic({
260+
type: 'router-cache-reconstruction-miss',
261+
url: location.href,
262+
buildId,
263+
reason: reconstruction.reason,
264+
})
265+
}
266+
195267
showOfflineNavigationCacheMiss(
196268
payload === undefined ? 'missing-entry' : 'invalid-payload',
197269
buildId
198270
)
199271
return {
200-
requestKind: 'client-resume',
201-
response: await neverResolveOfflineNavigationResponse(),
272+
kind: 'cache-miss',
273+
buildId,
202274
}
203275
}
204276

@@ -208,8 +280,8 @@ function createOfflineNavigationFallbackResponse():
208280
) {
209281
showOfflineNavigationCacheMiss('unsupported-request-kind', buildId)
210282
return {
211-
requestKind: 'client-resume',
212-
response: await neverResolveOfflineNavigationResponse(),
283+
kind: 'cache-miss',
284+
buildId,
213285
}
214286
}
215287

@@ -220,30 +292,42 @@ function createOfflineNavigationFallbackResponse():
220292

221293
showOfflineNavigationCacheHit(requestKind, buildId)
222294
return {
223-
requestKind,
224-
response: createOfflineNavigationRSCResponse(payload),
295+
kind: 'rsc-response',
296+
buildId,
297+
response: {
298+
requestKind,
299+
response: createOfflineNavigationRSCResponse(payload),
300+
},
225301
}
226-
})().catch(async (): Promise<OfflineNavigationFallbackResponse> => {
302+
})().catch((): OfflineNavigationFallbackBootstrap => {
227303
showOfflineNavigationCacheMiss('read-error', undefined)
228304
return {
229-
requestKind: 'client-resume',
230-
response: await neverResolveOfflineNavigationResponse(),
305+
kind: 'cache-miss',
306+
buildId: undefined,
231307
}
232308
})
233309
} else {
234310
return undefined
235311
}
236312
}
237313

238-
const offlineNavigationFallbackResponse =
239-
createOfflineNavigationFallbackResponse()
240-
const offlineNavigationClientResumeFetch =
241-
offlineNavigationFallbackResponse?.then(({ response }) => response)
314+
const offlineNavigationFallbackBootstrap =
315+
createOfflineNavigationFallbackBootstrap()
316+
317+
if (process.env.__NEXT_USE_OFFLINE) {
318+
if (offlineNavigationFallbackBootstrap) {
319+
const { notifyOffline } =
320+
require('./components/offline') as typeof import('./components/offline')
321+
notifyOffline()
322+
}
323+
} else {
324+
// Keep the offline event module out of disabled client bundles.
325+
}
242326

243327
const hasClientResumeShell = Boolean(window.__NEXT_CLIENT_RESUME)
244328
const hasLockedStaticShell =
245329
Boolean(instantTestStaticFetch) ||
246-
Boolean(offlineNavigationClientResumeFetch) ||
330+
Boolean(offlineNavigationFallbackBootstrap) ||
247331
hasClientResumeShell
248332

249333
const encoder = new TextEncoder()
@@ -417,7 +501,7 @@ if (
417501
if (
418502
process.env.__NEXT_CACHE_COMPONENTS &&
419503
process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS &&
420-
!offlineNavigationClientResumeFetch
504+
!offlineNavigationFallbackBootstrap
421505
) {
422506
const [forReact, forCache] = readable.tee()
423507
readable = forReact
@@ -461,25 +545,39 @@ if (instantTestStaticFetch) {
461545
initialRSCPayload
462546
)
463547
})
464-
} else if (offlineNavigationClientResumeFetch) {
465-
initialServerResponse = Promise.resolve(
466-
createFromFetch<InitialRSCPayload>(offlineNavigationClientResumeFetch, {
467-
callServer,
468-
findSourceMapURL,
469-
debugChannel,
470-
unstable_allowPartialStream: true,
471-
})
472-
).then(async (fallbackInitialRSCPayload) => {
473-
const fallbackResponse = await offlineNavigationFallbackResponse!
474-
if (fallbackResponse.requestKind === 'initial-load') {
475-
return fallbackInitialRSCPayload
476-
}
548+
} else if (offlineNavigationFallbackBootstrap) {
549+
initialServerResponse = offlineNavigationFallbackBootstrap.then(
550+
async (bootstrap) => {
551+
if (bootstrap.kind === 'cache-miss') {
552+
return await neverResolveInitialRSCPayload()
553+
}
477554

478-
return createInitialRSCPayloadFromFallbackPrerender(
479-
fallbackResponse.response,
480-
fallbackInitialRSCPayload
481-
)
482-
})
555+
if (bootstrap.kind === 'router-cache') {
556+
return bootstrap.initialRSCPayload
557+
}
558+
559+
const fallbackResponse = bootstrap.response
560+
const fallbackInitialRSCPayload =
561+
await createFromFetch<InitialRSCPayload>(
562+
Promise.resolve(fallbackResponse.response),
563+
{
564+
callServer,
565+
findSourceMapURL,
566+
debugChannel,
567+
unstable_allowPartialStream: true,
568+
}
569+
)
570+
571+
if (fallbackResponse.requestKind === 'initial-load') {
572+
return fallbackInitialRSCPayload
573+
}
574+
575+
return createInitialRSCPayloadFromFallbackPrerender(
576+
fallbackResponse.response,
577+
fallbackInitialRSCPayload
578+
)
579+
}
580+
)
483581
} else if (window.__NEXT_CLIENT_RESUME) {
484582
const clientResumeFetch: Promise<Response> = window.__NEXT_CLIENT_RESUME
485583
initialServerResponse = Promise.resolve(
@@ -600,7 +698,7 @@ export async function hydrate(
600698
if (process.env.__NEXT_USE_OFFLINE) {
601699
const { notifyOffline } =
602700
require('./components/offline') as typeof import('./components/offline')
603-
if (offlineNavigationClientResumeFetch) {
701+
if (offlineNavigationFallbackBootstrap) {
604702
notifyOffline()
605703
}
606704
}
@@ -625,25 +723,28 @@ export async function hydrate(
625723
}
626724

627725
if (process.env.__NEXT_OFFLINE_NAVIGATIONS) {
628-
if (!process.env.__NEXT_DEV_SERVER && offlineNavigationClientResumeFetch) {
726+
if (!process.env.__NEXT_DEV_SERVER && offlineNavigationFallbackBootstrap) {
629727
try {
630-
const { hydrateOfflineNavigationRouterCache } =
631-
require('./components/segment-cache/cache') as typeof import('./components/segment-cache/cache')
632-
const result = await hydrateOfflineNavigationRouterCache()
633-
reportOfflineNavigationFallbackDiagnostic({
634-
type: 'router-cache-hydration',
635-
url: location.href,
636-
buildId,
637-
routes: result.routes,
638-
segments: result.segments,
639-
})
728+
const bootstrap = await offlineNavigationFallbackBootstrap
729+
if (bootstrap.kind === 'rsc-response') {
730+
const { hydrateOfflineNavigationRouterCache } =
731+
require('./components/segment-cache/cache') as typeof import('./components/segment-cache/cache')
732+
const result = await hydrateOfflineNavigationRouterCache({ buildId })
733+
reportOfflineNavigationFallbackDiagnostic({
734+
type: 'router-cache-hydration',
735+
url: location.href,
736+
buildId,
737+
routes: result.routes,
738+
segments: result.segments,
739+
})
740+
}
640741
} catch {
641742
// The exact URL fallback already booted. Router cache hydration should
642743
// only improve later navigations, never block the current render.
643744
}
644745
}
645746
} else {
646-
// Keep router-cache hydration helpers out of disabled client bundles.
747+
// Keep router-cache reconstruction helpers out of disabled client bundles.
647748
}
648749

649750
const initialTimestamp = Date.now()

0 commit comments

Comments
 (0)