Skip to content

Commit fff03ee

Browse files
committed
offline navigations: replay exact-url navigations (7/13)
1 parent ca1a12e commit fff03ee

13 files changed

Lines changed: 877 additions & 39 deletions

File tree

packages/next/src/build/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4112,6 +4112,7 @@ export default async function build(
41124112
buildId,
41134113
buildManifest,
41144114
crossOrigin: config.crossOrigin,
4115+
deploymentId: config.deploymentId,
41154116
})
41164117

41174118
if (fallbackDocument === null) {

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@ export function createOfflineNavigationFallbackDocument({
3838
buildId,
3939
buildManifest,
4040
crossOrigin,
41+
deploymentId,
4142
}: {
4243
assetPrefix: string
4344
buildId: string
4445
buildManifest: BuildManifest
4546
crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined
47+
deploymentId: string | undefined
4648
}): string | null {
4749
const rootMainFiles = buildManifest.rootMainFiles.filter((file) =>
4850
file.endsWith('.js')
@@ -74,11 +76,15 @@ export function createOfflineNavigationFallbackDocument({
7476
source: 'offline-navigation-fallback',
7577
}
7678

79+
const deploymentIdAttribute = deploymentId
80+
? ` data-dpl-id="${htmlEscapeAttributeString(deploymentId)}"`
81+
: ''
82+
7783
return `<!DOCTYPE html><html data-next-offline-navigation-fallback="" data-build-id="${htmlEscapeAttributeString(
7884
buildId
79-
)}"><head><meta charSet="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="next-offline-navigation-fallback" content="1"><meta name="next-build-id" content="${htmlEscapeAttributeString(
85+
)}"${deploymentIdAttribute}><head><meta charSet="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="next-offline-navigation-fallback" content="1"><meta name="next-build-id" content="${htmlEscapeAttributeString(
8086
buildId
8187
)}"><script id="__NEXT_OFFLINE_NAVIGATION_FALLBACK" type="application/json">${htmlEscapeJsonString(
8288
JSON.stringify(metadata)
83-
)}</script></head><body><div id="__next"></div><script>self.__next_f=self.__next_f||[];self.__next_f.push([0])</script>${polyfillScripts}${bootstrapScripts}</body></html>`
89+
)}</script></head><body><div id="__next"></div><p id="__NEXT_OFFLINE_NAVIGATION_CACHE_MISS" hidden>This page is not available offline.</p><script>self.__next_f=self.__next_f||[];self.__next_f.push([0])</script>${polyfillScripts}${bootstrapScripts}</body></html>`
8490
}

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

Lines changed: 148 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,99 @@ const instantTestStaticFetch: Promise<Response> | undefined =
4545
? (self.__next_instant_test as unknown as Promise<Response>)
4646
: undefined
4747

48+
function isOfflineNavigationFallbackDocument(): boolean {
49+
return Boolean(
50+
process.env.__NEXT_OFFLINE_NAVIGATIONS &&
51+
!process.env.__NEXT_DEV_SERVER &&
52+
document.documentElement.hasAttribute(
53+
'data-next-offline-navigation-fallback'
54+
)
55+
)
56+
}
57+
58+
function showOfflineNavigationCacheMiss(): void {
59+
const cacheMissElement = document.getElementById(
60+
'__NEXT_OFFLINE_NAVIGATION_CACHE_MISS'
61+
)
62+
if (cacheMissElement !== null) {
63+
cacheMissElement.hidden = false
64+
}
65+
}
66+
67+
function neverResolveOfflineNavigationResponse(): Promise<Response> {
68+
return new Promise<Response>(() => {})
69+
}
70+
71+
type OfflineNavigationFallbackResponse = {
72+
requestKind: 'client-resume' | 'initial-load'
73+
response: Response
74+
}
75+
76+
function createOfflineNavigationFallbackResponse():
77+
| Promise<OfflineNavigationFallbackResponse>
78+
| undefined {
79+
if (!isOfflineNavigationFallbackDocument()) {
80+
return undefined
81+
}
82+
83+
return (async (): Promise<OfflineNavigationFallbackResponse> => {
84+
const {
85+
createOfflineNavigationRSCResponse,
86+
isOfflineNavigationRSCResponsePayload,
87+
readOfflineNavigationCacheEntry,
88+
} =
89+
require('./components/router-reducer/offline-navigation-cache') as typeof import('./components/router-reducer/offline-navigation-cache')
90+
91+
const buildId =
92+
getDeploymentId() ??
93+
document.documentElement.getAttribute('data-build-id') ??
94+
undefined
95+
const entry = await readOfflineNavigationCacheEntry(location.href, {
96+
buildId,
97+
})
98+
const payload = entry?.payload
99+
100+
if (
101+
!isOfflineNavigationRSCResponsePayload(payload) ||
102+
(payload.requestKind !== 'client-resume' &&
103+
payload.requestKind !== 'initial-load')
104+
) {
105+
showOfflineNavigationCacheMiss()
106+
return {
107+
requestKind: 'client-resume',
108+
response: await neverResolveOfflineNavigationResponse(),
109+
}
110+
}
111+
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> => {
120+
showOfflineNavigationCacheMiss()
121+
return {
122+
requestKind: 'client-resume',
123+
response: await neverResolveOfflineNavigationResponse(),
124+
}
125+
})
126+
}
127+
128+
const offlineNavigationFallbackResponse =
129+
createOfflineNavigationFallbackResponse()
130+
const offlineNavigationClientResumeFetch =
131+
offlineNavigationFallbackResponse?.then(({ response }) => response)
132+
133+
const hasClientResumeShell =
134+
// @ts-expect-error
135+
Boolean(window.__NEXT_CLIENT_RESUME)
136+
const hasLockedStaticShell =
137+
Boolean(instantTestStaticFetch) ||
138+
Boolean(offlineNavigationClientResumeFetch) ||
139+
hasClientResumeShell
140+
48141
const encoder = new TextEncoder()
49142

50143
let initialServerDataBuffer: (string | Uint8Array)[] | undefined = undefined
@@ -128,14 +221,11 @@ function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) {
128221
ctr.enqueue(typeof val === 'string' ? encoder.encode(val) : val)
129222
})
130223
if (initialServerDataLoaded && !initialServerDataFlushed) {
131-
// Instant Navigation Testing API: don't close or error the inline
132-
// Flight stream. The static shell has no inline Flight data, so the
133-
// stream is empty. Closing it would cause React to log an error about
134-
// missing data. Leaving it open lets React treat any holes as
135-
// "still suspended." Hydration uses the separately fetched RSC payload
136-
// (self.__next_instant_test), not this stream.
224+
// Locked static shells do not have a real inline Flight stream. Closing
225+
// or erroring this stream causes React to report a missing-data failure,
226+
// but the actual hydration data arrives through a separate response.
137227
if (isStreamErrorOrUnfinished(ctr)) {
138-
if (!instantTestStaticFetch) {
228+
if (!hasLockedStaticShell) {
139229
ctr.error(
140230
new Error(
141231
'The connection to the page was unexpectedly closed, possibly due to the stop button being clicked, loss of Wi-Fi, or an unstable internet connection.'
@@ -155,7 +245,11 @@ function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) {
155245

156246
// When `DOMContentLoaded`, we can close all pending writers to finish hydration.
157247
const DOMContentLoaded = function () {
158-
if (initialServerDataWriter && !initialServerDataFlushed) {
248+
if (
249+
initialServerDataWriter &&
250+
!initialServerDataFlushed &&
251+
!hasLockedStaticShell
252+
) {
159253
initialServerDataWriter.close()
160254
initialServerDataFlushed = true
161255
initialServerDataBuffer = undefined
@@ -196,9 +290,23 @@ if (process.env.NODE_ENV !== 'production') {
196290
// know if `l` is present until React decodes the payload, so always tee and
197291
// cancel the clone if not needed.
198292
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+
}
199306
if (
200307
process.env.__NEXT_CACHE_COMPONENTS &&
201-
process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS
308+
process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS &&
309+
!offlineNavigationClientResumeFetch
202310
) {
203311
const [forReact, forCache] = readable.tee()
204312
readable = forReact
@@ -242,6 +350,25 @@ if (instantTestStaticFetch) {
242350
initialRSCPayload
243351
)
244352
})
353+
} else if (offlineNavigationClientResumeFetch) {
354+
initialServerResponse = Promise.resolve(
355+
createFromFetch<InitialRSCPayload>(offlineNavigationClientResumeFetch, {
356+
callServer,
357+
findSourceMapURL,
358+
debugChannel,
359+
unstable_allowPartialStream: true,
360+
})
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,
369+
fallbackInitialRSCPayload
370+
)
371+
})
245372
} else if (
246373
// @ts-expect-error
247374
window.__NEXT_CLIENT_RESUME
@@ -365,7 +492,11 @@ export async function hydrate(
365492
// Initialize the offline module to register browser event listeners
366493
// (offline/online) before any components hydrate.
367494
if (process.env.__NEXT_USE_OFFLINE) {
368-
require('./components/offline') as typeof import('./components/offline')
495+
const { notifyOffline } =
496+
require('./components/offline') as typeof import('./components/offline')
497+
if (offlineNavigationClientResumeFetch) {
498+
notifyOffline()
499+
}
369500
}
370501

371502
// setNavigationBuildId should be called only once, during JS initialization
@@ -391,6 +522,7 @@ export async function hydrate(
391522
navigatedAt: initialTimestamp,
392523
initialRSCPayload,
393524
initialFlightStreamForCache,
525+
initialFlightStreamForOfflineNavigationCache,
394526
location: window.location,
395527
}),
396528
instrumentationHooks
@@ -411,9 +543,13 @@ export async function hydrate(
411543
</StrictModeIfEnabled>
412544
)
413545

414-
if (document.documentElement.id === '__next_error__') {
546+
if (
547+
document.documentElement.id === '__next_error__' ||
548+
isOfflineNavigationFallbackDocument()
549+
) {
415550
let element = reactEl
416-
// Server rendering failed, fall back to client-side rendering
551+
// Error documents and generated offline navigation fallback documents do
552+
// not contain route HTML that can be hydrated.
417553
if (process.env.NODE_ENV !== 'production') {
418554
const { RootLevelDevOverlayElement } =
419555
require('../next-devtools/userspace/app/client-entry') as typeof import('../next-devtools/userspace/app/client-entry')

0 commit comments

Comments
 (0)