Skip to content

Commit 00b5b5b

Browse files
committed
offline navigations: bootstrap fallback from router records (8/10)
1 parent f71c4f2 commit 00b5b5b

17 files changed

Lines changed: 1625 additions & 60 deletions

File tree

packages/next/src/build/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4110,6 +4110,7 @@ export default async function build(
41104110
buildId,
41114111
buildManifest,
41124112
crossOrigin: config.crossOrigin,
4113+
deploymentId: config.deploymentId,
41134114
})
41144115

41154116
if (fallbackDocument === null) {

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,21 @@ export interface OfflineNavigationFallbackDocument {
5151
}
5252

5353
// Generate the build-scoped HTML entrypoint used by offline document fallback
54-
// handling. It intentionally contains only the app bootstrap, not route HTML,
55-
// so the artifact stays request-invariant.
54+
// handling. It intentionally contains only the app bootstrap, not route HTML;
55+
// route data is restored by the client from persisted router-cache records
56+
// after this document loads.
5657
export function createOfflineNavigationFallbackDocument({
5758
assetPrefix,
5859
buildId,
5960
buildManifest,
6061
crossOrigin,
62+
deploymentId,
6163
}: {
6264
assetPrefix: string
6365
buildId: string
6466
buildManifest: BuildManifest
6567
crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined
68+
deploymentId: string | undefined
6669
}): OfflineNavigationFallbackDocument | null {
6770
const rootMainFiles = buildManifest.rootMainFiles.filter((file) =>
6871
file.endsWith('.js')
@@ -101,14 +104,18 @@ export function createOfflineNavigationFallbackDocument({
101104
source: 'offline-navigation-fallback',
102105
}
103106

107+
const deploymentIdAttribute = deploymentId
108+
? ` data-dpl-id="${htmlEscapeAttributeString(deploymentId)}"`
109+
: ''
110+
104111
return {
105112
assetHrefs: fallbackAssetHrefs,
106113
html: `<!DOCTYPE html><html data-next-offline-navigation-fallback="" data-build-id="${htmlEscapeAttributeString(
107114
buildId
108-
)}"><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(
115+
)}"${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(
109116
buildId
110117
)}"><script id="__NEXT_OFFLINE_NAVIGATION_FALLBACK" type="application/json">${htmlEscapeJsonString(
111118
JSON.stringify(metadata)
112-
)}</script></head><body><div id="__next"></div><script>self.__next_f=self.__next_f||[];self.__next_f.push([0])</script>${polyfillScripts}${bootstrapScripts}</body></html>`,
119+
)}</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>`,
113120
}
114121
}

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

Lines changed: 187 additions & 20 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'
@@ -45,6 +46,156 @@ const instantTestStaticFetch: Promise<Response> | undefined =
4546
? (self.__next_instant_test as unknown as Promise<Response>)
4647
: undefined
4748

49+
function isOfflineNavigationFallbackDocument(): boolean {
50+
return Boolean(
51+
process.env.__NEXT_OFFLINE_NAVIGATIONS &&
52+
!process.env.__NEXT_DEV_SERVER &&
53+
document.documentElement.hasAttribute(
54+
'data-next-offline-navigation-fallback'
55+
)
56+
)
57+
}
58+
59+
type OfflineNavigationCacheMissReason =
60+
| 'missing-route'
61+
| 'unsupported-route'
62+
| 'missing-segment'
63+
| 'missing-head'
64+
| 'read-error'
65+
66+
// Private test markers for the offline navigation e2e suite. These are only
67+
// emitted when the production testing API is explicitly enabled.
68+
function showOfflineNavigationCacheHit(): void {
69+
if (process.env.__NEXT_EXPOSE_TESTING_API) {
70+
document.documentElement.setAttribute(
71+
'data-next-offline-navigation-cache',
72+
'hit'
73+
)
74+
document.documentElement.removeAttribute(
75+
'data-next-offline-navigation-cache-reason'
76+
)
77+
}
78+
}
79+
80+
function showOfflineNavigationCacheMiss(
81+
reason: OfflineNavigationCacheMissReason
82+
): void {
83+
if (process.env.__NEXT_EXPOSE_TESTING_API) {
84+
document.documentElement.setAttribute(
85+
'data-next-offline-navigation-cache',
86+
'miss'
87+
)
88+
document.documentElement.setAttribute(
89+
'data-next-offline-navigation-cache-reason',
90+
reason
91+
)
92+
}
93+
const cacheMissElement = document.getElementById(
94+
'__NEXT_OFFLINE_NAVIGATION_CACHE_MISS'
95+
)
96+
if (cacheMissElement !== null) {
97+
cacheMissElement.hidden = false
98+
if (process.env.__NEXT_EXPOSE_TESTING_API) {
99+
cacheMissElement.setAttribute(
100+
'data-next-offline-navigation-cache-reason',
101+
reason
102+
)
103+
}
104+
}
105+
}
106+
107+
function neverResolveInitialRSCPayload(): Promise<InitialRSCPayload> {
108+
return new Promise<InitialRSCPayload>(() => {})
109+
}
110+
111+
type OfflineNavigationFallbackBootstrap =
112+
| {
113+
kind: 'router-cache'
114+
initialRSCPayload: InitialRSCPayload
115+
buildId: string | undefined
116+
}
117+
| {
118+
kind: 'cache-miss'
119+
buildId: string | undefined
120+
}
121+
122+
// The generated fallback document has no inline Flight data for the current
123+
// URL. Hydrate the persisted router-cache records, then ask the normal router
124+
// cache to reconstruct the initial payload for this URL.
125+
function createOfflineNavigationFallbackBootstrap():
126+
| Promise<OfflineNavigationFallbackBootstrap>
127+
| undefined {
128+
if (process.env.__NEXT_OFFLINE_NAVIGATIONS) {
129+
if (!isOfflineNavigationFallbackDocument()) {
130+
return undefined
131+
}
132+
133+
return (async (): Promise<OfflineNavigationFallbackBootstrap> => {
134+
const {
135+
createOfflineNavigationInitialRSCPayloadFromRouterCache,
136+
hydrateOfflineNavigationRouterCache,
137+
} =
138+
require('./components/segment-cache/cache') as typeof import('./components/segment-cache/cache')
139+
140+
const buildId =
141+
getDeploymentId() ??
142+
document.documentElement.getAttribute('data-build-id') ??
143+
undefined
144+
await hydrateOfflineNavigationRouterCache({
145+
buildId,
146+
})
147+
148+
const reconstruction =
149+
createOfflineNavigationInitialRSCPayloadFromRouterCache({
150+
buildId,
151+
globalErrorState: [DefaultGlobalError, undefined],
152+
now: Date.now(),
153+
url: location.href,
154+
})
155+
if (reconstruction.status === 'fulfilled') {
156+
showOfflineNavigationCacheHit()
157+
return {
158+
kind: 'router-cache',
159+
initialRSCPayload: reconstruction.initialRSCPayload,
160+
buildId,
161+
}
162+
}
163+
showOfflineNavigationCacheMiss(reconstruction.reason)
164+
return {
165+
kind: 'cache-miss',
166+
buildId,
167+
}
168+
})().catch((): OfflineNavigationFallbackBootstrap => {
169+
showOfflineNavigationCacheMiss('read-error')
170+
return {
171+
kind: 'cache-miss',
172+
buildId: undefined,
173+
}
174+
})
175+
} else {
176+
return undefined
177+
}
178+
}
179+
180+
const offlineNavigationFallbackBootstrap =
181+
createOfflineNavigationFallbackBootstrap()
182+
183+
if (process.env.__NEXT_USE_OFFLINE) {
184+
if (offlineNavigationFallbackBootstrap) {
185+
const { notifyOffline } =
186+
require('./components/offline') as typeof import('./components/offline')
187+
notifyOffline()
188+
}
189+
} else {
190+
// Keep the offline event module out of disabled client bundles.
191+
}
192+
193+
const hasClientResumeShell = Boolean(window.__NEXT_CLIENT_RESUME)
194+
const hasLockedStaticShell =
195+
Boolean(instantTestStaticFetch) ||
196+
Boolean(offlineNavigationFallbackBootstrap) ||
197+
hasClientResumeShell
198+
48199
const encoder = new TextEncoder()
49200

50201
let initialServerDataBuffer: (string | Uint8Array)[] | undefined = undefined
@@ -73,6 +224,7 @@ declare global {
73224
*/
74225
__next_r?: string
75226
__next_f: NextFlight
227+
__NEXT_CLIENT_RESUME?: Promise<Response>
76228
}
77229
}
78230

@@ -128,14 +280,11 @@ function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) {
128280
ctr.enqueue(typeof val === 'string' ? encoder.encode(val) : val)
129281
})
130282
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.
283+
// Locked static shells do not have a real inline Flight stream. Closing
284+
// or erroring this stream causes React to report a missing-data failure,
285+
// but the actual hydration data arrives through a separate response.
137286
if (isStreamErrorOrUnfinished(ctr)) {
138-
if (!instantTestStaticFetch) {
287+
if (!hasLockedStaticShell) {
139288
ctr.error(
140289
new Error(
141290
'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 +304,11 @@ function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) {
155304

156305
// When `DOMContentLoaded`, we can close all pending writers to finish hydration.
157306
const DOMContentLoaded = function () {
158-
if (initialServerDataWriter && !initialServerDataFlushed) {
307+
if (
308+
initialServerDataWriter &&
309+
!initialServerDataFlushed &&
310+
!hasLockedStaticShell
311+
) {
159312
initialServerDataWriter.close()
160313
initialServerDataFlushed = true
161314
initialServerDataBuffer = undefined
@@ -187,7 +340,7 @@ let readable: ReadableStream<Uint8Array> = new ReadableStream({
187340
},
188341
})
189342
if (process.env.NODE_ENV !== 'production') {
190-
// @ts-expect-error
343+
// @ts-expect-error name is a dev-only debugging affordance.
191344
readable.name = 'hydration'
192345
}
193346

@@ -198,7 +351,8 @@ if (process.env.NODE_ENV !== 'production') {
198351
let initialFlightStreamForCache: ReadableStream<Uint8Array> | null = null
199352
if (
200353
process.env.__NEXT_CACHE_COMPONENTS &&
201-
process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS
354+
process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS &&
355+
!offlineNavigationFallbackBootstrap
202356
) {
203357
const [forReact, forCache] = readable.tee()
204358
readable = forReact
@@ -242,13 +396,18 @@ if (instantTestStaticFetch) {
242396
initialRSCPayload
243397
)
244398
})
245-
} else if (
246-
// @ts-expect-error
247-
window.__NEXT_CLIENT_RESUME
248-
) {
249-
const clientResumeFetch: Promise<Response> =
250-
// @ts-expect-error
251-
window.__NEXT_CLIENT_RESUME
399+
} else if (offlineNavigationFallbackBootstrap) {
400+
initialServerResponse = offlineNavigationFallbackBootstrap.then(
401+
async (bootstrap) => {
402+
if (bootstrap.kind === 'router-cache') {
403+
return bootstrap.initialRSCPayload
404+
}
405+
406+
return await neverResolveInitialRSCPayload()
407+
}
408+
)
409+
} else if (window.__NEXT_CLIENT_RESUME) {
410+
const clientResumeFetch: Promise<Response> = window.__NEXT_CLIENT_RESUME
252411
initialServerResponse = Promise.resolve(
253412
createFromFetch<InitialRSCPayload>(clientResumeFetch, {
254413
callServer,
@@ -365,7 +524,11 @@ export async function hydrate(
365524
// Initialize the offline module to register browser event listeners
366525
// (offline/online) before any components hydrate.
367526
if (process.env.__NEXT_USE_OFFLINE) {
368-
require('./components/offline') as typeof import('./components/offline')
527+
const { notifyOffline } =
528+
require('./components/offline') as typeof import('./components/offline')
529+
if (offlineNavigationFallbackBootstrap) {
530+
notifyOffline()
531+
}
369532
}
370533

371534
// setNavigationBuildId should be called only once, during JS initialization
@@ -412,9 +575,13 @@ export async function hydrate(
412575
</StrictModeIfEnabled>
413576
)
414577

415-
if (document.documentElement.id === '__next_error__') {
578+
if (
579+
document.documentElement.id === '__next_error__' ||
580+
isOfflineNavigationFallbackDocument()
581+
) {
416582
let element = reactEl
417-
// Server rendering failed, fall back to client-side rendering
583+
// Error documents and generated offline navigation fallback documents do
584+
// not contain route HTML that can be hydrated.
418585
if (process.env.NODE_ENV !== 'production') {
419586
const { RootLevelDevOverlayElement } =
420587
require('../next-devtools/userspace/app/client-entry') as typeof import('../next-devtools/userspace/app/client-entry')

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export function createInitialRouterState({
8888
acc
8989
)
9090
const metadataVaryPath = acc.metadataVaryPath
91+
const initialStaleAt =
92+
location === null ||
93+
initialSeedData === null ||
94+
initialStaticStageByteLength !== undefined
95+
? null
96+
: getStaleAt(navigatedAt, initialStaleTime)
9197
const initialTask = createInitialCacheNodeForHydration(
9298
navigatedAt,
9399
initialRouteTree,
@@ -157,7 +163,7 @@ export function createInitialRouterState({
157163
// hydration and write it into the cache directly.
158164
const now = Date.now()
159165

160-
getStaleAt(now, initialStaleTime)
166+
initialStaleAt!
161167
.then((staleAt) => {
162168
writeStaticStageResponseIntoCache(
163169
now,

packages/next/src/client/components/use-offline.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ const OfflineContext = createContext<boolean>(false)
1515
// (via dispatchOfflineChange) to update the React tree.
1616
let setOptimistic: ((value: boolean) => void) | null = null
1717
let setCanonical: ((value: boolean) => void) | null = null
18+
let offlineSnapshot = false
1819

1920
/**
2021
* Called by the offline module when the offline state changes.
2122
* Dispatches into React via startTransition + useOptimistic.
2223
*/
2324
export function dispatchOfflineChange(isOffline: boolean): void {
25+
offlineSnapshot = isOffline
2426
const canonical = setCanonical
2527
const optimistic = setOptimistic
2628
if (canonical === null || optimistic === null) {
@@ -33,7 +35,7 @@ export function dispatchOfflineChange(isOffline: boolean): void {
3335
}
3436

3537
export function OfflineProvider({ children }: { children: React.ReactNode }) {
36-
const [canonicalOffline, setCanonicalOffline] = useState(false)
38+
const [canonicalOffline, setCanonicalOffline] = useState(offlineSnapshot)
3739
const [isOffline, setOptimisticOffline] = useOptimistic(canonicalOffline)
3840

3941
setOptimistic = setOptimisticOffline

test/e2e/app-dir/offline-navigations-deploy/app/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import Link from 'next/link'
12
import { OfflineStatus } from './offline-status'
23

34
export default function Page() {
45
return (
56
<>
67
<p id="offline-navigations-page">offline navigations deploy page</p>
8+
<Link id="viewport-prefetch-offline-navigation" href="/viewport-prefetch">
9+
Viewport prefetch offline navigation
10+
</Link>
711
<OfflineStatus />
812
</>
913
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function ViewportPrefetchPage() {
2+
return <p id="viewport-prefetch-page">viewport prefetch page</p>
3+
}

0 commit comments

Comments
 (0)