diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 19cbec2aca6515..ef7b6be46dd9b7 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -495,6 +495,7 @@ async function waitForSuccessfulPingInternal( } const sheetsMap = new Map() +const linkSheetsMap = new Map() // collect existing style elements that may have been inserted during SSR // to avoid FOUC or duplicate styles @@ -504,6 +505,13 @@ if ('document' in globalThis) { .forEach((el) => { sheetsMap.set(el.getAttribute('data-vite-dev-id')!, el) }) + document + .querySelectorAll( + 'link[rel="stylesheet"][data-vite-dev-id]', + ) + .forEach((el) => { + linkSheetsMap.set(el.getAttribute('data-vite-dev-id')!, el) + }) } // all css imports should be inserted at the same position @@ -511,6 +519,8 @@ if ('document' in globalThis) { let lastInsertedStyle: HTMLStyleElement | undefined export function updateStyle(id: string, content: string): void { + if (linkSheetsMap.has(id)) return + let style = sheetsMap.get(id) if (!style) { style = document.createElement('style') @@ -540,6 +550,19 @@ export function updateStyle(id: string, content: string): void { } export function removeStyle(id: string): void { + if (linkSheetsMap.has(id)) { + // re-select elements since HMR can replace links + document + .querySelectorAll( + `link[rel="stylesheet"][data-vite-dev-id]`, + ) + .forEach((el) => { + if (el.getAttribute('data-vite-dev-id') === id) { + el.remove() + } + }) + linkSheetsMap.delete(id) + } const style = sheetsMap.get(id) if (style) { document.head.removeChild(style) diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 139355368e53b1..61e100193c3390 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -1107,4 +1107,27 @@ if (!isBuild) { await loadPromise }, [/connected/, 'a.js']) }) + + test('deduplicate server rendered link stylesheet', async () => { + await page.goto(viteTestUrl + '/css-link/index.html') + await expect.poll(() => getColor('.test-css-link')).toBe('orange') + + // remove color + editFile('css-link/styles.css', (code) => + code.replace('color: orange;', '/* removed */'), + ) + await expect.poll(() => getColor('.test-css-link')).toBe('black') + + // add color + editFile('css-link/styles.css', (code) => + code.replace('/* removed */', 'color: blue;'), + ) + await expect.poll(() => getColor('.test-css-link')).toBe('blue') + + // // remove css import from js + editFile('css-link/main.js', (code) => + code.replace(`import './styles.css'`, ``), + ) + await expect.poll(() => getColor('.test-css-link')).toBe('black') + }) } diff --git a/playground/hmr/css-link/index.html b/playground/hmr/css-link/index.html new file mode 100644 index 00000000000000..8c12ba7c744bcb --- /dev/null +++ b/playground/hmr/css-link/index.html @@ -0,0 +1,2 @@ + + diff --git a/playground/hmr/css-link/main.js b/playground/hmr/css-link/main.js new file mode 100644 index 00000000000000..da10b7e956cc64 --- /dev/null +++ b/playground/hmr/css-link/main.js @@ -0,0 +1,5 @@ +import './styles.css' + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/playground/hmr/css-link/plugin.ts b/playground/hmr/css-link/plugin.ts new file mode 100644 index 00000000000000..dcfbf813119ce4 --- /dev/null +++ b/playground/hmr/css-link/plugin.ts @@ -0,0 +1,26 @@ +import path from 'node:path' +import { type Plugin, normalizePath } from 'vite' + +// use plugin to simulate server rendered css link +export function TestCssLinkPlugin(): Plugin { + return { + name: 'test-css-link', + transformIndexHtml: { + handler(_html, ctx) { + if (!ctx.filename.endsWith('/css-link/index.html')) return + return [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/css-link/styles.css', + 'data-vite-dev-id': normalizePath( + path.resolve(import.meta.dirname, 'styles.css'), + ), + }, + }, + ] + }, + }, + } +} diff --git a/playground/hmr/css-link/styles.css b/playground/hmr/css-link/styles.css new file mode 100644 index 00000000000000..af2b338eebe90e --- /dev/null +++ b/playground/hmr/css-link/styles.css @@ -0,0 +1,3 @@ +.test-css-link { + color: orange; +} diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index 9ee8024ee2bf44..f844d4eee8c4b8 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { defineConfig } from 'vite' import type { Plugin } from 'vite' +import { TestCssLinkPlugin } from './css-link/plugin' export default defineConfig({ experimental: { @@ -37,6 +38,7 @@ export default defineConfig({ virtualPlugin(), transformCountPlugin(), watchCssDepsPlugin(), + TestCssLinkPlugin(), ], })