Skip to content

Commit 6caa377

Browse files
committed
Hydrate offline navigation router caches
1 parent 4c6ecad commit 6caa377

5 files changed

Lines changed: 589 additions & 28 deletions

File tree

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ type OfflineNavigationFallbackDiagnostic =
7676
buildId: string | undefined
7777
reason: OfflineNavigationCacheMissReason
7878
}
79+
| {
80+
type: 'router-cache-hydration'
81+
url: string
82+
buildId: string | undefined
83+
routes: {
84+
hydrated: number
85+
skipped: number
86+
}
87+
segments: {
88+
hydrated: number
89+
skipped: number
90+
}
91+
}
7992

8093
declare global {
8194
interface Window {
@@ -597,6 +610,7 @@ export async function hydrate(
597610
} else {
598611
setNavigationBuildId(getDeploymentId()!)
599612
}
613+
const buildId = initialRSCPayload.b ?? getDeploymentId()
600614

601615
if (
602616
process.env.__NEXT_OFFLINE_NAVIGATIONS &&
@@ -607,6 +621,28 @@ export async function hydrate(
607621
registerOfflineNavigationServiceWorker()
608622
}
609623

624+
if (
625+
process.env.__NEXT_OFFLINE_NAVIGATIONS &&
626+
!process.env.__NEXT_DEV_SERVER &&
627+
offlineNavigationClientResumeFetch
628+
) {
629+
try {
630+
const { hydrateOfflineNavigationRouterCache } =
631+
require('./components/segment-cache/cache') as typeof import('./components/segment-cache/cache')
632+
const result = await hydrateOfflineNavigationRouterCache()
633+
reportOfflineNavigationFallbackDiagnostic({
634+
type: 'router-cache-hydration',
635+
url: location.href,
636+
buildId,
637+
routes: result.routes,
638+
segments: result.segments,
639+
})
640+
} catch {
641+
// The exact URL fallback already booted. Router cache hydration should
642+
// only improve later navigations, never block the current render.
643+
}
644+
}
645+
610646
const initialTimestamp = Date.now()
611647
const actionQueue: AppRouterActionQueue = createMutableActionQueue(
612648
createInitialRouterState({

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ class MemoryOfflineNavigationCacheStorage
6868
return this.routeEntries.get(this.getKey(key))
6969
}
7070

71+
async getRoutes(buildId: string): Promise<OfflineNavigationRouteRecord[]> {
72+
return Array.from(this.routeEntries.values()).filter(
73+
(entry) => entry.buildId === buildId
74+
)
75+
}
76+
7177
async putRoute(entry: OfflineNavigationRouteRecord): Promise<void> {
7278
this.routeEntries.set(this.getKey([entry.buildId, entry.key]), entry)
7379
}
@@ -91,6 +97,14 @@ class MemoryOfflineNavigationCacheStorage
9197
return this.segmentEntries.get(this.getKey(key))
9298
}
9399

100+
async getSegments(
101+
buildId: string
102+
): Promise<OfflineNavigationSegmentRecord[]> {
103+
return Array.from(this.segmentEntries.values()).filter(
104+
(entry) => entry.buildId === buildId
105+
)
106+
}
107+
94108
async putSegment(entry: OfflineNavigationSegmentRecord): Promise<void> {
95109
this.segmentEntries.set(this.getKey([entry.buildId, entry.key]), entry)
96110
}
@@ -144,6 +158,10 @@ class FailingOfflineNavigationCacheStorage
144158
throw new Error('get route failed')
145159
}
146160

161+
async getRoutes(): Promise<OfflineNavigationRouteRecord[]> {
162+
throw new Error('get routes failed')
163+
}
164+
147165
async putRoute(): Promise<void> {
148166
throw new Error('put route failed')
149167
}
@@ -164,6 +182,10 @@ class FailingOfflineNavigationCacheStorage
164182
throw new Error('get segment failed')
165183
}
166184

185+
async getSegments(): Promise<OfflineNavigationSegmentRecord[]> {
186+
throw new Error('get segments failed')
187+
}
188+
167189
async putSegment(): Promise<void> {
168190
throw new Error('put segment failed')
169191
}
@@ -708,6 +730,110 @@ describe('offline navigation cache', () => {
708730
).resolves.toBe(null)
709731
})
710732

733+
it('lists only fresh current-epoch route and segment records', async () => {
734+
const storage = new MemoryOfflineNavigationCacheStorage()
735+
const cache = createOfflineNavigationRouterCache(storage)
736+
const routeVaryPath = serializeOfflineNavigationVaryPath({
737+
id: null,
738+
value: '/dashboard',
739+
parent: null,
740+
})
741+
const segmentVaryPath = serializeOfflineNavigationVaryPath({
742+
id: null,
743+
value: 'children/page',
744+
parent: null,
745+
})
746+
747+
await cache.writeRoute({
748+
buildId: 'build-a',
749+
key: 'route:/dashboard',
750+
now: 100,
751+
staleAt: 200,
752+
expiresAt: 300,
753+
route: {
754+
pathname: '/dashboard',
755+
search: '',
756+
nextUrl: null,
757+
canonicalUrl: '/dashboard',
758+
renderedSearch: '',
759+
couldBeIntercepted: false,
760+
supportsPerSegmentPrefetching: true,
761+
hasDynamicRewrite: false,
762+
},
763+
routeVaryPath,
764+
tree: { segment: 'dashboard' },
765+
metadata: { segment: 'metadata' },
766+
})
767+
await cache.writeRoute({
768+
buildId: 'build-a',
769+
key: 'route:/expired',
770+
now: 100,
771+
staleAt: 110,
772+
expiresAt: 120,
773+
route: {
774+
pathname: '/expired',
775+
search: '',
776+
nextUrl: null,
777+
canonicalUrl: '/expired',
778+
renderedSearch: '',
779+
couldBeIntercepted: false,
780+
supportsPerSegmentPrefetching: true,
781+
hasDynamicRewrite: false,
782+
},
783+
routeVaryPath,
784+
tree: { segment: 'expired' },
785+
metadata: { segment: 'metadata' },
786+
})
787+
await cache.writeSegment({
788+
buildId: 'build-a',
789+
key: 'segment:/dashboard:children/page',
790+
now: 100,
791+
staleAt: 200,
792+
expiresAt: 300,
793+
segment: {
794+
requestKey: 'children/page',
795+
fetchStrategy: 1,
796+
isPartial: false,
797+
payloadIndex: 0,
798+
},
799+
segmentVaryPath,
800+
payload: { kind: 'segment-payload' },
801+
})
802+
await cache.writeSegment({
803+
buildId: 'build-a',
804+
key: 'segment:/expired:children/page',
805+
now: 100,
806+
staleAt: 110,
807+
expiresAt: 120,
808+
segment: {
809+
requestKey: 'children/page',
810+
fetchStrategy: 1,
811+
isPartial: false,
812+
payloadIndex: 0,
813+
},
814+
segmentVaryPath,
815+
payload: { kind: 'expired-payload' },
816+
})
817+
818+
await expect(
819+
cache.readRoutes({ buildId: 'build-a', now: 150 })
820+
).resolves.toMatchObject([{ key: 'route:/dashboard' }])
821+
await expect(
822+
cache.readSegments({ buildId: 'build-a', now: 150 })
823+
).resolves.toMatchObject([{ key: 'segment:/dashboard:children/page' }])
824+
expect(storage.routeEntries.has('build-a\0route:/expired')).toBe(false)
825+
expect(
826+
storage.segmentEntries.has('build-a\0segment:/expired:children/page')
827+
).toBe(false)
828+
829+
await expect(
830+
cache.readRoutes({ buildId: 'build-b', now: 150 })
831+
).resolves.toEqual([])
832+
await expect(
833+
cache.readSegments({ buildId: 'build-b', now: 150 })
834+
).resolves.toEqual([])
835+
})
836+
711837
it('ignores and deletes entries whose stored build id does not match', async () => {
712838
const storage = new MemoryOfflineNavigationCacheStorage()
713839
const cache = createOfflineNavigationCache(storage)

0 commit comments

Comments
 (0)