豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 99e499b

Browse files
committed
Collapse output export fallback critical path
1 parent f66f0da commit 99e499b

File tree

5 files changed

+201
-34
lines changed

5 files changed

+201
-34
lines changed

packages/next/src/client/output-export-fallback.test.ts

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import {
1212
describe('output export fallback helpers', () => {
1313
const originalFetch = global.fetch
1414
const originalBasePath = process.env.__NEXT_ROUTER_BASEPATH
15+
const originalTrailingSlash = process.env.__NEXT_TRAILING_SLASH
1516

1617
afterEach(() => {
1718
global.fetch = originalFetch
1819
process.env.__NEXT_ROUTER_BASEPATH = originalBasePath
20+
process.env.__NEXT_TRAILING_SLASH = originalTrailingSlash
1921
clearOutputExportFallbackManifestCache()
2022
jest.restoreAllMocks()
2123
})
@@ -93,18 +95,49 @@ describe('output export fallback helpers', () => {
9395
])
9496
})
9597

98+
it('tries the direct fallback artifact before manifest lookups', async () => {
99+
process.env.__NEXT_TRAILING_SLASH = 'false'
100+
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
101+
const url = String(input)
102+
103+
if (url.endsWith('/org/acme/chat/123/__fallback.txt')) {
104+
return new Response('payload', {
105+
status: 200,
106+
headers: { 'content-type': 'text/plain' },
107+
})
108+
}
109+
110+
return new Response('not found', {
111+
status: 404,
112+
headers: { 'content-type': 'text/html' },
113+
})
114+
})
115+
116+
global.fetch = fetchMock as typeof fetch
117+
118+
const renderedUrl = new URL('https://example.com/org/acme/chat/123')
119+
const result = await fetchOutputExportFallbackResponse(renderedUrl)
120+
121+
expect(result).not.toBeNull()
122+
expect(result?.renderedUrl.href).toBe(renderedUrl.href)
123+
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
124+
'https://example.com/org/acme/chat/123/__fallback.txt',
125+
])
126+
})
127+
96128
it('falls through deeper prefixes before using a shallower fallback artifact', async () => {
129+
process.env.__NEXT_TRAILING_SLASH = 'false'
97130
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
98131
const url = String(input)
99132

100-
if (url.endsWith('/docs/guides/export/__fallback.meta.json')) {
133+
if (url.endsWith('/docs/guides/export/__fallback.txt')) {
101134
return new Response('not found', {
102135
status: 404,
103136
headers: { 'content-type': 'text/html' },
104137
})
105138
}
106139

107-
if (url.endsWith('/docs/guides/export/__fallback.txt')) {
140+
if (url.endsWith('/docs/guides/export/__fallback.meta.json')) {
108141
return new Response('not found', {
109142
status: 404,
110143
headers: { 'content-type': 'text/html' },
@@ -133,18 +166,57 @@ describe('output export fallback helpers', () => {
133166
expect(result?.renderedUrl.href).toBe(renderedUrl.href)
134167
expect(result?.fallbackUrl.pathname).toBe('/docs/guides/__fallback')
135168
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
136-
'https://example.com/docs/guides/export/__fallback.meta.json',
137169
'https://example.com/docs/guides/export/__fallback.txt',
138-
'https://example.com/docs/guides/export/__fallback/index.txt',
139-
'https://example.com/docs/guides/__fallback.meta.json',
170+
'https://example.com/docs/guides/export/__fallback.meta.json',
140171
'https://example.com/docs/guides/__fallback.txt',
141172
])
142173
})
143174

175+
it('prefers the trailing-slash fallback artifact when the request URL ends with /', async () => {
176+
delete process.env.__NEXT_TRAILING_SLASH
177+
178+
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
179+
const url = String(input)
180+
181+
if (url.endsWith('/org/umbrella/chat/thread-789/__fallback/index.txt')) {
182+
return new Response('flight', {
183+
status: 200,
184+
headers: { 'content-type': 'text/x-component' },
185+
})
186+
}
187+
188+
return new Response('not found', {
189+
status: 404,
190+
headers: { 'content-type': 'text/html' },
191+
})
192+
})
193+
194+
global.fetch = fetchMock as typeof fetch
195+
196+
const renderedUrl = new URL(
197+
'https://example.com/org/umbrella/chat/thread-789/'
198+
)
199+
const result = await fetchOutputExportFallbackResponse(renderedUrl)
200+
201+
expect(result).not.toBeNull()
202+
expect(result?.renderedUrl.href).toBe(renderedUrl.href)
203+
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
204+
'https://example.com/org/umbrella/chat/thread-789/__fallback/index.txt',
205+
])
206+
})
207+
144208
it('uses fallback metadata to select the most specific conflicting route', async () => {
209+
process.env.__NEXT_TRAILING_SLASH = 'false'
145210
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
146211
const url = String(input)
147212

213+
if (url.endsWith('/docs/__fallback.txt')) {
214+
return new Response('not found', {
215+
status: 404,
216+
headers: { 'content-type': 'text/html' },
217+
})
218+
}
219+
148220
if (url.endsWith('/docs/__fallback.meta.json')) {
149221
return new Response(
150222
JSON.stringify({
@@ -187,16 +259,11 @@ describe('output export fallback helpers', () => {
187259

188260
expect(result).not.toBeNull()
189261
expect(result?.fallbackUrl.pathname).toBe('/docs/__fallback/__route_0')
190-
const requestedUrls = fetchMock.mock.calls.map(([url]) => String(url))
191-
expect(requestedUrls).toContain(
192-
'https://example.com/docs/__fallback.meta.json'
193-
)
194-
expect(requestedUrls).toContain(
195-
'https://example.com/docs/__fallback/__route_0.txt'
196-
)
197-
expect(requestedUrls).not.toContain(
198-
'https://example.com/docs/__fallback/__route_1.txt'
199-
)
262+
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
263+
'https://example.com/docs/__fallback.txt',
264+
'https://example.com/docs/__fallback.meta.json',
265+
'https://example.com/docs/__fallback/__route_0.txt',
266+
])
200267
})
201268

202269
it('matches and fetches conflicting fallback branches under basePath', async () => {
@@ -259,6 +326,7 @@ describe('output export fallback helpers', () => {
259326
})
260327

261328
it('caches the resolved fallback data URL for later RSC fetches', async () => {
329+
process.env.__NEXT_TRAILING_SLASH = 'false'
262330
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
263331
const url = String(input)
264332

@@ -325,9 +393,17 @@ describe('output export fallback helpers', () => {
325393
})
326394

327395
it('dedupes fallback artifact fetches across sibling routes', async () => {
396+
process.env.__NEXT_TRAILING_SLASH = 'false'
328397
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
329398
const url = String(input)
330399

400+
if (url.endsWith('/docs/__fallback.txt')) {
401+
return new Response('not found', {
402+
status: 404,
403+
headers: { 'content-type': 'text/html' },
404+
})
405+
}
406+
331407
if (url.endsWith('/docs/__fallback.meta.json')) {
332408
return new Response(
333409
JSON.stringify({
@@ -373,6 +449,7 @@ describe('output export fallback helpers', () => {
373449
)
374450

375451
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
452+
'https://example.com/docs/__fallback.txt',
376453
'https://example.com/docs/__fallback.meta.json',
377454
'https://example.com/docs/__fallback/__route_0.txt',
378455
])

packages/next/src/client/output-export-fallback.ts

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,22 @@ function getOutputExportDataCandidates(url: URL): URL[] {
171171
return [direct, trailingSlash]
172172
}
173173

174+
function getConfiguredOutputExportDataUrl(
175+
url: URL,
176+
prefersTrailingSlash: boolean
177+
): URL {
178+
const trailingSlash = process.env.__NEXT_TRAILING_SLASH
179+
const configuredUrl = new URL(url)
180+
const useTrailingSlash =
181+
trailingSlash == null
182+
? prefersTrailingSlash
183+
: String(trailingSlash) === 'true'
184+
if (useTrailingSlash && !configuredUrl.pathname.endsWith('/')) {
185+
configuredUrl.pathname = `${configuredUrl.pathname}/`
186+
}
187+
return addOutputExportDataSuffix(configuredUrl)
188+
}
189+
174190
function normalizeOutputExportRouteDirectory(pathname: string): string {
175191
if (pathname === '/') {
176192
return ''
@@ -237,6 +253,30 @@ async function fetchOutputExportDataResult(
237253
return null
238254
}
239255

256+
async function fetchConfiguredOutputExportDataResult(
257+
renderedUrl: URL,
258+
prefersTrailingSlash: boolean,
259+
init?: RequestInit
260+
): Promise<{ response: Response; dataUrl: URL } | null> {
261+
const configuredDataUrl = getConfiguredOutputExportDataUrl(
262+
renderedUrl,
263+
prefersTrailingSlash
264+
)
265+
266+
const response = await fetchOutputExportDataResponseByUrl(
267+
configuredDataUrl,
268+
init
269+
)
270+
if (response === null) {
271+
return null
272+
}
273+
274+
return {
275+
response,
276+
dataUrl: configuredDataUrl,
277+
}
278+
}
279+
240280
async function fetchOutputExportDataResponseByUrl(
241281
dataUrl: URL,
242282
init?: RequestInit
@@ -295,12 +335,32 @@ export async function fetchOutputExportFallbackResponse(
295335
renderedUrl: URL,
296336
init?: RequestInit
297337
): Promise<{ response: Response; renderedUrl: URL; fallbackUrl: URL } | null> {
338+
const prefersTrailingSlash = renderedUrl.pathname.endsWith('/')
339+
298340
for (const candidate of getOutputExportFallbackCandidates(
299341
renderedUrl.pathname
300342
)) {
301343
const candidateUrl = new URL(renderedUrl)
302344
candidateUrl.pathname = candidate
303345

346+
const directResult = await fetchConfiguredOutputExportDataResult(
347+
candidateUrl,
348+
prefersTrailingSlash,
349+
init
350+
)
351+
if (directResult) {
352+
cacheOutputExportFallbackDataUrl(
353+
renderedUrl,
354+
candidateUrl,
355+
directResult.dataUrl
356+
)
357+
return {
358+
response: directResult.response,
359+
renderedUrl,
360+
fallbackUrl: candidateUrl,
361+
}
362+
}
363+
304364
const fallbackManifest = await fetchOutputExportFallbackManifest(
305365
candidateUrl,
306366
init
@@ -320,8 +380,9 @@ export async function fetchOutputExportFallbackResponse(
320380
basePath
321381
)
322382

323-
const result = await fetchOutputExportDataResult(
383+
const result = await fetchConfiguredOutputExportDataResult(
324384
branchFallbackUrl,
385+
prefersTrailingSlash,
325386
init
326387
)
327388
if (result) {
@@ -338,20 +399,6 @@ export async function fetchOutputExportFallbackResponse(
338399
}
339400
}
340401
}
341-
342-
const result = await fetchOutputExportDataResult(candidateUrl, init)
343-
if (result) {
344-
cacheOutputExportFallbackDataUrl(
345-
renderedUrl,
346-
candidateUrl,
347-
result.dataUrl
348-
)
349-
return {
350-
response: result.response,
351-
renderedUrl,
352-
fallbackUrl: candidateUrl,
353-
}
354-
}
355402
}
356403

357404
return null

packages/next/src/export/helpers/output-export-fallback.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,11 @@ export async function writeOutputExportFallbackHtml(
286286
'<style id="__next-export-fallback-style">#__next{visibility:hidden}</style>'
287287
const injection = `${exportFallbackStyle}${exportFallbackScript}`
288288

289-
const patchedFallbackHtml = fallbackHtml.includes('</head>')
290-
? fallbackHtml.replace('</head>', `${injection}</head>`)
291-
: injection + fallbackHtml
289+
const patchedFallbackHtml = fallbackHtml.includes('<head>')
290+
? fallbackHtml.replace('<head>', `<head>${injection}`)
291+
: fallbackHtml.includes('</head>')
292+
? fallbackHtml.replace('</head>', `${injection}</head>`)
293+
: injection + fallbackHtml
292294

293295
await fs.writeFile(join(outDir, '_fallback.html'), patchedFallbackHtml)
294296
}

packages/next/src/server/stream-utils/node-web-streams-helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ function createClientResumeScriptInsertionTransformStream(): TransformStream<
535535
undefined // headers[NEXT_URL]
536536
)
537537
const searchStr = `${NEXT_RSC_UNION_QUERY}=${cacheBustingHeader}`
538-
const NEXT_CLIENT_RESUME_SCRIPT = `<script>try{var u=sessionStorage.getItem('${originalUrlSessionKey}');var r=location.pathname;if(u){r=r.endsWith('.html')?r.slice(0,-5)+'.txt':r.endsWith('/')?r+'index.txt':r+'.txt'}__NEXT_CLIENT_RESUME=fetch(r+'?${searchStr}',{credentials:'same-origin',headers:{'${RSC_HEADER}': '1','${NEXT_ROUTER_PREFETCH_HEADER}': '1','${NEXT_ROUTER_SEGMENT_PREFETCH_HEADER}': '${segmentPath}'}});if(u){window.${originalUrlGlobalKey}=u;history.replaceState(null,'',u);sessionStorage.removeItem('${originalUrlSessionKey}')}}catch{}</script>`
538+
const NEXT_CLIENT_RESUME_SCRIPT = `<script>try{var u=sessionStorage.getItem('${originalUrlSessionKey}');var r=location.pathname;if(u){r=r.endsWith('.html')?r.slice(0,-5)+'.txt':r.endsWith('/')?r+'index.txt':r+'.txt'}if(!self.__NEXT_EXPORT_FALLBACK){__NEXT_CLIENT_RESUME=fetch(r+'?${searchStr}',{credentials:'same-origin',headers:{'${RSC_HEADER}': '1','${NEXT_ROUTER_PREFETCH_HEADER}': '1','${NEXT_ROUTER_SEGMENT_PREFETCH_HEADER}': '${segmentPath}'}})}if(u){window.${originalUrlGlobalKey}=u;history.replaceState(null,'',u);sessionStorage.removeItem('${originalUrlSessionKey}')}}catch{}</script>`
539539

540540
let didAlreadyInsert = false
541541
return new TransformStream({

test/e2e/app-dir-export/test/dynamic-fallback-cache-components.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,47 @@ if (skipped) {
397397
}
398398
})
399399

400+
it('loads nested fallback routes without concrete retries or extra probes', async () => {
401+
clearRequests()
402+
const browser = await webdriver(port, '/org/umbrella/chat/thread-789/')
403+
404+
try {
405+
await retry(async () => {
406+
expect(await browser.elementByCss('#org-name').text()).toBe(
407+
'Org umbrella'
408+
)
409+
expect(await browser.elementByCss('h1').text()).toBe(
410+
'umbrella:thread-789'
411+
)
412+
})
413+
414+
const requests = getRequests()
415+
416+
expect(
417+
requests.some((requestPath) =>
418+
requestPath.startsWith('/org/umbrella/chat/thread-789/?_rsc=')
419+
)
420+
).toBe(false)
421+
expect(
422+
requests.some((requestPath) =>
423+
requestPath.startsWith('/org/__fallback.meta.json')
424+
)
425+
).toBe(false)
426+
expect(
427+
requests.some((requestPath) =>
428+
requestPath.startsWith('/org/__fallback.txt')
429+
)
430+
).toBe(false)
431+
expect(
432+
requests.filter((requestPath) =>
433+
requestPath.startsWith('/org/__fallback/index.txt')
434+
)
435+
).toHaveLength(1)
436+
} finally {
437+
await browser.close()
438+
}
439+
})
440+
400441
it('generates a hidden _fallback.html bootstrap document', async () => {
401442
const outDir = join(next.testDir, 'out')
402443
const fallbackHtml = await fs.readFile(

0 commit comments

Comments
 (0)