Skip to content

Commit 561d5a1

Browse files
committed
Define offline navigation router cache schema
1 parent a76c40f commit 561d5a1

3 files changed

Lines changed: 779 additions & 44 deletions

File tree

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

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,36 @@ import {
22
createOfflineNavigationCache,
33
createOfflineNavigationRSCResponse,
44
createOfflineNavigationRSCResponsePayload,
5+
createOfflineNavigationRouterCache,
6+
createOfflineNavigationVaryPathKey,
57
deleteOfflineNavigationCacheEntry,
68
getOfflineNavigationRSCResponseCacheSkipReason,
79
invalidateOfflineNavigationCacheEntries,
810
isOfflineNavigationRSCResponsePayload,
911
normalizeOfflineNavigationCacheUrl,
1012
readOfflineNavigationCacheEntry,
13+
serializeOfflineNavigationVaryPath,
1114
writeOfflineNavigationCacheEntry,
1215
type OfflineNavigationRSCResponseCacheEligibility,
1316
type OfflineNavigationRSCResponseCacheSkipReason,
1417
type OfflineNavigationCacheEntry,
1518
type OfflineNavigationCacheStorage,
19+
type OfflineNavigationRouteRecord,
20+
type OfflineNavigationSegmentRecord,
1621
} from './offline-navigation-cache'
1722

1823
type CacheKey = [buildId: string, url: string]
24+
type RouterCacheKey = [buildId: string, key: string]
1925

2026
class MemoryOfflineNavigationCacheStorage
2127
implements OfflineNavigationCacheStorage
2228
{
2329
entries = new Map<string, OfflineNavigationCacheEntry>()
30+
routeEntries = new Map<string, OfflineNavigationRouteRecord>()
31+
segmentEntries = new Map<string, OfflineNavigationSegmentRecord>()
2432
cacheEpoch = 0
33+
routeCacheEpoch = 0
34+
segmentCacheEpoch = 0
2535

2636
async get(key: CacheKey): Promise<OfflineNavigationCacheEntry | undefined> {
2737
return this.entries.get(this.getKey(key))
@@ -52,6 +62,52 @@ class MemoryOfflineNavigationCacheStorage
5262
return this.cacheEpoch
5363
}
5464

65+
async getRoute(
66+
key: RouterCacheKey
67+
): Promise<OfflineNavigationRouteRecord | undefined> {
68+
return this.routeEntries.get(this.getKey(key))
69+
}
70+
71+
async putRoute(entry: OfflineNavigationRouteRecord): Promise<void> {
72+
this.routeEntries.set(this.getKey([entry.buildId, entry.key]), entry)
73+
}
74+
75+
async deleteRoute(key: RouterCacheKey): Promise<void> {
76+
this.routeEntries.delete(this.getKey(key))
77+
}
78+
79+
async getRouteCacheEpoch(): Promise<number> {
80+
return this.routeCacheEpoch
81+
}
82+
83+
async incrementRouteCacheEpoch(): Promise<number> {
84+
this.routeCacheEpoch++
85+
return this.routeCacheEpoch
86+
}
87+
88+
async getSegment(
89+
key: RouterCacheKey
90+
): Promise<OfflineNavigationSegmentRecord | undefined> {
91+
return this.segmentEntries.get(this.getKey(key))
92+
}
93+
94+
async putSegment(entry: OfflineNavigationSegmentRecord): Promise<void> {
95+
this.segmentEntries.set(this.getKey([entry.buildId, entry.key]), entry)
96+
}
97+
98+
async deleteSegment(key: RouterCacheKey): Promise<void> {
99+
this.segmentEntries.delete(this.getKey(key))
100+
}
101+
102+
async getSegmentCacheEpoch(): Promise<number> {
103+
return this.segmentCacheEpoch
104+
}
105+
106+
async incrementSegmentCacheEpoch(): Promise<number> {
107+
this.segmentCacheEpoch++
108+
return this.segmentCacheEpoch
109+
}
110+
55111
private getKey(key: CacheKey): string {
56112
return `${key[0]}\0${key[1]}`
57113
}
@@ -83,6 +139,46 @@ class FailingOfflineNavigationCacheStorage
83139
async incrementCacheEpoch(): Promise<number> {
84140
throw new Error('increment epoch failed')
85141
}
142+
143+
async getRoute(): Promise<OfflineNavigationRouteRecord | undefined> {
144+
throw new Error('get route failed')
145+
}
146+
147+
async putRoute(): Promise<void> {
148+
throw new Error('put route failed')
149+
}
150+
151+
async deleteRoute(): Promise<void> {
152+
throw new Error('delete route failed')
153+
}
154+
155+
async getRouteCacheEpoch(): Promise<number> {
156+
throw new Error('get route epoch failed')
157+
}
158+
159+
async incrementRouteCacheEpoch(): Promise<number> {
160+
throw new Error('increment route epoch failed')
161+
}
162+
163+
async getSegment(): Promise<OfflineNavigationSegmentRecord | undefined> {
164+
throw new Error('get segment failed')
165+
}
166+
167+
async putSegment(): Promise<void> {
168+
throw new Error('put segment failed')
169+
}
170+
171+
async deleteSegment(): Promise<void> {
172+
throw new Error('delete segment failed')
173+
}
174+
175+
async getSegmentCacheEpoch(): Promise<number> {
176+
throw new Error('get segment epoch failed')
177+
}
178+
179+
async incrementSegmentCacheEpoch(): Promise<number> {
180+
throw new Error('increment segment epoch failed')
181+
}
86182
}
87183

88184
type OfflineNavigationEnvKey =
@@ -181,6 +277,49 @@ describe('offline navigation cache', () => {
181277
expect(first).not.toBe(reordered)
182278
})
183279

280+
it('serializes vary paths into stable router record keys', () => {
281+
const fallback = {}
282+
const varyPath = {
283+
id: null,
284+
value: 'children/page',
285+
parent: {
286+
id: '?',
287+
value: fallback,
288+
parent: {
289+
id: 'slug',
290+
value: 'hello',
291+
parent: null,
292+
},
293+
},
294+
}
295+
296+
expect(serializeOfflineNavigationVaryPath(varyPath)).toEqual([
297+
{
298+
id: null,
299+
value: {
300+
kind: 'value',
301+
value: 'children/page',
302+
},
303+
},
304+
{
305+
id: '?',
306+
value: {
307+
kind: 'fallback',
308+
},
309+
},
310+
{
311+
id: 'slug',
312+
value: {
313+
kind: 'value',
314+
value: 'hello',
315+
},
316+
},
317+
])
318+
expect(createOfflineNavigationVaryPathKey(varyPath)).toBe(
319+
'[{"id":null,"value":{"kind":"value","value":"children/page"}},{"id":"?","value":{"kind":"fallback"}},{"id":"slug","value":{"kind":"value","value":"hello"}}]'
320+
)
321+
})
322+
184323
it('normalizes exact URL keys with the configured trailing slash', () => {
185324
const originalTrailingSlash = process.env.__NEXT_TRAILING_SLASH
186325
process.env.__NEXT_TRAILING_SLASH = 'true'
@@ -441,6 +580,126 @@ describe('offline navigation cache', () => {
441580
})
442581
})
443582

583+
it('stores route and segment records with independent durable epochs', async () => {
584+
const storage = new MemoryOfflineNavigationCacheStorage()
585+
const cache = createOfflineNavigationRouterCache(storage)
586+
const routeVaryPath = serializeOfflineNavigationVaryPath({
587+
id: null,
588+
value: '/dashboard',
589+
parent: {
590+
id: '?',
591+
value: '',
592+
parent: {
593+
id: null,
594+
value: null,
595+
parent: null,
596+
},
597+
},
598+
})
599+
const segmentVaryPath = serializeOfflineNavigationVaryPath({
600+
id: null,
601+
value: 'children/page',
602+
parent: null,
603+
})
604+
605+
await expect(
606+
cache.writeRoute({
607+
buildId: 'build-a',
608+
key: 'route:/dashboard',
609+
now: 100,
610+
staleAt: 200,
611+
expiresAt: 300,
612+
route: {
613+
pathname: '/dashboard',
614+
search: '',
615+
nextUrl: null,
616+
canonicalUrl: '/dashboard',
617+
renderedSearch: '',
618+
couldBeIntercepted: false,
619+
supportsPerSegmentPrefetching: true,
620+
hasDynamicRewrite: false,
621+
},
622+
routeVaryPath,
623+
tree: { segment: 'dashboard' },
624+
metadata: { segment: 'metadata' },
625+
})
626+
).resolves.toBe(true)
627+
await expect(
628+
cache.writeSegment({
629+
buildId: 'build-a',
630+
key: 'segment:/dashboard:children/page',
631+
now: 100,
632+
staleAt: 200,
633+
expiresAt: 300,
634+
segment: {
635+
requestKey: 'children/page',
636+
fetchStrategy: 1,
637+
isPartial: false,
638+
},
639+
segmentVaryPath,
640+
payload: { kind: 'segment-payload' },
641+
})
642+
).resolves.toBe(true)
643+
644+
await expect(
645+
cache.readRoute('route:/dashboard', {
646+
buildId: 'build-a',
647+
now: 150,
648+
})
649+
).resolves.toMatchObject({
650+
buildId: 'build-a',
651+
cacheEpoch: 0,
652+
key: 'route:/dashboard',
653+
kind: 'route',
654+
route: {
655+
pathname: '/dashboard',
656+
},
657+
routeVaryPath,
658+
version: 1,
659+
})
660+
await expect(
661+
cache.readSegment('segment:/dashboard:children/page', {
662+
buildId: 'build-a',
663+
now: 150,
664+
})
665+
).resolves.toMatchObject({
666+
buildId: 'build-a',
667+
cacheEpoch: 0,
668+
key: 'segment:/dashboard:children/page',
669+
kind: 'segment',
670+
segment: {
671+
requestKey: 'children/page',
672+
},
673+
segmentVaryPath,
674+
version: 1,
675+
})
676+
677+
await expect(cache.invalidateRoutes()).resolves.toBe(true)
678+
await expect(
679+
cache.readRoute('route:/dashboard', {
680+
buildId: 'build-a',
681+
now: 150,
682+
})
683+
).resolves.toBe(null)
684+
await expect(
685+
cache.readSegment('segment:/dashboard:children/page', {
686+
buildId: 'build-a',
687+
now: 150,
688+
})
689+
).resolves.toMatchObject({
690+
cacheEpoch: 0,
691+
key: 'segment:/dashboard:children/page',
692+
})
693+
694+
await expect(cache.invalidateSegments()).resolves.toBe(true)
695+
await expect(
696+
cache.readSegment('segment:/dashboard:children/page', {
697+
buildId: 'build-a',
698+
now: 150,
699+
})
700+
).resolves.toBe(null)
701+
})
702+
444703
it('ignores and deletes entries whose stored build id does not match', async () => {
445704
const storage = new MemoryOfflineNavigationCacheStorage()
446705
const cache = createOfflineNavigationCache(storage)
@@ -547,6 +806,9 @@ describe('offline navigation cache', () => {
547806
const cache = createOfflineNavigationCache(
548807
new FailingOfflineNavigationCacheStorage()
549808
)
809+
const routerCache = createOfflineNavigationRouterCache(
810+
new FailingOfflineNavigationCacheStorage()
811+
)
550812

551813
await expect(
552814
cache.write({
@@ -565,6 +827,56 @@ describe('offline navigation cache', () => {
565827
).resolves.toBe(false)
566828
await expect(cache.deleteBuild('build-a')).resolves.toBe(false)
567829
await expect(cache.invalidate()).resolves.toBe(false)
830+
await expect(
831+
routerCache.writeRoute({
832+
buildId: 'build-a',
833+
key: 'route:/dashboard',
834+
staleAt: 200,
835+
expiresAt: 300,
836+
route: {
837+
pathname: '/dashboard',
838+
search: '',
839+
nextUrl: null,
840+
canonicalUrl: '/dashboard',
841+
renderedSearch: '',
842+
couldBeIntercepted: false,
843+
supportsPerSegmentPrefetching: true,
844+
hasDynamicRewrite: false,
845+
},
846+
routeVaryPath: [],
847+
tree: null,
848+
metadata: null,
849+
})
850+
).resolves.toBe(false)
851+
await expect(
852+
routerCache.readRoute('route:/dashboard', { buildId: 'build-a' })
853+
).resolves.toBe(null)
854+
await expect(
855+
routerCache.deleteRoute('route:/dashboard', { buildId: 'build-a' })
856+
).resolves.toBe(false)
857+
await expect(routerCache.invalidateRoutes()).resolves.toBe(false)
858+
await expect(
859+
routerCache.writeSegment({
860+
buildId: 'build-a',
861+
key: 'segment:/dashboard',
862+
staleAt: 200,
863+
expiresAt: 300,
864+
segment: {
865+
requestKey: 'children/page',
866+
fetchStrategy: 1,
867+
isPartial: false,
868+
},
869+
segmentVaryPath: [],
870+
payload: null,
871+
})
872+
).resolves.toBe(false)
873+
await expect(
874+
routerCache.readSegment('segment:/dashboard', { buildId: 'build-a' })
875+
).resolves.toBe(null)
876+
await expect(
877+
routerCache.deleteSegment('segment:/dashboard', { buildId: 'build-a' })
878+
).resolves.toBe(false)
879+
await expect(routerCache.invalidateSegments()).resolves.toBe(false)
568880
})
569881

570882
it('is a no-op when IndexedDB is unavailable', async () => {

0 commit comments

Comments
 (0)