Files
next.js/test/development/pages-dir/client-navigation/rendering.test.ts
Tobias KoppersGitHubTobias KoppersClaudeVercel <vercel[bot]@users.noreply.github.com>
4494261bd7 Remove devCacheControlNoCache experimental option (hard-code no-cache) (#91503)
### What?

Removes the `experimental.devCacheControlNoCache` config option entirely
and hard-codes `no-cache, must-revalidate` as the dev server
`Cache-Control` header value.

Previously the option controlled whether the dev server responded with:
- `no-store, must-revalidate` (default, `false`)
- `no-cache, must-revalidate` (opt-in, `true`)

This PR first flips the default to `true`, then removes the option
altogether — making `no-cache, must-revalidate` unconditional in all dev
code paths.

### Why?

`no-cache` is strictly better than `no-store` for the dev server:
- `no-cache` allows the browser to revalidate (conditional
`If-None-Match`/`If-Modified-Since` requests), letting the server
respond with `304 Not Modified` when nothing changed → faster page loads
during development.
- `no-store` forces a full re-fetch every time, discarding valid cached
responses.

Since `no-cache` is the correct behavior for all dev users, the toggle
has no remaining value and can be removed to simplify the codebase.

### How?

**Two commits:**

1. `bcec825` — Flip the default from `false` → `true`; update
tests/fixtures/manifest to reflect the new default.

2. `e6e919f` — Remove the option entirely:
- Deleted `devCacheControlNoCache?: boolean` from `ExperimentalConfig`
interface, `config-schema.ts` Zod schema, `defaultConfig`,
`NextConfigRuntime`, and `getNextConfigRuntime()`.
- Replaced all four conditional ternaries in `base-server.ts`,
`router-server.ts`, `pages-handler.ts`, and `app-page.ts` with the
hard-coded string `'no-cache, must-revalidate'`.
- Deleted the `dev-cache-control-no-cache-disabled` test suite (was
testing the `false` path which no longer exists).
- Simplified the remaining `dev-cache-control-no-cache` test (removed
experimental framing).
   - Synced `rspack-dev-tests-manifest.json`.

---------

Co-authored-by: Tobias Koppers <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-03-17 20:13:01 +01:00

472 lines
15 KiB
TypeScript

/* eslint-env jest */
import cheerio from 'cheerio'
import { nextTestSetup } from 'e2e-utils'
import { fetchViaHTTP, getDistDir, renderViaHTTP } from 'next-test-utils'
import webdriver from 'next-webdriver'
import { BUILD_MANIFEST, REACT_LOADABLE_MANIFEST } from 'next/constants'
import path from 'path'
const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18
describe('Client Navigation rendering', () => {
const { isTurbopack, next, isRspack } = nextTestSetup({
files: path.join(__dirname, 'fixture'),
env: {
TEST_STRICT_NEXT_HEAD: String(true),
},
})
function render(
pathname: Parameters<typeof renderViaHTTP>[1],
query?: Parameters<typeof renderViaHTTP>[2]
) {
return renderViaHTTP(next.appPort, pathname, query)
}
function fetch(
pathname: Parameters<typeof renderViaHTTP>[1],
query?: Parameters<typeof renderViaHTTP>[2]
) {
return fetchViaHTTP(next.appPort, pathname, query)
}
async function get$(path: any, query?: any) {
const html = await render(path, query)
return cheerio.load(html)
}
describe('Rendering via HTTP', () => {
test('renders a stateless component', async () => {
const html = await render('/stateless')
expect(html).toContain('<meta charSet="utf-8" data-next-head=""/>')
expect(html).toContain('My component!')
})
it('should should not contain scripts that are not js', async () => {
const $ = await get$('/')
$('script[src]').each((_index, element) => {
const parsedUrl = new URL($(element).attr('src'), next.url)
if (!parsedUrl.pathname.endsWith('.js')) {
throw new Error(
`Page includes script that is not a javascript file ${parsedUrl.pathname}`
)
}
})
})
test('renders with fragment syntax', async () => {
const html = await render('/fragment-syntax')
expect(html.includes('My component!')).toBeTruthy()
})
test('renders when component is a forwardRef instance', async () => {
const html = await render('/forwardRef-component')
expect(
html.includes('This is a component with a forwarded ref')
).toBeTruthy()
})
test('renders when component is a memo instance', async () => {
const html = await render('/memo-component')
expect(html.includes('Memo component')).toBeTruthy()
})
it('should render the page with custom extension', async () => {
const html = await render('/custom-extension')
expect(html).toContain('<div>Hello</div>')
expect(html).toContain('<div>World</div>')
})
it('should render the page without `err` property', async () => {
const html = await render('/')
expect(html).not.toContain('"err"')
})
it('should render the page with `nextExport` property', async () => {
const html = await render('/')
expect(html).toContain('"nextExport"')
})
it('should render the page without `nextExport` property', async () => {
const html = await render('/async-props')
expect(html).not.toContain('"nextExport"')
})
test('renders styled jsx', async () => {
const $ = await get$('/styled-jsx')
const styleId = $('#blue-box').attr('class')
const style = $('style')
expect(style.text()).toMatch(
new RegExp(`p.${styleId}{color:(?:blue|#00f)`)
)
})
test('renders styled jsx external', async () => {
const $ = await get$('/styled-jsx-external')
const styleId = $('#blue-box').attr('class')
const style = $('style')
expect(style.text()).toMatch(
new RegExp(`p.${styleId}{color:(?:blue|#00f)`)
)
})
test('renders properties populated asynchronously', async () => {
const html = await render('/async-props')
expect(html.includes('Diego Milito')).toBeTruthy()
})
test('renders a link component', async () => {
const $ = await get$('/link')
const link = $('a[href="/about"]')
expect(link.text()).toBe('About')
})
test('getInitialProps circular structure', async () => {
const browser = await webdriver(next.appPort, '/circular-json-error')
if (isReact18 && isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "Circular structure in "getInitialProps" result of page "/circular-json-error". https://nextjs.org/docs/messages/circular-structure",
"environmentLabel": null,
"label": "Runtime Error",
"source": null,
"stack": [
"new Promise <anonymous>",
],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "Circular structure in "getInitialProps" result of page "/circular-json-error". https://nextjs.org/docs/messages/circular-structure",
"environmentLabel": null,
"label": "Runtime Error",
"source": null,
"stack": [],
}
`)
}
})
test('getInitialProps should be class method', async () => {
const browser = await webdriver(
next.appPort,
'/instance-get-initial-props'
)
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": ""InstanceInitialPropsPage.getInitialProps()" is defined as an instance method - visit https://nextjs.org/docs/messages/get-initial-props-as-an-instance-method for more information.",
"environmentLabel": null,
"label": "Runtime Error",
"source": null,
"stack": [],
}
`)
})
test('getInitialProps resolves to null', async () => {
const browser = await webdriver(next.appPort, '/empty-get-initial-props')
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": ""EmptyInitialPropsPage.getInitialProps()" should resolve to an object. But found "null" instead.",
"environmentLabel": null,
"label": "Runtime Error",
"source": null,
"stack": [],
}
`)
})
test('default Content-Type', async () => {
const res = await fetch('/stateless')
expect(res.headers.get('Content-Type')).toMatch(
'text/html; charset=utf-8'
)
})
test('setting Content-Type in getInitialProps', async () => {
const res = await fetch('/custom-encoding')
expect(res.headers.get('Content-Type')).toMatch(
'text/html; charset=iso-8859-2'
)
})
test('should render 404 for _next routes that do not exist', async () => {
const res = await fetch('/_next/abcdef')
expect(res.status).toBe(404)
})
test('should render page that has module.exports anywhere', async () => {
const res = await fetch('/exports')
expect(res.status).toBe(200)
})
test('allows to import .json files', async () => {
const html = await render('/json')
expect(html.includes('Vercel')).toBeTruthy()
})
test('default export is not a React Component', async () => {
const browser = await webdriver(next.appPort, '/no-default-export')
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "The default export is not a React Component in page: "/no-default-export"",
"environmentLabel": null,
"label": "Runtime Error",
"source": null,
"stack": [],
}
`)
})
test('error-inside-page', async () => {
const browser = await webdriver(next.appPort, '/error-inside-page')
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "This is an expected error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "pages/error-inside-page.js (2:9) @ {default export}
> 2 | throw new Error('This is an expected error')
| ^",
"stack": [
"{default export} pages/error-inside-page.js (2:9)",
],
}
`)
} else if (isRspack) {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "This is an expected error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "pages/error-inside-page.js (2:9) @ __rspack_default_export
> 2 | throw new Error('This is an expected error')
| ^",
"stack": [
"__rspack_default_export pages/error-inside-page.js (2:9)",
],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "This is an expected error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "pages/error-inside-page.js (2:9) @ default
> 2 | throw new Error('This is an expected error')
| ^",
"stack": [
"default pages/error-inside-page.js (2:9)",
],
}
`)
}
})
test('error-in-the-global-scope', async () => {
const browser = await webdriver(
next.appPort,
'/error-in-the-global-scope'
)
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "aa is not defined",
"environmentLabel": null,
"label": "Runtime ReferenceError",
"source": "pages/error-in-the-global-scope.js (1:1) @ module evaluation
> 1 | aa = 10 //eslint-disable-line
| ^",
"stack": [
"module evaluation pages/error-in-the-global-scope.js (1:1)",
"<FIXME-next-dist-dir>",
],
}
`)
} else if (isRspack) {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "aa is not defined",
"environmentLabel": null,
"label": "Runtime ReferenceError",
"source": "pages/error-in-the-global-scope.js (1:1) @ eval
> 1 | aa = 10 //eslint-disable-line
| ^",
"stack": [
"eval pages/error-in-the-global-scope.js (1:1)",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "aa is not defined",
"environmentLabel": null,
"label": "Runtime ReferenceError",
"source": "pages/error-in-the-global-scope.js (1:1) @ eval
> 1 | aa = 10 //eslint-disable-line
| ^",
"stack": [
"eval pages/error-in-the-global-scope.js (1:1)",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
],
}
`)
}
})
it('should set Cache-Control header', async () => {
// build dynamic page
await fetch('/dynamic/ssr')
const buildManifest = await next.readJSON(
`${getDistDir()}/${BUILD_MANIFEST}`
)
const reactLoadableManifest = await next.readJSON(
process.env.IS_TURBOPACK_TEST
? `${getDistDir()}/server/pages/dynamic/ssr/${REACT_LOADABLE_MANIFEST}`
: `${getDistDir()}/${REACT_LOADABLE_MANIFEST}`
)
const resources = []
const manifestKey = Object.keys(reactLoadableManifest).find((item) => {
return item
.replace(/\\/g, '/')
.endsWith(
process.env.IS_TURBOPACK_TEST
? 'components/hello1.js [client] (ecmascript, next/dynamic entry)'
: 'ssr.js -> ../../components/hello1'
)
})
expect(manifestKey).toBeString()
// test dynamic chunk
resources.push('/_next/' + reactLoadableManifest[manifestKey].files[0])
// test main.js runtime etc
for (const item of buildManifest.pages['/dynamic/ssr']) {
resources.push('/_next/' + item)
}
for (const item of buildManifest.devFiles) {
resources.push('/_next/' + item)
}
const responses = await Promise.all(
resources.map((resource) => fetch(resource))
)
responses.forEach((res) => {
try {
expect(res.headers.get('Cache-Control')).toBe(
'no-cache, must-revalidate'
)
} catch (err) {
err.message = res.url + ' ' + err.message
throw err
}
})
})
test('asPath', async () => {
const $ = await get$('/nav/as-path', { aa: 10 })
expect($('.as-path-content').text()).toBe('/nav/as-path?aa=10')
})
describe('404', () => {
it('should 404 on not existent page', async () => {
const $ = await get$('/non-existent')
expect($('h1').text()).toBe('404')
expect($('h2').text()).toBe('This page could not be found.')
})
it('should 404 on wrong casing', async () => {
const $ = await get$('/NaV/aBoUt')
expect($('h1').text()).toBe('404')
expect($('h2').text()).toBe('This page could not be found.')
})
it('should not 404 for <page>/', async () => {
const $ = await get$('/nav/about/')
expect($('.nav-about p').text()).toBe('This is the about page.')
})
it('should should not contain a page script in a 404 page', async () => {
const $ = await get$('/non-existent')
$('script[src]').each((index, element) => {
const src = $(element).attr('src')
if (src.includes('/non-existent')) {
throw new Error('Page includes page script')
}
})
})
})
describe('with the HOC based router', () => {
it('should navigate as expected', async () => {
const $ = await get$('/nav/with-hoc')
expect($('#pathname').text()).toBe('Current path: /nav/with-hoc')
})
it('should include asPath', async () => {
const $ = await get$('/nav/with-hoc')
expect($('#asPath').text()).toBe('Current asPath: /nav/with-hoc')
})
})
it('should show a valid error when undefined is thrown', async () => {
const browser = await webdriver(next.appPort, '/throw-undefined')
await expect(browser).toDisplayRedbox(`
{
"code": "E394",
"description": "An undefined error was thrown, see here for more info: https://nextjs.org/docs/messages/threw-undefined",
"environmentLabel": null,
"label": "Runtime Error",
"source": null,
"stack": [],
}
`)
})
})
})