mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
dde2980379
### What? Refactors the Safari `?ts=` cache-busting workaround in the Pages Router so it **only appears on CSS and font URLs**, not on script tags or script preload links. ### Why? The `?ts=` timestamp was originally added to all preloaded assets as a workaround for a [Safari caching bug](https://bugs.webkit.org/show_bug.cgi?id=187726) (see [#5860](https://github.com/vercel/next.js/issues/5860)). When it appears on `<script>` tags, the Turbopack runtime's `getAssetSuffixFromScriptSrc()` reads the executing script's query string and infers it as the `ASSET_SUFFIX`, which then leaks onto all static asset URLs — including images. This causes `next/image` validation errors because the image URL gets an unexpected `?ts=` parameter. Fixes #92118 ### How? Instead of maintaining parallel `assetQueryString` (with `?ts=`) and `scriptAssetQueryString` (without `?ts=`) paths, this PR: 1. **`assetQueryString`** carries only the deployment token (`?dpl=...`) and is used for all script-related URLs (script tags, script preloads) 2. **`safariCacheBuster`** is a new, separate field (`?ts=<timestamp>` or `""`) that is only combined with `assetQueryString` at the 3 CSS/font URL sites via a `joinQueryStrings()` helper 3. Removes the `scriptAssetQueryString` / `scriptMutableAssetQueryString` / `cssAssetQueryString` fields entirely The Safari cache-buster is computed the same way as before (dev server only, Safari user-agent check) — it just no longer contaminates the base query string. ### Test plan - [x] Updated existing test in `test/e2e/app-document/rendering.test.ts` to assert `?ts=` appears only on `<link rel="preload" as="style">` and `<link rel="preload" as="font">`, and does NOT appear on `<script>` or `<link rel="preload" as="script">` - [x] `pnpm --filter=next types` passes <!-- NEXT_JS_LLM_PR -->
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
import { retry } from 'next-test-utils'
|
|
import { nextTestSetup } from 'e2e-utils'
|
|
|
|
describe('Document and App - Rendering via HTTP', () => {
|
|
const { next, isNextDev } = nextTestSetup({
|
|
files: __dirname,
|
|
})
|
|
|
|
describe('_document', () => {
|
|
it('should include required elements in rendered html', async () => {
|
|
const $ = await next.render$('/')
|
|
// It has a custom html class
|
|
expect($('html').hasClass('test-html-props')).toBe(true)
|
|
// It has a custom body class
|
|
expect($('body').hasClass('custom_class')).toBe(true)
|
|
// It injects custom head tags
|
|
expect($('head').text()).toMatch('body { margin: 0 }')
|
|
// It has __NEXT_DATA__ script tag
|
|
expect($('script#__NEXT_DATA__')).toBeTruthy()
|
|
// It passes props from Document.getInitialProps to Document
|
|
expect($('#custom-property').text()).toBe('Hello Document')
|
|
})
|
|
|
|
it('Document.getInitialProps returns html prop representing app shell', async () => {
|
|
// Extract css-in-js-class from the rendered HTML, which is returned by Document.getInitialProps
|
|
const $index = await next.render$('/')
|
|
const $about = await next.render$('/about')
|
|
expect($index('#css-in-cjs-count').text()).toBe('2')
|
|
expect($about('#css-in-cjs-count').text()).toBe('0')
|
|
})
|
|
|
|
it('adds nonces to all scripts and preload links', async () => {
|
|
const $ = await next.render$('/')
|
|
const nonce = 'test-nonce'
|
|
let noncesAdded = true
|
|
$('script, link[rel=preload]').each((index, element) => {
|
|
if ($(element).attr('nonce') !== nonce) noncesAdded = false
|
|
})
|
|
expect(noncesAdded).toBe(true)
|
|
})
|
|
|
|
it('adds crossOrigin to all scripts and preload links', async () => {
|
|
const $ = await next.render$('/')
|
|
const crossOrigin = 'anonymous'
|
|
$('script, link[rel=preload]').each((index, element) => {
|
|
expect($(element).attr('crossorigin') === crossOrigin).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
it('renders ctx.renderPage with enhancer correctly', async () => {
|
|
const $ = await next.render$('/?withEnhancer=true')
|
|
const nonce = 'RENDERED'
|
|
expect($('#render-page-enhance-component').text().includes(nonce)).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
it('renders ctx.renderPage with enhanceComponent correctly', async () => {
|
|
const $ = await next.render$('/?withEnhanceComponent=true')
|
|
const nonce = 'RENDERED'
|
|
expect($('#render-page-enhance-component').text().includes(nonce)).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
it('renders ctx.renderPage with enhanceApp correctly', async () => {
|
|
const $ = await next.render$('/?withEnhanceApp=true')
|
|
const nonce = 'RENDERED'
|
|
expect($('#render-page-enhance-app').text().includes(nonce)).toBe(true)
|
|
})
|
|
|
|
it('renders ctx.renderPage with enhanceApp and enhanceComponent correctly', async () => {
|
|
const $ = await next.render$(
|
|
'/?withEnhanceComponent=true&withEnhanceApp=true'
|
|
)
|
|
const nonce = 'RENDERED'
|
|
expect($('#render-page-enhance-app').text().includes(nonce)).toBe(true)
|
|
expect($('#render-page-enhance-component').text().includes(nonce)).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
if (isNextDev) {
|
|
// The ?ts= timestamp is a workaround for a Safari preload cache bug:
|
|
// https://github.com/vercel/next.js/issues/5860
|
|
// https://bugs.webkit.org/show_bug.cgi?id=187726
|
|
// It must only appear on CSS/font resources, not on script tags, because
|
|
// the Turbopack runtime reads ASSET_SUFFIX from the executing script's
|
|
// query string and would leak it onto image URLs.
|
|
it('adds a timestamp only to CSS/font link tags to invalidate the cache in dev', async () => {
|
|
const $ = await next.render$('/', undefined, {
|
|
headers: { 'user-agent': 'Safari' },
|
|
})
|
|
// CSS preload links must have ?ts= for Safari cache busting
|
|
$('link[rel=preload][as=style]').each((index, element) => {
|
|
const href = $(element).attr('href')
|
|
expect(href).toMatch(/^[^?]+\?ts=\d+$/)
|
|
})
|
|
// Font preload links must have ?ts= for Safari cache busting
|
|
$('link[rel=preload][as=font]').each((index, element) => {
|
|
const href = $(element).attr('href')
|
|
expect(href).toMatch(/^[^?]+\?ts=\d+$/)
|
|
})
|
|
// Script preload links must NOT have ?ts= (Turbopack ASSET_SUFFIX bug)
|
|
$('link[rel=preload][as=script]').each((index, element) => {
|
|
const src = $(element).attr('href')
|
|
expect(src).not.toMatch(/[?&]ts=/)
|
|
})
|
|
// Script tags must NOT have ?ts= (Turbopack ASSET_SUFFIX bug)
|
|
$('script[src]').each((index, element) => {
|
|
const src = $(element).attr('src')
|
|
expect(src).not.toMatch(/[?&]ts=/)
|
|
})
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('_app', () => {
|
|
it('shows a custom tag', async () => {
|
|
const $ = await next.render$('/')
|
|
expect($('#hello-app').text()).toBe('Hello App')
|
|
})
|
|
|
|
// For example react context uses shared module state
|
|
// Also known as singleton modules
|
|
it('should share module state with pages', async () => {
|
|
const $ = await next.render$('/shared')
|
|
expect($('#currentstate').text()).toBe('UPDATED')
|
|
})
|
|
|
|
if (isNextDev) {
|
|
it('should show valid error when thrown in _app getInitialProps', async () => {
|
|
const errMsg = 'have an error from _app getInitialProps'
|
|
const origContent = await next.readFile('pages/_app.js')
|
|
|
|
expect(await next.render('/')).toContain('page-index')
|
|
|
|
await next.patchFile(
|
|
'pages/_app.js',
|
|
origContent.replace(
|
|
'// throw _app GIP err here',
|
|
`throw new Error("${errMsg}")`
|
|
)
|
|
)
|
|
|
|
let foundErr = false
|
|
try {
|
|
await retry(async () =>
|
|
expect(await next.render('/')).toContain(errMsg)
|
|
)
|
|
foundErr = true
|
|
} finally {
|
|
await next.patchFile('pages/_app.js', origContent)
|
|
|
|
// Make sure _app is restored
|
|
await retry(async () =>
|
|
expect(await next.render('/')).toContain('page-index')
|
|
)
|
|
expect(foundErr).toBeTruthy()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|