From 0fc4074ba04ea03168ce16d19a4c953ede4545bd Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Thu, 9 Apr 2026 08:51:07 -0700 Subject: [PATCH 1/4] safari fix --- packages/next/src/pages/_document.tsx | 10 ++-- packages/next/src/server/render-result.ts | 2 - packages/next/src/server/render.tsx | 48 ++++++++++--------- .../shared/lib/html-context.shared-runtime.ts | 13 +++++ test/e2e/app-document/rendering.test.ts | 26 ++++++++-- 5 files changed, 65 insertions(+), 34 deletions(-) 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..9476cc4b49af 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -458,35 +458,38 @@ export async function renderToHTMLImpl( // Adds support for reading `cookies` in `getServerSideProps` when SSR. setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers)) - let baseAssetQueryString = + // cacheBuster 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. + let cacheBuster = (process.env.__NEXT_DEV_SERVER && renderOpts.assetQueryString) || '' - if (process.env.__NEXT_DEV_SERVER && !baseAssetQueryString) { + if (process.env.__NEXT_DEV_SERVER && !cacheBuster) { 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()}` + cacheBuster = `?ts=${Date.now()}` } } - const mutableAssetQueryString = - baseAssetQueryString + - (sharedContext.deploymentId - ? `${baseAssetQueryString ? '&' : '?'}dpl=${sharedContext.deploymentId}` - : '') - const assetQueryString = - baseAssetQueryString + + 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 = + cacheBuster + (sharedContext.clientAssetToken - ? `${baseAssetQueryString ? '&' : '?'}dpl=${sharedContext.clientAssetToken}` + ? `${cacheBuster ? '&' : '?'}dpl=${sharedContext.clientAssetToken}` : '') - const metadata: PagesRenderResultMetadata = { - assetQueryString, - mutableAssetQueryString, - } + const metadata: PagesRenderResultMetadata = {} // don't modify original query object query = Object.assign({}, query) @@ -1530,8 +1533,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..1070ea4acae9 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,19 @@ export type HtmlProps = { unstable_JsPreload?: false assetQueryString: string mutableAssetQueryString: string + /** + * Asset query string for CSS and font assets. Includes both the deployment + * token (if any) and a cache-busting parameter for Safari + * (https://bugs.webkit.org/show_bug.cgi?id=187726) in dev. Use this for + * ``, ``, and font + * preload tags. + * + * Must NOT be used for script tags — the Turbopack runtime infers + * ASSET_SUFFIX from the executing script's query string and leaks it onto + * all static asset URLs, causing next/image validation errors. + * 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=/) }) }) } From 56b45200add638da84a0747ca425ea369babf5da Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Thu, 9 Apr 2026 13:05:28 -0700 Subject: [PATCH 2/4] simplify comment --- .../next/src/shared/lib/html-context.shared-runtime.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) 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 1070ea4acae9..457822ed4bb6 100644 --- a/packages/next/src/shared/lib/html-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/html-context.shared-runtime.ts @@ -32,15 +32,7 @@ export type HtmlProps = { assetQueryString: string mutableAssetQueryString: string /** - * Asset query string for CSS and font assets. Includes both the deployment - * token (if any) and a cache-busting parameter for Safari - * (https://bugs.webkit.org/show_bug.cgi?id=187726) in dev. Use this for - * ``, ``, and font - * preload tags. - * - * Must NOT be used for script tags — the Turbopack runtime infers - * ASSET_SUFFIX from the executing script's query string and leaks it onto - * all static asset URLs, causing next/image validation errors. + * Asset query string for CSS and font assets. * See https://github.com/vercel/next.js/issues/92118. */ cssAssetQueryString: string From 35e69fc9ba60d39aa1ba710c6850f56e03065fc2 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Tue, 14 Apr 2026 10:17:10 -0700 Subject: [PATCH 3/4] simplify logic --- packages/next/src/server/render.tsx | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 9476cc4b49af..a69db93eae36 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,18 @@ function serializeError( } } +function getSafariCacheBusterQueryString( + req: IncomingMessage +): string | undefined { + 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 undefined +} + export async function renderToHTMLImpl( req: IncomingMessage, res: ServerResponse, @@ -458,7 +469,7 @@ export async function renderToHTMLImpl( // Adds support for reading `cookies` in `getServerSideProps` when SSR. setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers)) - // cacheBuster is a workaround for a Safari bug + // 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 @@ -466,15 +477,7 @@ export async function renderToHTMLImpl( // leaks it onto all static asset URLs (including images), causing // next/image validation errors. // See https://github.com/vercel/next.js/issues/92118. - let cacheBuster = - (process.env.__NEXT_DEV_SERVER && renderOpts.assetQueryString) || '' - - if (process.env.__NEXT_DEV_SERVER && !cacheBuster) { - const userAgent = (req.headers['user-agent'] || '').toLowerCase() - if (userAgent.includes('safari') && !userAgent.includes('chrome')) { - cacheBuster = `?ts=${Date.now()}` - } - } + const cssCacheBuster = getSafariCacheBusterQueryString(req) const mutableAssetQueryString = sharedContext.deploymentId ? `?dpl=${sharedContext.deploymentId}` @@ -485,9 +488,9 @@ export async function renderToHTMLImpl( // cssAssetQueryString is assetQueryString with the cacheBuster prepended. // Use this for CSS and font URLs; use assetQueryString for script URLs. const cssAssetQueryString = - cacheBuster + + cssCacheBuster + (sharedContext.clientAssetToken - ? `${cacheBuster ? '&' : '?'}dpl=${sharedContext.clientAssetToken}` + ? `${cssCacheBuster ? '&' : '?'}dpl=${sharedContext.clientAssetToken}` : '') const metadata: PagesRenderResultMetadata = {} From 5c52bc90788da039529222626980df1212ec20c0 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Tue, 14 Apr 2026 16:24:58 -0700 Subject: [PATCH 4/4] Update render.tsx --- packages/next/src/server/render.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index a69db93eae36..8ebfe8e9d0a3 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -444,16 +444,14 @@ function serializeError( } } -function getSafariCacheBusterQueryString( - req: IncomingMessage -): string | undefined { +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 undefined + return '' } export async function renderToHTMLImpl(