Files
next.js/test/e2e/app-dir/partial-fallback-shell-upgrade/partial-fallback-shell-upgrade.test.ts
Andrew ClarkandGitHub 3717cd3dfb Gate partial fallback shell upgrades behind partialPrefetching for next start (#96297)
In #96074 I put the `partialFallback` behavior behind the
`partialPrefetching` flag, so that we don't risk an explosion in ISR
costs for apps that haven't opted into Partial Prefetching. But it turns
out I only handled this for deploy mode — i.e. the build output consumed
by the Vercel adapter. The behavior was still unconditionally on for
`next start`.

The reason it was easy to miss is that `next start` and the adapter
express partial fallback shells through completely separate mechanisms.
The adapter emits a `partialFallback` flag into the build output and
lets the platform's ISR layer perform the shell upgrade. `next start`
has no such flag; the server performs the upgrade itself, in the
compiled page runtime. So the gate I added to the adapter output had no
effect on the self-hosted path.

To understand how the `next start` path was configured, I looked at the
PR that removed the original top-level `partialFallbacks` config flag
(#93859). Before that PR, the behavior was gated in the runtime; when
the flag was removed those gates were deleted and the behavior became
always-on.

The important subtlety is that only the *upgrade* should be gated, not
the shell machinery as a whole. On `next start`, the value that decides
whether a shell can be specialized (`remainingPrerenderableParams`) also
drives core Cache Components behavior — build-time partial prerendering
of params and serving build-time sub-shells — which must stay on
regardless of `partialPrefetching`. So this gates only the two things
that make up the "upgrade on first request" cost risk: the background
ISR revalidation that specializes a shell per request, and the
client-facing `isFallbackUpgradeable` signal that tells the client to
retry a prefetch waiting for that upgrade.

Adds coverage with a `partialPrefetching`-disabled fixture asserting
that fallback shells stay shared rather than specializing per request.
2026-07-29 19:56:49 +00:00

244 lines
8.4 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 = process.env.NEXT_ENABLE_ADAPTER === '1'
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({
// Deployed shell upgrades require `partialFallback` metadata, which the
// adapter only emits when Partial Prefetching is enabled in the fixture.
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 - partialPrefetching disabled', () => {
const { next, isNextDev } = nextTestSetup({
files: path.join(__dirname, 'fixtures', 'partial-prefetching-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 not upgrade the fallback shell to a route shell', async () => {
const pathname = '/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,
'fallback shell should remain unupgraded without partialPrefetching'
)
})
it('should not specialize a generic shell into a more specific 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.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)
// The generic shell stays shared: `#one` is never baked into the
// static part the way it is when Partial Prefetching is enabled.
expect(secondResult.static$('#one').length).toBe(0)
expect(secondResult.static$('#one-fallback').text()).toBe(
'loading one...'
)
expect(secondResult.dynamicPart).toContain('<div id="one">c</div>')
expect(secondResult.dynamicPart).toContain('<div id="two">bar</div>')
if (Date.now() - start < 5000) {
throw new Error('continue polling generic shell')
}
},
6000,
500,
'generic shell should remain shared without partialPrefetching'
)
})
})