Skip to content

Commit bb5cc45

Browse files
committed
Persist offline navigation route data
1 parent f979b1a commit bb5cc45

8 files changed

Lines changed: 438 additions & 2 deletions

File tree

packages/next/src/client/components/router-reducer/fetch-server-response.ts

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import type {
1616
} from '../../../shared/lib/app-router-types'
1717

1818
import {
19-
type NEXT_ROUTER_PREFETCH_HEADER,
20-
type NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
19+
NEXT_ROUTER_PREFETCH_HEADER,
20+
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
2121
type NEXT_INSTANT_PREFETCH_HEADER,
2222
NEXT_ROUTER_STATE_TREE_HEADER,
2323
NEXT_RSC_UNION_QUERY,
@@ -45,8 +45,14 @@ import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants'
4545
import {
4646
stripIsPartialByte,
4747
createNonTaskyPrefetchResponseStream,
48+
getStaleAt,
4849
} from '../segment-cache/cache'
4950
import { UnknownDynamicStaleTime } from '../segment-cache/bfcache'
51+
import {
52+
createOfflineNavigationRSCResponsePayload,
53+
writeOfflineNavigationRSCResponseCacheEntry,
54+
type OfflineNavigationRSCResponsePayload,
55+
} from './offline-navigation-cache'
5056

5157
const createFromReadableStream =
5258
createFromReadableStreamBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromReadableStream']
@@ -275,6 +281,16 @@ export async function fetchServerResponse(
275281
return doMpaNavigation(normalizedFlightData)
276282
}
277283

284+
persistOfflineNavigationResponse({
285+
canonicalUrl,
286+
flightResponse,
287+
interception,
288+
isHmrRefresh: options.isHmrRefresh,
289+
originalUrl,
290+
postponed,
291+
response: res,
292+
})
293+
278294
const staticStageData =
279295
cacheData !== null
280296
? await resolveStaticStageData(cacheData, flightResponse, headers)
@@ -355,6 +371,7 @@ export type RSCResponse<T> = {
355371
url: string
356372
flightResponsePromise: (Promise<T> & { _debugInfo?: Array<any> }) | null
357373
cacheData: Promise<FetchResponseCacheData | null>
374+
offlineNavigationCachePayload: Promise<OfflineNavigationRSCResponsePayload | null> | null
358375
}
359376

360377
type FetchResponseCacheData = {
@@ -535,6 +552,8 @@ export async function createFetch<T>(
535552
let fetchUrl = new URL(url)
536553
await setCacheBustingSearchParam(fetchUrl, headers)
537554
let processed = fetch(fetchUrl, fetchOptions).then(processFetch)
555+
let offlineNavigationCachePayload =
556+
createOfflineNavigationCachePayloadFromProcessedResponse(processed, headers)
538557
let fetchPromise = processed.then(({ response }) => response)
539558

540559
// Immediately pass the fetch promise to the Flight client so that the debug
@@ -605,6 +624,11 @@ export async function createFetch<T>(
605624
fetchUrl = new URL(responseUrl)
606625
await setCacheBustingSearchParam(fetchUrl, headers)
607626
processed = fetch(fetchUrl, fetchOptions).then(processFetch)
627+
offlineNavigationCachePayload =
628+
createOfflineNavigationCachePayloadFromProcessedResponse(
629+
processed,
630+
headers
631+
)
608632
fetchPromise = processed.then(({ response }) => response)
609633
flightResponsePromise = shouldImmediatelyDecode
610634
? createFromNextFetch<T>(fetchPromise, headers)
@@ -643,11 +667,120 @@ export async function createFetch<T>(
643667
flightResponsePromise: flightResponsePromise,
644668

645669
cacheData: processed.then(({ cacheData }) => cacheData),
670+
671+
offlineNavigationCachePayload,
646672
}
647673

648674
return rscResponse
649675
}
650676

677+
function getOfflineNavigationCacheRequestKind(
678+
headers: RequestHeaders
679+
): 'navigation' | 'route-prefetch' | 'client-resume' | null {
680+
if (
681+
!process.env.__NEXT_OFFLINE_NAVIGATIONS ||
682+
process.env.__NEXT_DEV_SERVER ||
683+
process.env.NODE_ENV !== 'production' ||
684+
process.env.__NEXT_CONFIG_OUTPUT === 'export' ||
685+
headers[NEXT_HMR_REFRESH_HEADER]
686+
) {
687+
return null
688+
}
689+
690+
if (
691+
headers[NEXT_ROUTER_PREFETCH_HEADER] !== undefined &&
692+
headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] === '/_full'
693+
) {
694+
return 'client-resume'
695+
}
696+
697+
if (
698+
headers[NEXT_ROUTER_PREFETCH_HEADER] !== undefined &&
699+
headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] === undefined
700+
) {
701+
return 'route-prefetch'
702+
}
703+
704+
if (
705+
headers[NEXT_ROUTER_STATE_TREE_HEADER] !== undefined &&
706+
headers[NEXT_ROUTER_PREFETCH_HEADER] === undefined &&
707+
headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] === undefined
708+
) {
709+
return 'navigation'
710+
}
711+
712+
return null
713+
}
714+
715+
function createOfflineNavigationCachePayloadFromProcessedResponse(
716+
processed: Promise<{
717+
response: Response
718+
cacheData: FetchResponseCacheData | null
719+
}>,
720+
headers: RequestHeaders
721+
): Promise<OfflineNavigationRSCResponsePayload | null> | null {
722+
const requestKind = getOfflineNavigationCacheRequestKind(headers)
723+
if (requestKind === null) {
724+
return null
725+
}
726+
727+
return processed
728+
.then(({ response }) => {
729+
if (!response.ok || !response.body) {
730+
return null
731+
}
732+
733+
return createOfflineNavigationRSCResponsePayload(response, requestKind)
734+
})
735+
.catch(() => null)
736+
}
737+
738+
function persistOfflineNavigationResponse({
739+
canonicalUrl,
740+
flightResponse,
741+
interception,
742+
isHmrRefresh,
743+
originalUrl,
744+
postponed,
745+
response,
746+
}: {
747+
canonicalUrl: URL
748+
flightResponse: NavigationFlightResponse
749+
interception: boolean
750+
isHmrRefresh: boolean | undefined
751+
originalUrl: URL
752+
postponed: boolean
753+
response: RSCResponse<NavigationFlightResponse>
754+
}): void {
755+
if (
756+
response.offlineNavigationCachePayload === null ||
757+
!flightResponse.S ||
758+
flightResponse.d !== undefined ||
759+
flightResponse.p !== undefined ||
760+
interception ||
761+
isHmrRefresh ||
762+
postponed ||
763+
response.redirected ||
764+
canonicalUrl.origin !== location.origin
765+
) {
766+
return
767+
}
768+
769+
void (async () => {
770+
const now = Date.now()
771+
const staleAt = await getStaleAt(now, flightResponse.s, response)
772+
await writeOfflineNavigationRSCResponseCacheEntry({
773+
buildId:
774+
response.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? flightResponse.b,
775+
expiresAt: staleAt,
776+
payload: response.offlineNavigationCachePayload!,
777+
staleAt,
778+
url: originalUrl,
779+
now,
780+
})
781+
})()
782+
}
783+
651784
export function createFromNextReadableStream<T>(
652785
flightStream: ReadableStream<Uint8Array>,
653786
requestHeaders: RequestHeaders | undefined,

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
22
createOfflineNavigationCache,
3+
createOfflineNavigationRSCResponse,
4+
createOfflineNavigationRSCResponsePayload,
35
deleteOfflineNavigationCacheEntry,
46
normalizeOfflineNavigationCacheUrl,
57
readOfflineNavigationCacheEntry,
@@ -101,6 +103,81 @@ describe('offline navigation cache', () => {
101103
})
102104
})
103105

106+
it('serializes RSC response payloads for persisted navigation entries', async () => {
107+
const response = new Response('0:["$","payload"]', {
108+
headers: {
109+
'content-type': 'text/x-component',
110+
'x-nextjs-stale-time': '60',
111+
},
112+
status: 200,
113+
statusText: 'OK',
114+
})
115+
Object.defineProperty(response, 'url', {
116+
value: 'https://example.com/dashboard?_rsc=abc',
117+
})
118+
119+
const payload = await createOfflineNavigationRSCResponsePayload(
120+
response,
121+
'route-prefetch'
122+
)
123+
expect(payload).toMatchObject({
124+
version: 1,
125+
kind: 'rsc-response',
126+
requestKind: 'route-prefetch',
127+
status: 200,
128+
statusText: 'OK',
129+
headers: expect.arrayContaining([
130+
['content-type', 'text/x-component'],
131+
['x-nextjs-stale-time', '60'],
132+
]),
133+
})
134+
135+
await expect(
136+
createOfflineNavigationRSCResponse(payload).text()
137+
).resolves.toBe('0:["$","payload"]')
138+
})
139+
140+
it('writes RSC response payloads through the exact URL cache', async () => {
141+
const storage = new MemoryOfflineNavigationCacheStorage()
142+
const cache = createOfflineNavigationCache(storage)
143+
const payload = createOfflineNavigationRSCResponsePayload(
144+
new Response('0:["$","payload"]'),
145+
'navigation'
146+
)
147+
148+
await expect(payload).resolves.toMatchObject({
149+
kind: 'rsc-response',
150+
requestKind: 'navigation',
151+
})
152+
await expect(
153+
cache.write({
154+
buildId: 'build-a',
155+
expiresAt: 300,
156+
payload: await payload,
157+
staleAt: 200,
158+
url: 'https://example.com/dashboard#section',
159+
now: 100,
160+
})
161+
).resolves.toBe(true)
162+
163+
await expect(
164+
cache.read('https://example.com/dashboard', {
165+
buildId: 'build-a',
166+
now: 150,
167+
})
168+
).resolves.toMatchObject({
169+
buildId: 'build-a',
170+
createdAt: 100,
171+
payload: {
172+
kind: 'rsc-response',
173+
requestKind: 'navigation',
174+
},
175+
staleAt: 200,
176+
expiresAt: 300,
177+
url: 'https://example.com/dashboard',
178+
})
179+
})
180+
104181
it('deletes exact URL entries', async () => {
105182
const storage = new MemoryOfflineNavigationCacheStorage()
106183
const cache = createOfflineNavigationCache(storage)

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ const DATABASE_NAME = 'next-offline-navigation-cache'
44
const DATABASE_VERSION = 1
55
const STORE_NAME = 'navigation-data'
66
const ENTRY_VERSION = 1
7+
const RSC_RESPONSE_PAYLOAD_VERSION = 1
78

89
type OfflineNavigationCacheKey = [buildId: string, url: string]
10+
type OfflineNavigationRSCResponseRequestKind =
11+
| 'navigation'
12+
| 'route-prefetch'
13+
| 'client-resume'
914

1015
export type OfflineNavigationCacheEntry = {
1116
version: typeof ENTRY_VERSION
@@ -18,6 +23,17 @@ export type OfflineNavigationCacheEntry = {
1823
payload: unknown
1924
}
2025

26+
export type OfflineNavigationRSCResponsePayload = {
27+
version: typeof RSC_RESPONSE_PAYLOAD_VERSION
28+
kind: 'rsc-response'
29+
requestKind: OfflineNavigationRSCResponseRequestKind
30+
url: string
31+
status: number
32+
statusText: string
33+
headers: Array<[string, string]>
34+
body: ArrayBuffer
35+
}
36+
2137
export type OfflineNavigationCacheWrite = {
2238
url: string | URL
2339
staleAt: number
@@ -32,6 +48,15 @@ export type OfflineNavigationCacheReadOptions = {
3248
now?: number
3349
}
3450

51+
export type OfflineNavigationRSCResponseCacheWrite = {
52+
url: string | URL
53+
staleAt: number
54+
expiresAt: number
55+
buildId?: string
56+
now?: number
57+
payload: Promise<OfflineNavigationRSCResponsePayload | null>
58+
}
59+
3560
export type OfflineNavigationCacheStorage = {
3661
get(
3762
key: OfflineNavigationCacheKey
@@ -148,6 +173,48 @@ export function createOfflineNavigationCache(
148173
}
149174
}
150175

176+
export async function createOfflineNavigationRSCResponsePayload(
177+
response: Response,
178+
requestKind: OfflineNavigationRSCResponseRequestKind
179+
): Promise<OfflineNavigationRSCResponsePayload> {
180+
const clone = response.clone()
181+
return {
182+
version: RSC_RESPONSE_PAYLOAD_VERSION,
183+
kind: 'rsc-response',
184+
requestKind,
185+
url: clone.url,
186+
status: clone.status,
187+
statusText: clone.statusText,
188+
headers: Array.from(clone.headers.entries()),
189+
body: await clone.arrayBuffer(),
190+
}
191+
}
192+
193+
export function createOfflineNavigationRSCResponse(
194+
payload: OfflineNavigationRSCResponsePayload
195+
): Response {
196+
return new Response(payload.body.slice(0), {
197+
status: payload.status,
198+
statusText: payload.statusText,
199+
headers: payload.headers,
200+
})
201+
}
202+
203+
export async function writeOfflineNavigationRSCResponseCacheEntry({
204+
payload,
205+
...entry
206+
}: OfflineNavigationRSCResponseCacheWrite): Promise<boolean> {
207+
const resolvedPayload = await payload
208+
if (resolvedPayload === null) {
209+
return false
210+
}
211+
212+
return writeOfflineNavigationCacheEntry({
213+
...entry,
214+
payload: resolvedPayload,
215+
})
216+
}
217+
151218
function getCacheBuildId(buildId: string | undefined): string | null {
152219
const cacheBuildId = buildId ?? getNavigationBuildId()
153220
return cacheBuildId === '' ? null : cacheBuildId

0 commit comments

Comments
 (0)