Files
next.js/test/e2e/app-dir/partial-fallback-shell-upgrade/partial-fallback-shell-upgrade.test.ts
Zack TannerandGitHub 9895972da5 [partial fallbacks]: include prefetch requests in shell upgrade handling (#92894)
With the new `partialFallbacks` heuristic, routes that weren't part of
`generateStaticParams` will now upgrade their shells to include the
params as if they had been in `generateStaticParams`, but only when the
page with the unknown param was lazily visited.

This tweaks the heuristic to allow segment prefetches to also trigger
the upgrade behavior. This will ensure subsequent clients, when
prefetching the upgraded route, will receive the entry with params
filled in rather than showing the generic fallback.

In a subsequent PR, we'll make it so that the requesting client that
triggers the upgrade path can also benefit from the params being
upgraded, as the client router currently has no awareness that a
background revalidation completed.
2026-04-17 09:35:39 -07:00

227 lines
7.9 KiB
TypeScript

import cheerio from 'cheerio'
import { nextTestSetup } from 'e2e-utils'
import { splitResponseWithPPRSentinel } from 'e2e-utils/ppr'
import { retry, waitFor } from 'next-test-utils'
import path from 'path'
const isAdapterTest = Boolean(process.env.NEXT_ENABLE_ADAPTER)
type NextInstance = ReturnType<typeof nextTestSetup>['next']
function createSplitHTMLFetcher(next: NextInstance) {
return async function fetchSplitHTML(pathname: string) {
let response: Awaited<ReturnType<typeof next.fetch>> | undefined
const [staticPart, dynamicPart] = await splitResponseWithPPRSentinel(
async () => {
response = await next.fetch(pathname)
expect(response.status).toBe(200)
if (!response.body) {
throw new Error(`Expected a streamed response body for ${pathname}`)
}
return response.body
}
)
return {
response: response!,
dynamicPart,
static$: cheerio.load(staticPart),
}
}
}
describe('partial-fallback-shell-upgrade', () => {
const { next, isNextDev } = nextTestSetup({
files: path.join(__dirname, 'fixtures', 'default'),
// The latest changes to support this behavior on deployed infra are available in the adapter,
// and are not being backported to the CLI
skipDeployment: !isAdapterTest,
})
if (isNextDev) {
it('skipped in dev', () => {})
return
}
const fetchSplitHTML = createSplitHTMLFetcher(next)
it('should upgrade the fallback shell to a route shell', async () => {
const pathname = '/two'
let $ = await next.render$(pathname)
expect($('#fallback').text()).toBe('loading...')
expect($('#slug').closest('[hidden]').length).toBe(1)
await retry(async () => {
$ = await next.render$(pathname)
expect($('#slug').closest('[hidden]').length).toBe(0)
expect($('#fallback').length).toBe(0)
})
})
it('should not upgrade a route shell when no params were prerendered', async () => {
const pathname = '/no-gsp/two'
const start = Date.now()
await retry(
async () => {
const $ = await next.render$(pathname)
expect($('#fallback').text()).toBe('loading...')
expect($('#slug').closest('[hidden]').length).toBe(1)
if (Date.now() - start < 5000) {
throw new Error('continue polling fallback shell')
}
},
6000,
500,
'no-gsp fallback shell should remain unupgraded'
)
})
it('should upgrade a generic shell into the most specific prerendered shell', async () => {
const firstResult = await fetchSplitHTML('/prefix/c/foo')
expect(firstResult.response.status).toBe(200)
expect(firstResult.static$('#one').length).toBe(0)
expect(firstResult.static$('#one-fallback').text()).toBe('loading one...')
expect(firstResult.static$('#two-fallback').length).toBe(0)
expect(firstResult.static$('#two').length).toBe(0)
expect(firstResult.dynamicPart).toContain('<div id="one">c</div>')
expect(firstResult.dynamicPart).toContain('<div id="two">foo</div>')
await retry(async () => {
const secondResult = await fetchSplitHTML('/prefix/c/bar')
expect(secondResult.response.status).toBe(200)
expect(secondResult.static$('#one').text()).toBe('c')
expect(secondResult.static$('#one-fallback').length).toBe(0)
expect(secondResult.static$('#two-fallback').text()).toBe(
'loading two...'
)
expect(secondResult.static$('#two').length).toBe(0)
expect(secondResult.dynamicPart).toContain('<div id="two">bar</div>')
expect(secondResult.dynamicPart).not.toContain('<div id="two">foo</div>')
})
})
it('should let a segment prefetch trigger the background shell upgrade', async () => {
const prefetchResponse = await next.fetch('/prefix/z/foo', {
headers: {
rsc: '1',
'next-router-prefetch': '1',
'next-router-segment-prefetch': '/_tree',
},
})
expect(prefetchResponse.status).toBe(200)
// Wait a moment to let the background upgrade to finish
await waitFor(3000)
const result = await fetchSplitHTML('/prefix/z/bar')
expect(result.response.status).toBe(200)
expect(result.static$('#one').text()).toBe('z')
expect(result.static$('#one-fallback').length).toBe(0)
expect(result.static$('#two-fallback').text()).toBe('loading two...')
expect(result.static$('#two').length).toBe(0)
expect(result.dynamicPart).toContain('<div id="two">bar</div>')
expect(result.dynamicPart).not.toContain('<div id="two">foo</div>')
})
it('should not keep upgrading once only fully dynamic params remain', async () => {
const firstResult = await fetchSplitHTML('/prefix/b/foo')
const start = Date.now()
expect(firstResult.response.status).toBe(200)
expect(firstResult.static$('#one').text()).toBe('b')
expect(firstResult.static$('#one-fallback').length).toBe(0)
expect(firstResult.static$('#two-fallback').text()).toBe('loading two...')
expect(firstResult.static$('#two').length).toBe(0)
expect(firstResult.dynamicPart).toContain('<div id="two">foo</div>')
await retry(
async () => {
const secondResult = await fetchSplitHTML('/prefix/b/bar')
expect(secondResult.response.status).toBe(200)
expect(secondResult.static$('#one').text()).toBe('b')
expect(secondResult.static$('#one-fallback').length).toBe(0)
expect(secondResult.static$('#two-fallback').text()).toBe(
'loading two...'
)
expect(secondResult.static$('#two').length).toBe(0)
expect(secondResult.dynamicPart).toContain('<div id="two">bar</div>')
expect(secondResult.dynamicPart).not.toContain(
'<div id="two">foo</div>'
)
if (Date.now() - start < 5000) {
throw new Error('continue polling more complete shell')
}
},
6000,
500,
'shell should remain partial when remaining params are dynamic'
)
})
})
describe('partial-fallback-shell-upgrade when disabled', () => {
const { next, isNextDev } = nextTestSetup({
files: path.join(__dirname, 'fixtures', 'disabled'),
// The latest changes to support this behavior on deployed infra are available in the adapter,
// and are not being backported to the CLI
skipDeployment: !isAdapterTest,
})
if (isNextDev) {
it('skipped in dev', () => {})
return
}
const fetchSplitHTML = createSplitHTMLFetcher(next)
it('should keep serving the generic shell when partialFallbacks is disabled', async () => {
const firstResult = await fetchSplitHTML('/prefix/c/foo')
expect(firstResult.response.status).toBe(200)
expect(firstResult.static$('#one').length).toBe(0)
expect(firstResult.static$('#one-fallback').text()).toBe('loading one...')
expect(firstResult.static$('#two-fallback').length).toBe(0)
expect(firstResult.static$('#two').length).toBe(0)
expect(firstResult.dynamicPart).toContain('<div id="one">c</div>')
expect(firstResult.dynamicPart).toContain('<div id="two">foo</div>')
const start = Date.now()
await retry(
async () => {
const secondResult = await fetchSplitHTML('/prefix/c/bar')
expect(secondResult.response.status).toBe(200)
expect(secondResult.static$('#one').length).toBe(0)
expect(secondResult.static$('#one-fallback').text()).toBe(
'loading one...'
)
expect(secondResult.static$('#two-fallback').length).toBe(0)
expect(secondResult.static$('#two').length).toBe(0)
expect(secondResult.dynamicPart).toContain('<div id="one">c</div>')
expect(secondResult.dynamicPart).toContain('<div id="two">bar</div>')
expect(secondResult.dynamicPart).not.toContain(
'<div id="two">foo</div>'
)
if (Date.now() - start < 5000) {
throw new Error('continue polling generic shell')
}
},
6000,
500,
'generic shell should remain unupgraded when partialFallbacks is disabled'
)
})
})