Skip to content

Commit

Permalink
feat: invalidate message and fix HMR for HOC, class component & style…
Browse files Browse the repository at this point in the history
…d component
  • Loading branch information
ArnaudBarre committed Jan 23, 2023
1 parent 545aa67 commit e20e791
Show file tree
Hide file tree
Showing 16 changed files with 195 additions and 332 deletions.
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -56,7 +56,7 @@
"tsx": "^3.12.2",
"typescript": "^4.6.4",
"unbuild": "^1.1.1",
"vite": "^4.0.4",
"vite": "^4.1.0-beta.0",
"vitest": "^0.27.3"
},
"simple-git-hooks": {
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-react/README.md
Expand Up @@ -105,3 +105,11 @@ Otherwise, you'll probably get this error:
```
Uncaught Error: @vitejs/plugin-react can't detect preamble. Something is wrong.
```

## Consistent components exports

For React refresh to work correctly, your file should only export React components. You can find a good explanation in the [Gatsby docs](https://www.gatsbyjs.com/docs/reference/local-development/fast-refresh/#how-it-works).

If an incompatible change in exports is found, the module will be invalidated and HMR will propagate. To make it easier to export simple constants alongside your component, the module is only invalidated when their value changes.

You can catch mistakes and get more detailed warning with this [eslint rule](https://github.com/ArnaudBarre/eslint-plugin-react-refresh).
7 changes: 4 additions & 3 deletions packages/plugin-react/package.json
Expand Up @@ -4,7 +4,8 @@
"license": "MIT",
"author": "Evan You",
"contributors": [
"Alec Larson"
"Alec Larson",
"Arnaud Barré"
],
"files": [
"dist"
Expand All @@ -21,7 +22,7 @@
},
"scripts": {
"dev": "unbuild --stub",
"build": "unbuild && pnpm run patch-cjs",
"build": "unbuild && pnpm run patch-cjs && tsx scripts/copyRefreshUtils.ts",
"patch-cjs": "tsx ../../scripts/patchCJS.ts",
"prepublishOnly": "npm run build"
},
Expand All @@ -45,6 +46,6 @@
"react-refresh": "^0.14.0"
},
"peerDependencies": {
"vite": "^4.0.0"
"vite": "^4.1.0-beta.0"
}
}
3 changes: 3 additions & 0 deletions packages/plugin-react/scripts/copyRefreshUtils.ts
@@ -0,0 +1,3 @@
import { copyFileSync } from 'node:fs'

copyFileSync('src/refreshUtils.js', 'dist/refreshUtils.js')
68 changes: 14 additions & 54 deletions packages/plugin-react/src/fast-refresh.ts
Expand Up @@ -16,14 +16,7 @@ const runtimeFilePath = path.join(
export const runtimeCode = `
const exports = {}
${fs.readFileSync(runtimeFilePath, 'utf-8')}
function debounce(fn, delay) {
let handle
return () => {
clearTimeout(handle)
handle = setTimeout(fn, delay)
}
}
exports.performReactRefresh = debounce(exports.performReactRefresh, 16)
${fs.readFileSync(_require.resolve('./refreshUtils.js'), 'utf-8')}
export default exports
`

Expand Down Expand Up @@ -57,58 +50,25 @@ if (import.meta.hot) {
window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
}`.replace(/\n+/g, '')

const timeout = `
if (!window.__vite_plugin_react_timeout) {
window.__vite_plugin_react_timeout = setTimeout(() => {
window.__vite_plugin_react_timeout = 0;
RefreshRuntime.performReactRefresh();
}, 30);
}
`

const checkAndAccept = `
function isReactRefreshBoundary(mod) {
if (mod == null || typeof mod !== 'object') {
return false;
}
let hasExports = false;
let areAllExportsComponents = true;
for (const exportName in mod) {
hasExports = true;
if (exportName === '__esModule') {
continue;
}
const desc = Object.getOwnPropertyDescriptor(mod, exportName);
if (desc && desc.get) {
// Don't invoke getters as they may have side effects.
return false;
}
const exportValue = mod[exportName];
if (!RefreshRuntime.isLikelyComponentType(exportValue)) {
areAllExportsComponents = false;
}
}
return hasExports && areAllExportsComponents;
}
import.meta.hot.accept(mod => {
if (!mod) return;
if (isReactRefreshBoundary(mod)) {
${timeout}
} else {
import.meta.hot.invalidate();
}
});
`

const footer = `
if (import.meta.hot) {
window.$RefreshReg$ = prevRefreshReg;
window.$RefreshSig$ = prevRefreshSig;
${checkAndAccept}
import(/* @vite-ignore */ import.meta.url).then((currentExports) => {
RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports);
import.meta.hot.accept((nextExports) => {
if (!nextExports) return;
const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports);
if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
});
});
}`

export function addRefreshWrapper(code: string, id: string): string {
return header.replace('__SOURCE__', JSON.stringify(id)) + code + footer
return (
header.replace('__SOURCE__', JSON.stringify(id)) +
code +
footer.replace('__SOURCE__', JSON.stringify(id))
)
}
57 changes: 57 additions & 0 deletions packages/plugin-react/src/refreshUtils.js
@@ -0,0 +1,57 @@
function debounce(fn, delay) {
let handle
return () => {
clearTimeout(handle)
handle = setTimeout(fn, delay)
}
}

const enqueueUpdate = debounce(exports.performReactRefresh, 16)

// Taken from https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/lib/runtime/RefreshUtils.js#L141
// This allows to resister components not detected by SWC like styled component
function registerExportsForReactRefresh(filename, moduleExports) {
for (const key in moduleExports) {
if (key === '__esModule') continue
const exportValue = moduleExports[key]
if (exports.isLikelyComponentType(exportValue)) {
exports.register(exportValue, filename + ' ' + key)
}
}
}

function validateRefreshBoundaryAndEnqueueUpdate(prevExports, nextExports) {
if (!predicateOnExport(prevExports, (key) => !!nextExports[key])) {
return 'Could not Fast Refresh (export removed)'
}

let hasExports = false
const allExportsAreComponentsOrUnchanged = predicateOnExport(
nextExports,
(key, value) => {
hasExports = true
if (exports.isLikelyComponentType(value)) return true
if (!prevExports[key]) return false
return prevExports[key] === nextExports[key]
},
)
if (hasExports && allExportsAreComponentsOrUnchanged) {
enqueueUpdate()
} else {
return 'Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react#consistent-components-exports'
}
}

function predicateOnExport(moduleExports, predicate) {
for (const key in moduleExports) {
if (key === '__esModule') continue
const desc = Object.getOwnPropertyDescriptor(moduleExports, key)
if (desc && desc.get) return false
if (!predicate(key, moduleExports[key])) return false
}
return true
}

exports.registerExportsForReactRefresh = registerExportsForReactRefresh
exports.validateRefreshBoundaryAndEnqueueUpdate =
validateRefreshBoundaryAndEnqueueUpdate
2 changes: 1 addition & 1 deletion packages/plugin-react/tsconfig.json
@@ -1,5 +1,5 @@
{
"include": ["src"],
"include": ["src", "scripts"],
"exclude": ["**/*.spec.ts"],
"compilerOptions": {
"outDir": "dist",
Expand Down
1 change: 0 additions & 1 deletion playground/package.json
Expand Up @@ -3,7 +3,6 @@
"private": true,
"version": "1.0.0",
"devDependencies": {
"css-color-names": "^1.0.1",
"kill-port": "^1.6.1",
"node-fetch": "^3.3.0"
}
Expand Down
20 changes: 2 additions & 18 deletions playground/react-emotion/App.jsx
@@ -1,24 +1,8 @@
import { useState } from 'react'
import { css } from '@emotion/react'

import _Switch from 'react-switch'
import { Counter, StyledCode } from './Counter'
const Switch = _Switch.default || _Switch

export function Counter() {
const [count, setCount] = useState(0)

return (
<button
css={css`
border: 2px solid #000;
`}
onClick={() => setCount((count) => count + 1)}
>
count is: {count}
</button>
)
}

function FragmentTest() {
const [checked, setChecked] = useState(false)
return (
Expand All @@ -38,7 +22,7 @@ function App() {
<h1>Hello Vite + React + @emotion/react</h1>
<FragmentTest />
<p>
Edit <code>App.jsx</code> and save to test HMR updates.
Edit <StyledCode>App.jsx</StyledCode> and save to test HMR updates.
</p>
<a
className="App-link"
Expand Down
23 changes: 23 additions & 0 deletions playground/react-emotion/Counter.jsx
@@ -0,0 +1,23 @@
import styled from '@emotion/styled'
import { css } from '@emotion/react'
import { useState } from 'react'

// Ensure HMR of styled component alongside other components
export const StyledCode = styled.code`
color: #646cff;
`

export function Counter() {
const [count, setCount] = useState(0)

return (
<button
css={css`
border: 2px solid #000;
`}
onClick={() => setCount((count) => count + 1)}
>
count is: {count}
</button>
)
}
11 changes: 9 additions & 2 deletions playground/react-emotion/__tests__/react.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest'
import { editFile, page, untilUpdated } from '~utils'
import { editFile, getColor, page, untilUpdated } from '~utils'

test('should render', async () => {
expect(await page.textContent('h1')).toMatch(
Expand All @@ -18,6 +18,13 @@ test('should hmr', async () => {
code.replace('Vite + React + @emotion/react', 'Updated'),
)
await untilUpdated(() => page.textContent('h1'), 'Hello Updated')

editFile('Counter.jsx', (code) =>
code.replace('color: #646cff;', 'color: #d26ac2;'),
)

await untilUpdated(() => getColor('code'), '#d26ac2')

// preserve state
expect(await page.textContent('button')).toMatch('count is: 1')
})
Expand All @@ -35,7 +42,7 @@ test('should update button style', async () => {

expect(await getButtonBorderStyle()).toMatch('2px solid rgb(0, 0, 0)')

editFile('App.jsx', (code) =>
editFile('Counter.jsx', (code) =>
code.replace('border: 2px solid #000', 'border: 4px solid red'),
)

Expand Down
1 change: 1 addition & 0 deletions playground/react-emotion/package.json
Expand Up @@ -10,6 +10,7 @@
},
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-switch": "^7.0.0"
Expand Down
4 changes: 2 additions & 2 deletions playground/react/__tests__/react.spec.ts
Expand Up @@ -78,7 +78,7 @@ if (!isBuild) {
code.replace('An Object', 'Updated'),
),
[
'[vite] invalidate /hmr/no-exported-comp.jsx',
'[vite] invalidate /hmr/no-exported-comp.jsx: Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react#consistent-components-exports',
'[vite] hot updated: /hmr/no-exported-comp.jsx',
'[vite] hot updated: /hmr/parent.jsx',
'Parent rendered',
Expand All @@ -103,7 +103,7 @@ if (!isBuild) {
code.replace('context provider', 'context provider updated'),
),
[
'[vite] invalidate /context/CountProvider.jsx',
'[vite] invalidate /context/CountProvider.jsx: Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react#consistent-components-exports',
'[vite] hot updated: /context/CountProvider.jsx',
'[vite] hot updated: /App.jsx',
'[vite] hot updated: /context/ContextButton.jsx',
Expand Down
5 changes: 0 additions & 5 deletions playground/shims.d.ts
@@ -1,8 +1,3 @@
declare module 'css-color-names' {
const colors: Record<string, string>
export default colors
}

declare module 'kill-port' {
const kill: (port: number) => Promise<void>
export default kill
Expand Down
8 changes: 1 addition & 7 deletions playground/test-utils.ts
Expand Up @@ -3,7 +3,6 @@

import fs from 'node:fs'
import path from 'node:path'
import colors from 'css-color-names'
import type { ConsoleMessage, ElementHandle } from 'playwright-chromium'
import { expect } from 'vitest'
import { isBuild, page, testDir } from './vitestSetup'
Expand All @@ -18,11 +17,6 @@ export const hmrPorts = {
'ssr-react': 24685,
}

const hexToNameMap: Record<string, string> = {}
Object.keys(colors).forEach((color) => {
hexToNameMap[colors[color]] = color
})

function componentToHex(c: number): string {
const hex = c.toString(16)
return hex.length === 1 ? '0' + hex : hex
Expand Down Expand Up @@ -55,7 +49,7 @@ async function toEl(el: string | ElementHandle): Promise<ElementHandle> {
export async function getColor(el: string | ElementHandle): Promise<string> {
el = await toEl(el)
const rgb = await el.evaluate((el) => getComputedStyle(el as Element).color)
return hexToNameMap[rgbToHex(rgb)] ?? rgb
return rgbToHex(rgb)
}

export async function getBg(el: string | ElementHandle): Promise<string> {
Expand Down

0 comments on commit e20e791

Please sign in to comment.