@@ -20,6 +20,7 @@ import {
2020 createMutableActionQueue ,
2121} from './components/app-router-instance'
2222import AppRouter from './components/app-router'
23+ import DefaultGlobalError from './components/builtin/global-error'
2324import type { InitialRSCPayload } from '../shared/lib/app-router-types'
2425import { createInitialRouterState } from './components/router-reducer/create-initial-router-state'
2526import { 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+
48199const encoder = new TextEncoder ( )
49200
50201let 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.
157306const 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} )
189342if ( 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') {
198351let initialFlightStreamForCache : ReadableStream < Uint8Array > | null = null
199352if (
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' )
0 commit comments