From 97be2e672883a797ada61ebf0e1909816ea35c78 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 30 Apr 2023 22:50:58 +0200 Subject: [PATCH] Fix CSS ordering issue with HMR (#49010) Closes #48807. The issue seems to be introduced with recent React Float change, which isn't a real problem but a behavior change. Resources are layered by the `precedence` key and the style insertion logic can be simplified as "insert the new stylesheet right after the existing stylesheet in the same layer". When multiple stylesheets are inserted in the same render pass, their new order will be flipped. This is a nice feature so we can always maintain the order of resources that might conflict. --- packages/next/src/client/app-link-gc.ts | 4 +- .../next/src/server/app-render/app-render.tsx | 78 ++++++++++++------- test/e2e/app-dir/app-css/index.test.ts | 35 ++++++++- 3 files changed, 87 insertions(+), 30 deletions(-) diff --git a/packages/next/src/client/app-link-gc.ts b/packages/next/src/client/app-link-gc.ts index faacd087aec5c..ef231e9fc6268 100644 --- a/packages/next/src/client/app-link-gc.ts +++ b/packages/next/src/client/app-link-gc.ts @@ -10,7 +10,7 @@ export function linkGc() { (node as HTMLLinkElement).tagName === 'LINK' ) { const link = node as HTMLLinkElement - if (link.dataset.precedence === 'next.js') { + if (link.dataset.precedence?.startsWith('next')) { const href = link.getAttribute('href') if (href) { const [resource, version] = href.split('?v=') @@ -19,7 +19,7 @@ export function linkGc() { `link[href^="${resource}"]` ) as NodeListOf for (const otherLink of allLinks) { - if (otherLink.dataset.precedence === 'next.js') { + if (otherLink.dataset.precedence?.startsWith('next')) { const otherHref = otherLink.getAttribute('href') if (otherHref) { const [, otherVersion] = otherHref.split('?v=') diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 92dfa6cb1c46e..7c68f33a76a0b 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -377,23 +377,39 @@ export async function renderToHTMLOrFlight( ) const styles = cssHrefs - ? cssHrefs.map((href, index) => ( - - )) + ? cssHrefs.map((href, index) => { + // In dev, Safari and Firefox will cache the resource during HMR: + // - https://github.com/vercel/next.js/issues/5860 + // - https://bugs.webkit.org/show_bug.cgi?id=187726 + // Because of this, we add a `?v=` query to bypass the cache during + // development. We need to also make sure that the number is always + // increasing. + const fullHref = `${assetPrefix}/_next/${href}${ + process.env.NODE_ENV === 'development' ? `?v=${Date.now()}` : '' + }` + + // `Precedence` is an opt-in signal for React to handle resource + // loading and deduplication, etc. It's also used as the key to sort + // resources so they will be injected in the correct order. + // During HMR, it's critical to use different `precedence` values + // for different stylesheets, so their order will be kept. + // https://github.com/facebook/react/pull/25060 + const precedence = shouldPreload + ? process.env.NODE_ENV === 'development' + ? 'next_' + href + : 'next' + : undefined + + return ( + + ) + }) : null const Comp = interopDefault(await getComponent()) @@ -454,25 +470,33 @@ export async function renderToHTMLOrFlight( const styles = stylesheets ? stylesheets.map((href, index) => { + // In dev, Safari and Firefox will cache the resource during HMR: + // - https://github.com/vercel/next.js/issues/5860 + // - https://bugs.webkit.org/show_bug.cgi?id=187726 + // Because of this, we add a `?v=` query to bypass the cache during + // development. We need to also make sure that the number is always + // increasing. const fullHref = `${assetPrefix}/_next/${href}${ process.env.NODE_ENV === 'development' ? `?v=${Date.now()}` : '' }` + + // `Precedence` is an opt-in signal for React to handle resource + // loading and deduplication, etc. It's also used as the key to sort + // resources so they will be injected in the correct order. + // During HMR, it's critical to use different `precedence` values + // for different stylesheets, so their order will be kept. + // https://github.com/facebook/react/pull/25060 + const precedence = + process.env.NODE_ENV === 'development' ? 'next_' + href : 'next' + ComponentMod.preloadStyle(fullHref) + return ( ) diff --git a/test/e2e/app-dir/app-css/index.test.ts b/test/e2e/app-dir/app-css/index.test.ts index 94f0ef1b4a50c..cc9c58b8bb00a 100644 --- a/test/e2e/app-dir/app-css/index.test.ts +++ b/test/e2e/app-dir/app-css/index.test.ts @@ -254,6 +254,39 @@ createNextDescribe( }) if (isDev) { + it('should not affect css orders during HMR', async () => { + const filePath = 'app/ordering/page.js' + const origContent = await next.readFile(filePath) + + // h1 should be red + const browser = await next.browser('/ordering') + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('h1')).color` + ) + ).toBe('rgb(255, 0, 0)') + + try { + await next.patchFile( + filePath, + origContent.replace('

Hello

', '

Hello!

') + ) + + // Wait for HMR to trigger + await check( + () => browser.eval(`document.querySelector('h1').textContent`), + 'Hello!' + ) + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('h1')).color` + ) + ).toBe('rgb(255, 0, 0)') + } finally { + await next.patchFile(filePath, origContent) + } + }) + describe('multiple entries', () => { it('should only inject the same style once if used by different layers', async () => { const browser = await next.browser('/css/css-duplicate-2/client') @@ -272,7 +305,7 @@ createNextDescribe( // Even if it's deduped by Float, it should still only be included once in the payload. // There are 3 matches, one for the rendered , one for float preload and one for the inside flight payload. expect( - initialHtml.match(/css-duplicate-2\/layout\.css/g).length + initialHtml.match(/css-duplicate-2\/layout\.css\?v=/g).length ).toBe(3) })