Skip to content

Commit cfe3cce

Browse files
committed
offline navigations: cache fallback artifacts (4/11)
1 parent 8ac906e commit cfe3cce

3 files changed

Lines changed: 103 additions & 5 deletions

File tree

packages/next/src/build/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4144,6 +4144,7 @@ export default async function build(
41444144
await writeFileUtf8(
41454145
serviceWorkerPath,
41464146
createOfflineNavigationServiceWorker({
4147+
cacheNamespace: manifest.cacheNamespace,
41474148
manifestHref: manifest.manifest.href,
41484149
})
41494150
)

packages/next/src/build/offline-navigation-service-worker.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,67 @@ export function getOfflineNavigationServiceWorkerFilePath(): string {
77
return path.join(CLIENT_STATIC_FILES_PATH, OFFLINE_NAVIGATION_SERVICE_WORKER)
88
}
99

10-
// Generate an app-local service worker for offline navigations. This slice is
11-
// pass-through: it only installs and claims clients so later slices can add
12-
// cache population and fallback document handling.
10+
// Offline navigations use a generated, app-local service worker. It caches the
11+
// bootstrap manifest and fallback document during installation; document
12+
// navigations still go to the network until fallback handling is enabled.
1313
export function createOfflineNavigationServiceWorker({
14+
cacheNamespace,
1415
manifestHref,
1516
}: {
17+
cacheNamespace: string
1618
manifestHref: string
1719
}): string {
1820
const metadata = JSON.stringify({
21+
cacheNamespace,
1922
manifestHref,
2023
source: 'offline-navigation-service-worker',
2124
})
2225

2326
return `self.__NEXT_OFFLINE_NAVIGATION_SW=${metadata};
24-
self.addEventListener('install',(event)=>{event.waitUntil(self.skipWaiting())});
25-
self.addEventListener('activate',(event)=>{event.waitUntil(self.clients.claim())});
27+
const CACHE_PREFIX='next-offline-navigation-v1:';
28+
function withDeploymentQuery(href){
29+
const url=new URL(href,self.location.origin);
30+
const deploymentParams=new URLSearchParams(self.location.search);
31+
deploymentParams.forEach((value,key)=>{
32+
if(!url.searchParams.has(key)){
33+
url.searchParams.set(key,value);
34+
}
35+
});
36+
return url.href;
37+
}
38+
async function fetchRequiredResource(href){
39+
const response=await fetch(withDeploymentQuery(href),{cache:'no-store'});
40+
if(!response.ok){
41+
throw new Error('Failed to cache offline navigation resource: '+href);
42+
}
43+
return response;
44+
}
45+
async function cacheOfflineNavigationResources(){
46+
const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;
47+
const cache=await caches.open(metadata.cacheNamespace);
48+
const manifestResponse=await fetchRequiredResource(metadata.manifestHref);
49+
const manifest=await manifestResponse.clone().json();
50+
await cache.put(metadata.manifestHref,manifestResponse);
51+
const fallbackResponse=await fetchRequiredResource(manifest.fallbackDocument.href);
52+
await cache.put(manifest.fallbackDocument.href,fallbackResponse);
53+
}
54+
self.addEventListener('install',(event)=>{
55+
event.waitUntil((async()=>{
56+
await cacheOfflineNavigationResources();
57+
await self.skipWaiting();
58+
})());
59+
});
60+
self.addEventListener('activate',(event)=>{
61+
event.waitUntil((async()=>{
62+
const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;
63+
const cacheNames=await caches.keys();
64+
await Promise.all(cacheNames.map((cacheName)=>{
65+
if(cacheName.startsWith(CACHE_PREFIX)&&cacheName!==metadata.cacheNamespace){
66+
return caches.delete(cacheName);
67+
}
68+
}));
69+
await self.clients.claim();
70+
})());
71+
});
2672
`
2773
}

test/production/app-dir/offline-navigations/offline-navigations.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,14 @@ describe('offlineNavigations build artifacts', () => {
6363
expect(html).toContain('/app-assets/_next/static/')
6464
expect(html).not.toContain('offline navigations page')
6565

66+
expect(serviceWorkerScript).toContain(
67+
`"cacheNamespace":"next-offline-navigation-v1:${buildId}:/docs"`
68+
)
6669
expect(serviceWorkerScript).toContain(
6770
`"manifestHref":"/docs/_next/static/${buildId}/_offline-navigation-manifest.json"`
6871
)
72+
expect(serviceWorkerScript).toContain('cacheOfflineNavigationResources')
73+
expect(serviceWorkerScript).toContain('caches.delete')
6974
expect(serviceWorkerScript).toContain('skipWaiting')
7075
expect(serviceWorkerScript).toContain('clients.claim')
7176
expect(serviceWorkerScript).not.toContain('respondWith')
@@ -98,6 +103,7 @@ describe('offlineNavigations build artifacts', () => {
98103
const buildResult = await next.build()
99104
expect(buildResult.exitCode).toBe(0)
100105

106+
const { buildId } = await getOfflineNavigationArtifactPaths()
101107
await next.start({ skipBuild: true })
102108

103109
try {
@@ -138,11 +144,56 @@ describe('offlineNavigations build artifacts', () => {
138144
})
139145
})
140146

147+
const cacheState = await browser.eval(async () => {
148+
const cacheNames = (await caches.keys()).filter((cacheName) =>
149+
cacheName.startsWith('next-offline-navigation-v1:')
150+
)
151+
const entries: Array<{ cacheName: string; pathname: string }> = []
152+
153+
for (const cacheName of cacheNames) {
154+
const cache = await caches.open(cacheName)
155+
const requests = await cache.keys()
156+
157+
for (const request of requests) {
158+
entries.push({
159+
cacheName,
160+
pathname: new URL(request.url).pathname,
161+
})
162+
}
163+
}
164+
165+
return { cacheNames, entries }
166+
})
167+
168+
const cacheName = `next-offline-navigation-v1:${buildId}:/docs`
169+
expect(cacheState.cacheNames).toContain(cacheName)
170+
expect(cacheState.entries).toEqual(
171+
expect.arrayContaining([
172+
{
173+
cacheName,
174+
pathname: `/docs/_next/static/${buildId}/_offline-navigation-manifest.json`,
175+
},
176+
{
177+
cacheName,
178+
pathname: `/docs/_next/static/${buildId}/_offline-navigation-fallback.html`,
179+
},
180+
])
181+
)
182+
141183
await browser.eval(async () => {
142184
if (!('serviceWorker' in navigator)) {
143185
return
144186
}
145187

188+
const cacheNames = await caches.keys()
189+
await Promise.all(
190+
cacheNames
191+
.filter((cacheName) =>
192+
cacheName.startsWith('next-offline-navigation-v1:')
193+
)
194+
.map((cacheName) => caches.delete(cacheName))
195+
)
196+
146197
const registrations = await navigator.serviceWorker.getRegistrations()
147198
await Promise.all(
148199
registrations.map((registration) => registration.unregister())

0 commit comments

Comments
 (0)