@@ -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
1823type CacheKey = [ buildId : string , url : string ]
24+ type RouterCacheKey = [ buildId : string , key : string ]
1925
2026class 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
88184type 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