Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions packages/next/src/pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export class Head extends React.Component<HeadProps> {
getCssLinks(files: DocumentFiles): JSX.Element[] | null {
const {
assetPrefix,
assetQueryString,
cssAssetQueryString,
dynamicImports,
dynamicCssManifest,
crossOrigin,
Expand Down Expand Up @@ -422,7 +422,7 @@ export class Head extends React.Component<HeadProps> {
rel="preload"
href={`${assetPrefix}/_next/${encodeURIPath(
file
)}${assetQueryString}`}
)}${cssAssetQueryString}`}
as="style"
crossOrigin={this.props.crossOrigin || crossOrigin}
/>
Expand All @@ -436,7 +436,7 @@ export class Head extends React.Component<HeadProps> {
rel="stylesheet"
href={`${assetPrefix}/_next/${encodeURIPath(
file
)}${assetQueryString}`}
)}${cssAssetQueryString}`}
crossOrigin={this.props.crossOrigin || crossOrigin}
data-n-g={isUnmanagedFile ? undefined : isSharedFile ? '' : undefined}
data-n-p={
Expand Down Expand Up @@ -589,7 +589,7 @@ export class Head extends React.Component<HeadProps> {
optimizeCss,
assetPrefix,
nextFontManifest,
assetQueryString,
cssAssetQueryString,
} = this.context

const disableRuntimeJS = unstable_runtimeJS === false
Expand Down Expand Up @@ -659,7 +659,7 @@ export class Head extends React.Component<HeadProps> {
nextFontManifest,
dangerousAsPath,
assetPrefix,
assetQueryString
cssAssetQueryString
)

const tracingMetadata = getTracedMetadata(
Expand Down
2 changes: 0 additions & 2 deletions packages/next/src/server/render-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ export type AppPageRenderResultMetadata = {
export type PagesRenderResultMetadata = {
pageData?: any
cacheControl?: CacheControl
assetQueryString?: string
mutableAssetQueryString?: string
isNotFound?: boolean
isRedirect?: boolean
}
Expand Down
65 changes: 35 additions & 30 deletions packages/next/src/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,6 @@ export type RenderOptsPartial = {
optimizeCss: any
nextConfigOutput?: 'standalone' | 'export'
nextScriptWorkers: any
assetQueryString?: string
resolvedUrl?: string
resolvedAsPath?: string
setIsrStatus?: (key: string, value: boolean | undefined) => void
Expand Down Expand Up @@ -445,6 +444,16 @@ function serializeError(
}
}

function getSafariCacheBusterQueryString(req: IncomingMessage): string {
if (process.env.__NEXT_DEV_SERVER) {
const userAgent = (req.headers['user-agent'] || '').toLowerCase()
if (userAgent.includes('safari') && !userAgent.includes('chrome')) {
return `?ts=${Date.now()}`
}
}
return ''
}

export async function renderToHTMLImpl(
req: IncomingMessage,
res: ServerResponse,
Expand All @@ -458,35 +467,30 @@ export async function renderToHTMLImpl(
// Adds support for reading `cookies` in `getServerSideProps` when SSR.
setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers))

let baseAssetQueryString =
(process.env.__NEXT_DEV_SERVER && renderOpts.assetQueryString) || ''

if (process.env.__NEXT_DEV_SERVER && !baseAssetQueryString) {
const userAgent = (req.headers['user-agent'] || '').toLowerCase()
if (userAgent.includes('safari') && !userAgent.includes('chrome')) {
// In dev we invalidate the cache by appending a timestamp to the resource URL.
// This is a workaround to fix https://github.com/vercel/next.js/issues/5860
// TODO: remove this workaround when https://bugs.webkit.org/show_bug.cgi?id=187726 is fixed.
// Note: The workaround breaks breakpoints on reload since the script url always changes,
// so we only apply it to Safari.
baseAssetQueryString = `?ts=${Date.now()}`
}
}

const mutableAssetQueryString =
baseAssetQueryString +
(sharedContext.deploymentId
? `${baseAssetQueryString ? '&' : '?'}dpl=${sharedContext.deploymentId}`
: '')
const assetQueryString =
baseAssetQueryString +
// cssCacheBuster is a workaround for a Safari bug
// (https://bugs.webkit.org/show_bug.cgi?id=187726) where preloaded CSS
// resources are cached and not re-fetched on HMR. It must only be applied
// to CSS and font assets — not to script tags — because the Turbopack
// runtime infers ASSET_SUFFIX from the executing script's query string and
// leaks it onto all static asset URLs (including images), causing
// next/image validation errors.
// See https://github.com/vercel/next.js/issues/92118.
const cssCacheBuster = getSafariCacheBusterQueryString(req)

const mutableAssetQueryString = sharedContext.deploymentId
? `?dpl=${sharedContext.deploymentId}`
: ''
const assetQueryString = sharedContext.clientAssetToken
? `?dpl=${sharedContext.clientAssetToken}`
: ''
// cssAssetQueryString is assetQueryString with the cacheBuster prepended.
// Use this for CSS and font URLs; use assetQueryString for script URLs.
const cssAssetQueryString =
cssCacheBuster +
(sharedContext.clientAssetToken
? `${baseAssetQueryString ? '&' : '?'}dpl=${sharedContext.clientAssetToken}`
? `${cssCacheBuster ? '&' : '?'}dpl=${sharedContext.clientAssetToken}`
: '')
const metadata: PagesRenderResultMetadata = {
assetQueryString,
mutableAssetQueryString,
}
const metadata: PagesRenderResultMetadata = {}

// don't modify original query object
query = Object.assign({}, query)
Expand Down Expand Up @@ -1530,8 +1534,9 @@ export async function renderToHTMLImpl(
? pageConfig.unstable_runtimeJS
: undefined,
unstable_JsPreload: pageConfig.unstable_JsPreload,
assetQueryString: assetQueryString || '',
mutableAssetQueryString: mutableAssetQueryString || '',
assetQueryString,
cssAssetQueryString,
mutableAssetQueryString,
scriptLoader,
locale,
disableOptimizedLoading,
Expand Down
5 changes: 5 additions & 0 deletions packages/next/src/shared/lib/html-context.shared-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export type HtmlProps = {
unstable_JsPreload?: false
assetQueryString: string
mutableAssetQueryString: string
/**
* Asset query string for CSS and font assets.
* See https://github.com/vercel/next.js/issues/92118.
*/
cssAssetQueryString: string
scriptLoader: {
afterInteractive?: string[]
beforeInteractive?: any[]
Expand Down
26 changes: 21 additions & 5 deletions test/e2e/app-document/rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,35 @@ describe('Document and App - Rendering via HTTP', () => {
})

if (isNextDev) {
// This is a workaround to fix https://github.com/vercel/next.js/issues/5860
// TODO: remove this workaround when https://bugs.webkit.org/show_bug.cgi?id=187726 is fixed.
it('adds a timestamp to link tags with preload attribute to invalidate the cache in dev', async () => {
// 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' },
})
$('link[rel=preload]').each((index, element) => {
// 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).toMatch(/^[^?]+\?ts=\d+$/)
expect(src).not.toMatch(/[?&]ts=/)
})
})
}
Expand Down
Loading