mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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.
244 lines
8.4 KiB
TypeScript
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'
|
|
)
|
|
})
|
|
})
|