Skip to content

Commit 510565d

Browse files
committed
offline navigations: persist initial-load navigation data (11/25)
1 parent 00af9b0 commit 510565d

5 files changed

Lines changed: 283 additions & 78 deletions

File tree

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

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,19 @@ function neverResolveOfflineNavigationResponse(): Promise<Response> {
6868
return new Promise<Response>(() => {})
6969
}
7070

71-
function createOfflineNavigationClientResumeFetch():
72-
| Promise<Response>
71+
type OfflineNavigationFallbackResponse = {
72+
requestKind: 'client-resume' | 'initial-load'
73+
response: Response
74+
}
75+
76+
function createOfflineNavigationFallbackResponse():
77+
| Promise<OfflineNavigationFallbackResponse>
7378
| undefined {
7479
if (!isOfflineNavigationFallbackDocument()) {
7580
return undefined
7681
}
7782

78-
return (async () => {
83+
return (async (): Promise<OfflineNavigationFallbackResponse> => {
7984
const {
8085
createOfflineNavigationRSCResponse,
8186
isOfflineNavigationRSCResponsePayload,
@@ -94,27 +99,44 @@ function createOfflineNavigationClientResumeFetch():
9499

95100
if (
96101
!isOfflineNavigationRSCResponsePayload(payload) ||
97-
payload.requestKind !== 'client-resume'
102+
(payload.requestKind !== 'client-resume' &&
103+
payload.requestKind !== 'initial-load')
98104
) {
99105
showOfflineNavigationCacheMiss()
100-
return neverResolveOfflineNavigationResponse()
106+
return {
107+
requestKind: 'client-resume',
108+
response: await neverResolveOfflineNavigationResponse(),
109+
}
101110
}
102111

103-
return createOfflineNavigationRSCResponse(payload)
104-
})().catch(() => {
112+
const requestKind: OfflineNavigationFallbackResponse['requestKind'] =
113+
payload.requestKind === 'initial-load' ? 'initial-load' : 'client-resume'
114+
115+
return {
116+
requestKind,
117+
response: createOfflineNavigationRSCResponse(payload),
118+
}
119+
})().catch(async (): Promise<OfflineNavigationFallbackResponse> => {
105120
showOfflineNavigationCacheMiss()
106-
return neverResolveOfflineNavigationResponse()
121+
return {
122+
requestKind: 'client-resume',
123+
response: await neverResolveOfflineNavigationResponse(),
124+
}
107125
})
108126
}
109127

128+
const offlineNavigationFallbackResponse =
129+
createOfflineNavigationFallbackResponse()
110130
const offlineNavigationClientResumeFetch =
111-
createOfflineNavigationClientResumeFetch()
131+
offlineNavigationFallbackResponse?.then(({ response }) => response)
112132

133+
const hasClientResumeShell =
134+
// @ts-expect-error
135+
Boolean(window.__NEXT_CLIENT_RESUME)
113136
const hasLockedStaticShell =
114137
Boolean(instantTestStaticFetch) ||
115138
Boolean(offlineNavigationClientResumeFetch) ||
116-
// @ts-expect-error
117-
Boolean(window.__NEXT_CLIENT_RESUME)
139+
hasClientResumeShell
118140

119141
const encoder = new TextEncoder()
120142

@@ -268,6 +290,19 @@ if (process.env.NODE_ENV !== 'production') {
268290
// know if `l` is present until React decodes the payload, so always tee and
269291
// cancel the clone if not needed.
270292
let initialFlightStreamForCache: ReadableStream<Uint8Array> | null = null
293+
let initialFlightStreamForOfflineNavigationCache: ReadableStream<Uint8Array> | null =
294+
null
295+
if (
296+
process.env.__NEXT_OFFLINE_NAVIGATIONS &&
297+
process.env.NODE_ENV === 'production' &&
298+
process.env.__NEXT_CONFIG_OUTPUT !== 'export' &&
299+
!process.env.__NEXT_DEV_SERVER &&
300+
!hasLockedStaticShell
301+
) {
302+
const [forApp, forOfflineNavigationCache] = readable.tee()
303+
readable = forApp
304+
initialFlightStreamForOfflineNavigationCache = forOfflineNavigationCache
305+
}
271306
if (
272307
process.env.__NEXT_CACHE_COMPONENTS &&
273308
process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS &&
@@ -323,12 +358,17 @@ if (instantTestStaticFetch) {
323358
debugChannel,
324359
unstable_allowPartialStream: true,
325360
})
326-
).then(async (fallbackInitialRSCPayload) =>
327-
createInitialRSCPayloadFromFallbackPrerender(
328-
await offlineNavigationClientResumeFetch,
361+
).then(async (fallbackInitialRSCPayload) => {
362+
const fallbackResponse = await offlineNavigationFallbackResponse!
363+
if (fallbackResponse.requestKind === 'initial-load') {
364+
return fallbackInitialRSCPayload
365+
}
366+
367+
return createInitialRSCPayloadFromFallbackPrerender(
368+
fallbackResponse.response,
329369
fallbackInitialRSCPayload
330370
)
331-
)
371+
})
332372
} else if (
333373
// @ts-expect-error
334374
window.__NEXT_CLIENT_RESUME
@@ -482,6 +522,7 @@ export async function hydrate(
482522
navigatedAt: initialTimestamp,
483523
initialRSCPayload,
484524
initialFlightStreamForCache,
525+
initialFlightStreamForOfflineNavigationCache,
485526
location: window.location,
486527
}),
487528
instrumentationHooks

packages/next/src/client/components/router-reducer/create-initial-router-state.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,21 @@ import {
2121
import { decodeStaticStage } from './fetch-server-response'
2222
import { discoverKnownRoute } from '../segment-cache/optimistic-routes'
2323
import type { NormalizedSearch } from '../segment-cache/cache-key'
24+
import { RSC_CONTENT_TYPE_HEADER } from '../app-router-headers'
2425

2526
export interface InitialRouterStateParameters {
2627
navigatedAt: number
2728
initialRSCPayload: InitialRSCPayload
2829
initialFlightStreamForCache?: ReadableStream<Uint8Array> | null
30+
initialFlightStreamForOfflineNavigationCache?: ReadableStream<Uint8Array> | null
2931
location: Location | null
3032
}
3133

3234
export function createInitialRouterState({
3335
navigatedAt,
3436
initialRSCPayload,
3537
initialFlightStreamForCache,
38+
initialFlightStreamForOfflineNavigationCache,
3639
location,
3740
}: InitialRouterStateParameters): AppRouterState {
3841
const {
@@ -88,6 +91,12 @@ export function createInitialRouterState({
8891
acc
8992
)
9093
const metadataVaryPath = acc.metadataVaryPath
94+
const initialStaleAt =
95+
location === null ||
96+
initialSeedData === null ||
97+
initialStaticStageByteLength !== undefined
98+
? null
99+
: getStaleAt(navigatedAt, initialStaleTime)
91100
const initialTask = createInitialCacheNodeForHydration(
92101
navigatedAt,
93102
initialRouteTree,
@@ -157,7 +166,7 @@ export function createInitialRouterState({
157166
// hydration and write it into the cache directly.
158167
const now = Date.now()
159168

160-
getStaleAt(now, initialStaleTime)
169+
initialStaleAt!
161170
.then((staleAt) => {
162171
writeStaticStageResponseIntoCache(
163172
now,
@@ -216,6 +225,18 @@ export function createInitialRouterState({
216225
}
217226
}
218227

228+
persistInitialOfflineNavigationResponse({
229+
initialCouldBeIntercepted,
230+
initialDynamicStaleTimeSeconds,
231+
initialFlightStreamForOfflineNavigationCache,
232+
initialRuntimePrefetchStream,
233+
initialStaleAt,
234+
initialStaticStageByteLength,
235+
initialSupportsPerSegmentPrefetching,
236+
location,
237+
navigatedAt,
238+
})
239+
219240
// NOTE: We intentionally don't check if any data needs to be fetched from the
220241
// server. We assume the initial hydration payload is sufficient to render
221242
// the page.
@@ -259,3 +280,82 @@ export function createInitialRouterState({
259280

260281
return initialState
261282
}
283+
284+
function persistInitialOfflineNavigationResponse({
285+
initialCouldBeIntercepted,
286+
initialDynamicStaleTimeSeconds,
287+
initialFlightStreamForOfflineNavigationCache,
288+
initialRuntimePrefetchStream,
289+
initialStaleAt,
290+
initialStaticStageByteLength,
291+
initialSupportsPerSegmentPrefetching,
292+
location,
293+
navigatedAt,
294+
}: {
295+
initialCouldBeIntercepted: boolean
296+
initialDynamicStaleTimeSeconds: number | undefined
297+
initialFlightStreamForOfflineNavigationCache:
298+
| ReadableStream<Uint8Array>
299+
| null
300+
| undefined
301+
initialRuntimePrefetchStream: ReadableStream<Uint8Array> | undefined
302+
initialStaleAt: Promise<number> | null
303+
initialStaticStageByteLength: Promise<number> | undefined
304+
initialSupportsPerSegmentPrefetching: boolean
305+
location: Location | null
306+
navigatedAt: number
307+
}): void {
308+
if (initialFlightStreamForOfflineNavigationCache == null) {
309+
return
310+
}
311+
312+
if (
313+
!process.env.__NEXT_OFFLINE_NAVIGATIONS ||
314+
process.env.__NEXT_DEV_SERVER ||
315+
process.env.NODE_ENV !== 'production' ||
316+
process.env.__NEXT_CONFIG_OUTPUT === 'export' ||
317+
location === null ||
318+
!initialSupportsPerSegmentPrefetching ||
319+
initialCouldBeIntercepted ||
320+
initialDynamicStaleTimeSeconds !== undefined ||
321+
initialRuntimePrefetchStream !== undefined ||
322+
initialStaleAt === null ||
323+
initialStaticStageByteLength !== undefined
324+
) {
325+
initialFlightStreamForOfflineNavigationCache.cancel()
326+
return
327+
}
328+
329+
const response = new Response(initialFlightStreamForOfflineNavigationCache, {
330+
headers: { 'content-type': RSC_CONTENT_TYPE_HEADER },
331+
status: 200,
332+
statusText: 'OK',
333+
})
334+
Object.defineProperty(response, 'url', {
335+
value: createHrefFromUrl(location),
336+
})
337+
338+
const {
339+
createOfflineNavigationRSCResponsePayload,
340+
writeOfflineNavigationRSCResponseCacheEntry,
341+
} =
342+
require('./offline-navigation-cache') as typeof import('./offline-navigation-cache')
343+
344+
const payload = createOfflineNavigationRSCResponsePayload(
345+
response,
346+
'initial-load'
347+
).catch(() => null)
348+
349+
void (async () => {
350+
const staleAt = await initialStaleAt
351+
await writeOfflineNavigationRSCResponseCacheEntry({
352+
expiresAt: staleAt,
353+
payload,
354+
staleAt,
355+
url: createHrefFromUrl(location),
356+
now: navigatedAt,
357+
})
358+
})().catch(() => {
359+
// The page already rendered. Offline persistence must not affect boot.
360+
})
361+
}

packages/next/src/client/components/router-reducer/offline-navigation-cache.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,19 @@ describe('offline navigation cache', () => {
203203
})
204204
})
205205

206+
it('accepts initial load RSC responses for offline document boot', async () => {
207+
const payload = await createOfflineNavigationRSCResponsePayload(
208+
new Response('0:["$","payload"]'),
209+
'initial-load'
210+
)
211+
212+
expect(isOfflineNavigationRSCResponsePayload(payload)).toBe(true)
213+
expect(payload).toMatchObject({
214+
kind: 'rsc-response',
215+
requestKind: 'initial-load',
216+
})
217+
})
218+
206219
it('deletes exact URL entries', async () => {
207220
const storage = new MemoryOfflineNavigationCacheStorage()
208221
const cache = createOfflineNavigationCache(storage)

packages/next/src/client/components/router-reducer/offline-navigation-cache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type OfflineNavigationRSCResponseRequestKind =
1212
| 'navigation'
1313
| 'route-prefetch'
1414
| 'client-resume'
15+
| 'initial-load'
1516

1617
export type OfflineNavigationCacheEntry = {
1718
version: typeof ENTRY_VERSION
@@ -217,7 +218,8 @@ export function isOfflineNavigationRSCResponsePayload(
217218
candidate.kind === 'rsc-response' &&
218219
(candidate.requestKind === 'navigation' ||
219220
candidate.requestKind === 'route-prefetch' ||
220-
candidate.requestKind === 'client-resume') &&
221+
candidate.requestKind === 'client-resume' ||
222+
candidate.requestKind === 'initial-load') &&
221223
typeof candidate.url === 'string' &&
222224
typeof candidate.status === 'number' &&
223225
typeof candidate.statusText === 'string' &&

0 commit comments

Comments
 (0)