diff --git a/packages/next/src/pages/_document.tsx b/packages/next/src/pages/_document.tsx index de5db41c80de..117ad774a2df 100644 --- a/packages/next/src/pages/_document.tsx +++ b/packages/next/src/pages/_document.tsx @@ -384,7 +384,7 @@ export class Head extends React.Component { getCssLinks(files: DocumentFiles): JSX.Element[] | null { const { assetPrefix, - assetQueryString, + cssAssetQueryString, dynamicImports, dynamicCssManifest, crossOrigin, @@ -422,7 +422,7 @@ export class Head extends React.Component { rel="preload" href={`${assetPrefix}/_next/${encodeURIPath( file - )}${assetQueryString}`} + )}${cssAssetQueryString}`} as="style" crossOrigin={this.props.crossOrigin || crossOrigin} /> @@ -436,7 +436,7 @@ export class Head extends React.Component { rel="stylesheet" href={`${assetPrefix}/_next/${encodeURIPath( file - )}${assetQueryString}`} + )}${cssAssetQueryString}`} crossOrigin={this.props.crossOrigin || crossOrigin} data-n-g={isUnmanagedFile ? undefined : isSharedFile ? '' : undefined} data-n-p={ @@ -589,7 +589,7 @@ export class Head extends React.Component { optimizeCss, assetPrefix, nextFontManifest, - assetQueryString, + cssAssetQueryString, } = this.context const disableRuntimeJS = unstable_runtimeJS === false @@ -659,7 +659,7 @@ export class Head extends React.Component { nextFontManifest, dangerousAsPath, assetPrefix, - assetQueryString + cssAssetQueryString ) const tracingMetadata = getTracedMetadata( diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index a9f4fe0e74c2..3788f475a1b4 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -72,8 +72,6 @@ export type AppPageRenderResultMetadata = { export type PagesRenderResultMetadata = { pageData?: any cacheControl?: CacheControl - assetQueryString?: string - mutableAssetQueryString?: string isNotFound?: boolean isRedirect?: boolean } diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index f2d70c2f2736..8ebfe8e9d0a3 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -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 @@ -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, @@ -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) @@ -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, diff --git a/packages/next/src/shared/lib/html-context.shared-runtime.ts b/packages/next/src/shared/lib/html-context.shared-runtime.ts index e998af5f4a7c..457822ed4bb6 100644 --- a/packages/next/src/shared/lib/html-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/html-context.shared-runtime.ts @@ -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[] diff --git a/test/e2e/app-document/rendering.test.ts b/test/e2e/app-document/rendering.test.ts index 80c7f60390bc..b6510e47b0b0 100644 --- a/test/e2e/app-document/rendering.test.ts +++ b/test/e2e/app-document/rendering.test.ts @@ -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=/) }) }) }