Skip to content

Commit e8d5ded

Browse files
committed
Emit offline navigation fallback document
1 parent dfc196e commit e8d5ded

6 files changed

Lines changed: 187 additions & 0 deletions

File tree

packages/next/src/build/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ import {
229229
} from '../server/lib/router-utils/build-prefetch-segment-data-route'
230230
import { generateRoutesManifest } from './generate-routes-manifest'
231231
import { validateAppPaths } from './validate-app-paths'
232+
import {
233+
createOfflineNavigationFallbackDocument,
234+
getOfflineNavigationFallbackFilePath,
235+
} from './offline-navigation-fallback'
232236

233237
type Fallback = null | boolean | string
234238

@@ -4091,6 +4095,30 @@ export default async function build(
40914095

40924096
// #endregion
40934097

4098+
if (config.experimental.offlineNavigations && appDir) {
4099+
await nextBuildSpan
4100+
.traceChild('write-offline-navigation-fallback')
4101+
.traceAsyncFn(async () => {
4102+
const fallbackDocument = createOfflineNavigationFallbackDocument({
4103+
assetPrefix: config.assetPrefix,
4104+
buildId,
4105+
buildManifest,
4106+
crossOrigin: config.crossOrigin,
4107+
})
4108+
4109+
if (fallbackDocument === null) {
4110+
return
4111+
}
4112+
4113+
const fallbackPath = path.join(
4114+
distDir,
4115+
getOfflineNavigationFallbackFilePath(buildId)
4116+
)
4117+
await mkdir(path.dirname(fallbackPath), { recursive: true })
4118+
await writeFileUtf8(fallbackPath, fallbackDocument)
4119+
})
4120+
}
4121+
40944122
await writeImagesManifest(distDir, config)
40954123
await writeManifest(path.join(distDir, EXPORT_MARKER), {
40964124
version: 1,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import path from 'node:path'
2+
3+
import type { BuildManifest } from '../server/get-page-files'
4+
import { encodeURIPath } from '../shared/lib/encode-uri-path'
5+
import {
6+
htmlEscapeAttributeString,
7+
htmlEscapeJsonString,
8+
} from '../shared/lib/htmlescape'
9+
import { CLIENT_STATIC_FILES_PATH } from '../shared/lib/constants'
10+
11+
export const OFFLINE_NAVIGATION_FALLBACK_HTML =
12+
'_offline-navigation-fallback.html'
13+
14+
export function getOfflineNavigationFallbackFilePath(buildId: string): string {
15+
return path.join(
16+
CLIENT_STATIC_FILES_PATH,
17+
buildId,
18+
OFFLINE_NAVIGATION_FALLBACK_HTML
19+
)
20+
}
21+
22+
function getAssetHref(assetPrefix: string, file: string): string {
23+
return `${assetPrefix}/_next/${encodeURIPath(file)}`
24+
}
25+
26+
function getScriptAttributes(
27+
crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined
28+
): string {
29+
if (!crossOrigin) {
30+
return ''
31+
}
32+
33+
return ` crossOrigin="${htmlEscapeAttributeString(crossOrigin)}"`
34+
}
35+
36+
export function createOfflineNavigationFallbackDocument({
37+
assetPrefix,
38+
buildId,
39+
buildManifest,
40+
crossOrigin,
41+
}: {
42+
assetPrefix: string
43+
buildId: string
44+
buildManifest: BuildManifest
45+
crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined
46+
}): string | null {
47+
const rootMainFiles = buildManifest.rootMainFiles.filter((file) =>
48+
file.endsWith('.js')
49+
)
50+
51+
if (rootMainFiles.length === 0) {
52+
return null
53+
}
54+
55+
const polyfillScripts = buildManifest.polyfillFiles
56+
.filter((file) => file.endsWith('.js') && !file.endsWith('.module.js'))
57+
.map((file) => {
58+
return `<script src="${htmlEscapeAttributeString(
59+
getAssetHref(assetPrefix, file)
60+
)}" noModule${getScriptAttributes(crossOrigin)}></script>`
61+
})
62+
.join('')
63+
64+
const bootstrapScripts = rootMainFiles
65+
.map((file) => {
66+
return `<script src="${htmlEscapeAttributeString(
67+
getAssetHref(assetPrefix, file)
68+
)}" async${getScriptAttributes(crossOrigin)}></script>`
69+
})
70+
.join('')
71+
72+
const metadata = {
73+
buildId,
74+
source: 'offline-navigation-fallback',
75+
}
76+
77+
return `<!DOCTYPE html><html data-next-offline-navigation-fallback="" data-build-id="${htmlEscapeAttributeString(
78+
buildId
79+
)}"><head><meta charSet="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="next-offline-navigation-fallback" content="1"><meta name="next-build-id" content="${htmlEscapeAttributeString(
80+
buildId
81+
)}"><script id="__NEXT_OFFLINE_NAVIGATION_FALLBACK" type="application/json">${htmlEscapeJsonString(
82+
JSON.stringify(metadata)
83+
)}</script></head><body><div id="__next"></div><script>self.__next_f=self.__next_f||[];self.__next_f.push([0])</script>${polyfillScripts}${bootstrapScripts}</body></html>`
84+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <p>offline navigations page</p>
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
cacheComponents: true,
6+
experimental: {
7+
offlineNavigations: true,
8+
},
9+
}
10+
11+
module.exports = nextConfig
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { existsSync } from 'fs'
2+
import { join } from 'path'
3+
import { nextTestSetup } from 'e2e-utils'
4+
5+
describe('offlineNavigations fallback document', () => {
6+
const { next } = nextTestSetup({
7+
files: __dirname,
8+
skipStart: true,
9+
})
10+
11+
async function getFallbackDocumentPath() {
12+
const buildId = (await next.readFile('.next/BUILD_ID')).trim()
13+
14+
return {
15+
buildId,
16+
absolutePath: join(
17+
next.testDir,
18+
'.next',
19+
'static',
20+
buildId,
21+
'_offline-navigation-fallback.html'
22+
),
23+
relativePath: `.next/static/${buildId}/_offline-navigation-fallback.html`,
24+
}
25+
}
26+
27+
it('emits a request-invariant fallback document when enabled', async () => {
28+
const buildResult = await next.build()
29+
expect(buildResult.exitCode).toBe(0)
30+
31+
const { buildId, relativePath } = await getFallbackDocumentPath()
32+
const html = await next.readFile(relativePath)
33+
34+
expect(html).toContain('data-next-offline-navigation-fallback')
35+
expect(html).toContain('id="__NEXT_OFFLINE_NAVIGATION_FALLBACK"')
36+
expect(html).toContain(`"buildId":"${buildId}"`)
37+
expect(html).toContain('self.__next_f')
38+
expect(html).toContain('/_next/static/')
39+
expect(html).not.toContain('offline navigations page')
40+
})
41+
42+
it('does not emit a fallback document when disabled', async () => {
43+
await next.patchFile('next.config.js', (content) =>
44+
content.replace('offlineNavigations: true', 'offlineNavigations: false')
45+
)
46+
47+
const buildResult = await next.build()
48+
expect(buildResult.exitCode).toBe(0)
49+
50+
const { absolutePath } = await getFallbackDocumentPath()
51+
expect(existsSync(absolutePath)).toBe(false)
52+
})
53+
})

0 commit comments

Comments
 (0)