diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 5aec1eae9c5bc0..8f39164e05a774 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -101,7 +101,18 @@ async function handleMessage(payload: HMRPayload) { const newPath = `${base}${searchUrl.slice(1)}${ searchUrl.includes('?') ? '&' : '?' }t=${timestamp}` - el.href = new URL(newPath, el.href).href + + // rather than swapping the href on the existing tag, we will + // create a new link tag. Once the new stylesheet has loaded we + // will remove the existing link tag. This removes a Flash Of + // Unstyled Content that can occur when swapping out the tag href + // directly, as the new stylesheet has not yet been loaded. + const newLinkTag = el.cloneNode() as HTMLLinkElement + newLinkTag.href = new URL(newPath, el.href).href + const removeOldEl = () => el.remove() + newLinkTag.addEventListener('load', removeOldEl) + newLinkTag.addEventListener('error', removeOldEl) + el.after(newLinkTag) } console.log(`[vite] css hot updated: ${searchUrl}`) } diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index d06fee31f110ea..2ccdf96d84b970 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -169,6 +169,20 @@ if (!isBuild) { expect(textpost).not.toMatch('direct') }) + test('it swaps out link tags', async () => { + await page.goto(viteTestUrl) + + editFile('global.css', (code) => code.replace('white', 'tomato')) + + let el = await page.$('.link-tag-added') + await untilUpdated(() => el.textContent(), 'yes') + + el = await page.$('.link-tag-removed') + await untilUpdated(() => el.textContent(), 'yes') + + expect((await page.$$('link')).length).toBe(1) + }) + test('not loaded dynamic import', async () => { await page.goto(viteTestUrl + '/counter/index.html') diff --git a/playground/hmr/hmr.ts b/playground/hmr/hmr.ts index 113b87bc5865d4..f2d21b9bc78884 100644 --- a/playground/hmr/hmr.ts +++ b/playground/hmr/hmr.ts @@ -41,12 +41,42 @@ if (import.meta.hot) { update.type === 'css-update' && update.path.match('global.css') ) if (cssUpdate) { - const el = document.querySelector('#global-css') as HTMLLinkElement - text('.css-prev', el.href) - // We don't have a vite:afterUpdate event, but updates are currently sync - setTimeout(() => { - text('.css-post', el.href) - }, 0) + text( + '.css-prev', + (document.querySelector('.global-css') as HTMLLinkElement).href + ) + + // We don't have a vite:afterUpdate event. + // We need to wait until the tag has been swapped out, which + // includes the time taken to download and parse the new stylesheet. + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if ( + node.nodeType === Node.ELEMENT_NODE && + (node as Element).tagName === 'LINK' + ) { + text('.link-tag-added', 'yes') + } + }) + mutation.removedNodes.forEach((node) => { + if ( + node.nodeType === Node.ELEMENT_NODE && + (node as Element).tagName === 'LINK' + ) { + text('.link-tag-removed', 'yes') + text( + '.css-post', + (document.querySelector('.global-css') as HTMLLinkElement).href + ) + } + }) + }) + }) + + observer.observe(document.querySelector('#style-tags-wrapper'), { + childList: true + }) } }) diff --git a/playground/hmr/index.html b/playground/hmr/index.html index 65a2ed381b027a..7857ef818a911c 100644 --- a/playground/hmr/index.html +++ b/playground/hmr/index.html @@ -1,4 +1,10 @@ - +
+ +