Skip to content

Commit 48017b7

Browse files
authored
feat: invalidate message and fix HMR for HOC, class component & styled component (#79)
1 parent 545aa67 commit 48017b7

File tree

17 files changed

+254
-407
lines changed

17 files changed

+254
-407
lines changed

.github/renovate.json5

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
],
1414
"ignoreDeps": [
1515
// manually bumping
16-
"rollup",
1716
"node",
18-
"typescript",
1917

2018
// breaking changes
2119
"source-map", // `source-map:v0.7.0+` needs more investigation

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@
5151
"picocolors": "^1.0.0",
5252
"playwright-chromium": "^1.29.2",
5353
"prettier": "2.8.3",
54-
"rollup": "^3.7.0",
54+
"rollup": "^3.10.1",
5555
"simple-git-hooks": "^2.8.1",
5656
"tsx": "^3.12.2",
57-
"typescript": "^4.6.4",
57+
"typescript": "^4.9.4",
5858
"unbuild": "^1.1.1",
59-
"vite": "^4.0.4",
59+
"vite": "^4.1.0-beta.0",
6060
"vitest": "^0.27.3"
6161
},
6262
"simple-git-hooks": {

packages/plugin-react/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,11 @@ Otherwise, you'll probably get this error:
105105
```
106106
Uncaught Error: @vitejs/plugin-react can't detect preamble. Something is wrong.
107107
```
108+
109+
## Consistent components exports
110+
111+
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).
112+
113+
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.
114+
115+
You can catch mistakes and get more detailed warning with this [eslint rule](https://github.com/ArnaudBarre/eslint-plugin-react-refresh).

packages/plugin-react/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"license": "MIT",
55
"author": "Evan You",
66
"contributors": [
7-
"Alec Larson"
7+
"Alec Larson",
8+
"Arnaud Barré"
89
],
910
"files": [
1011
"dist"
@@ -21,7 +22,7 @@
2122
},
2223
"scripts": {
2324
"dev": "unbuild --stub",
24-
"build": "unbuild && pnpm run patch-cjs",
25+
"build": "unbuild && pnpm run patch-cjs && tsx scripts/copyRefreshUtils.ts",
2526
"patch-cjs": "tsx ../../scripts/patchCJS.ts",
2627
"prepublishOnly": "npm run build"
2728
},
@@ -45,6 +46,6 @@
4546
"react-refresh": "^0.14.0"
4647
},
4748
"peerDependencies": {
48-
"vite": "^4.0.0"
49+
"vite": "^4.1.0-beta.0"
4950
}
5051
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { copyFileSync } from 'node:fs'
2+
3+
copyFileSync('src/refreshUtils.js', 'dist/refreshUtils.js')

packages/plugin-react/src/fast-refresh.ts

Lines changed: 14 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,7 @@ const runtimeFilePath = path.join(
1616
export const runtimeCode = `
1717
const exports = {}
1818
${fs.readFileSync(runtimeFilePath, 'utf-8')}
19-
function debounce(fn, delay) {
20-
let handle
21-
return () => {
22-
clearTimeout(handle)
23-
handle = setTimeout(fn, delay)
24-
}
25-
}
26-
exports.performReactRefresh = debounce(exports.performReactRefresh, 16)
19+
${fs.readFileSync(_require.resolve('./refreshUtils.js'), 'utf-8')}
2720
export default exports
2821
`
2922

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

60-
const timeout = `
61-
if (!window.__vite_plugin_react_timeout) {
62-
window.__vite_plugin_react_timeout = setTimeout(() => {
63-
window.__vite_plugin_react_timeout = 0;
64-
RefreshRuntime.performReactRefresh();
65-
}, 30);
66-
}
67-
`
68-
69-
const checkAndAccept = `
70-
function isReactRefreshBoundary(mod) {
71-
if (mod == null || typeof mod !== 'object') {
72-
return false;
73-
}
74-
let hasExports = false;
75-
let areAllExportsComponents = true;
76-
for (const exportName in mod) {
77-
hasExports = true;
78-
if (exportName === '__esModule') {
79-
continue;
80-
}
81-
const desc = Object.getOwnPropertyDescriptor(mod, exportName);
82-
if (desc && desc.get) {
83-
// Don't invoke getters as they may have side effects.
84-
return false;
85-
}
86-
const exportValue = mod[exportName];
87-
if (!RefreshRuntime.isLikelyComponentType(exportValue)) {
88-
areAllExportsComponents = false;
89-
}
90-
}
91-
return hasExports && areAllExportsComponents;
92-
}
93-
94-
import.meta.hot.accept(mod => {
95-
if (!mod) return;
96-
if (isReactRefreshBoundary(mod)) {
97-
${timeout}
98-
} else {
99-
import.meta.hot.invalidate();
100-
}
101-
});
102-
`
103-
10453
const footer = `
10554
if (import.meta.hot) {
10655
window.$RefreshReg$ = prevRefreshReg;
10756
window.$RefreshSig$ = prevRefreshSig;
10857
109-
${checkAndAccept}
58+
import(/* @vite-ignore */ import.meta.url).then((currentExports) => {
59+
RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports);
60+
import.meta.hot.accept((nextExports) => {
61+
if (!nextExports) return;
62+
const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports);
63+
if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
64+
});
65+
});
11066
}`
11167

11268
export function addRefreshWrapper(code: string, id: string): string {
113-
return header.replace('__SOURCE__', JSON.stringify(id)) + code + footer
69+
return (
70+
header.replace('__SOURCE__', JSON.stringify(id)) +
71+
code +
72+
footer.replace('__SOURCE__', JSON.stringify(id))
73+
)
11474
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
function debounce(fn, delay) {
2+
let handle
3+
return () => {
4+
clearTimeout(handle)
5+
handle = setTimeout(fn, delay)
6+
}
7+
}
8+
9+
const enqueueUpdate = debounce(exports.performReactRefresh, 16)
10+
11+
// Taken from https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/lib/runtime/RefreshUtils.js#L141
12+
// This allows to resister components not detected by SWC like styled component
13+
function registerExportsForReactRefresh(filename, moduleExports) {
14+
for (const key in moduleExports) {
15+
if (key === '__esModule') continue
16+
const exportValue = moduleExports[key]
17+
if (exports.isLikelyComponentType(exportValue)) {
18+
exports.register(exportValue, filename + ' ' + key)
19+
}
20+
}
21+
}
22+
23+
function validateRefreshBoundaryAndEnqueueUpdate(prevExports, nextExports) {
24+
if (!predicateOnExport(prevExports, (key) => !!nextExports[key])) {
25+
return 'Could not Fast Refresh (export removed)'
26+
}
27+
28+
let hasExports = false
29+
const allExportsAreComponentsOrUnchanged = predicateOnExport(
30+
nextExports,
31+
(key, value) => {
32+
hasExports = true
33+
if (exports.isLikelyComponentType(value)) return true
34+
if (!prevExports[key]) return false
35+
return prevExports[key] === nextExports[key]
36+
},
37+
)
38+
if (hasExports && allExportsAreComponentsOrUnchanged) {
39+
enqueueUpdate()
40+
} else {
41+
return 'Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react#consistent-components-exports'
42+
}
43+
}
44+
45+
function predicateOnExport(moduleExports, predicate) {
46+
for (const key in moduleExports) {
47+
if (key === '__esModule') continue
48+
const desc = Object.getOwnPropertyDescriptor(moduleExports, key)
49+
if (desc && desc.get) return false
50+
if (!predicate(key, moduleExports[key])) return false
51+
}
52+
return true
53+
}
54+
55+
exports.registerExportsForReactRefresh = registerExportsForReactRefresh
56+
exports.validateRefreshBoundaryAndEnqueueUpdate =
57+
validateRefreshBoundaryAndEnqueueUpdate

packages/plugin-react/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"include": ["src"],
2+
"include": ["src", "scripts"],
33
"exclude": ["**/*.spec.ts"],
44
"compilerOptions": {
55
"outDir": "dist",

playground/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"private": true,
44
"version": "1.0.0",
55
"devDependencies": {
6-
"css-color-names": "^1.0.1",
76
"kill-port": "^1.6.1",
87
"node-fetch": "^3.3.0"
98
}

playground/react-emotion/App.jsx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,8 @@
11
import { useState } from 'react'
2-
import { css } from '@emotion/react'
3-
42
import _Switch from 'react-switch'
3+
import { Counter, StyledCode } from './Counter'
54
const Switch = _Switch.default || _Switch
65

7-
export function Counter() {
8-
const [count, setCount] = useState(0)
9-
10-
return (
11-
<button
12-
css={css`
13-
border: 2px solid #000;
14-
`}
15-
onClick={() => setCount((count) => count + 1)}
16-
>
17-
count is: {count}
18-
</button>
19-
)
20-
}
21-
226
function FragmentTest() {
237
const [checked, setChecked] = useState(false)
248
return (
@@ -38,7 +22,7 @@ function App() {
3822
<h1>Hello Vite + React + @emotion/react</h1>
3923
<FragmentTest />
4024
<p>
41-
Edit <code>App.jsx</code> and save to test HMR updates.
25+
Edit <StyledCode>App.jsx</StyledCode> and save to test HMR updates.
4226
</p>
4327
<a
4428
className="App-link"

0 commit comments

Comments
 (0)