Skip to content

Commit

Permalink
fix(dev): fix FOUC when swapping out link tag
Browse files Browse the repository at this point in the history
Assuming we have a CSS file at the following path...

```
/resources/css/app.css
```

When adding a link tag to the DOM manually (i.e. via Server Side Rendering)...

```html
<link rel="stylesheet" href="{DEV_SERVER_URL}/resources/css/app.css">
```

Vite will now watch this file and hot reload changes in the browser for this
asset. When we update `app.css` Vite will update the link tag's `href`
attribute like so...

```diff
- <link rel="stylesheet" href="{DEV_SERVER_URL}/resources/css/app.css?">
+ <link rel="stylesheet" href="{DEV_SERVER_URL}/resources/css/app.css?t={TIMESTAMP}">
```

This is great, but for a split moment there is a flash of unstyled content as the asset has not loaded yet. The browser unloads the styles from the original stylesheet and waits until the new stylesheet has loaded and been parsed before it will show the new stylings.

This PR addresses this problem. Now the process is as follows..

First the client adds a second (cloned) link tag to the DOM, leaving the original in place and still loaded by the browser...

```diff
<link rel="stylesheet" href="{DEV_SERVER_URL}/resources/css/app.css?">
+ <link rel="stylesheet" href="{DEV_SERVER_URL}/resources/css/app.css?t={TIMESTAMP}">
```

Once the new stylesheet has loaded, the browser will remove the original stylesheet from the DOM, leaving the new _loaded_ stylesheet in the DOM.

```diff
- <link rel="stylesheet" href="{DEV_SERVER_URL}/resources/css/app.css?">
<link rel="stylesheet" href="{DEV_SERVER_URL}/resources/css/app.css?t={TIMESTAMP}">
```

This process then continues for future updates to the CSS file.

I have no idea where to start adding a unit test for this. I'm a dumb back-end developer. Send help!

Edit: i've added a test. Not sure if it is any good though!
  • Loading branch information
timacdonald committed Jun 9, 2022
1 parent fba82d0 commit 96f07a4
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 8 deletions.
11 changes: 10 additions & 1 deletion packages/vite/src/client/client.ts
Expand Up @@ -101,7 +101,16 @@ 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
newLinkTag.addEventListener('load', () => el.remove())
el.after(newLinkTag)
}
console.log(`[vite] css hot updated: ${searchUrl}`)
}
Expand Down
14 changes: 14 additions & 0 deletions playground/hmr/__tests__/hmr.spec.ts
Expand Up @@ -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')

Expand Down
42 changes: 36 additions & 6 deletions playground/hmr/hmr.ts
Expand Up @@ -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, but updates are currently
// sync. 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
})
}
})

Expand Down
10 changes: 9 additions & 1 deletion playground/hmr/index.html
@@ -1,4 +1,10 @@
<link id="global-css" rel="stylesheet" href="./global.css?param=required" />
<div id="style-tags-wrapper">
<link
class="global-css"
rel="stylesheet"
href="./global.css?param=required"
/>
</div>
<script type="module" src="./hmr.ts"></script>
<style>
.import-image {
Expand All @@ -16,4 +22,6 @@
<div class="custom-communication"></div>
<div class="css-prev"></div>
<div class="css-post"></div>
<div class="link-tag-added">no</div>
<div class="link-tag-removed">no</div>
<div class="import-image"></div>

0 comments on commit 96f07a4

Please sign in to comment.