Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: invalidate message and fix HMR for HOC, class component & styled components #79

Merged
merged 1 commit into from
Jan 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
],
"ignoreDeps": [
// manually bumping
"rollup",
"node",
"typescript",

// breaking changes
"source-map", // `source-map:v0.7.0+` needs more investigation
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@
"picocolors": "^1.0.0",
"playwright-chromium": "^1.29.2",
"prettier": "2.8.3",
"rollup": "^3.7.0",
"rollup": "^3.10.1",
"simple-git-hooks": "^2.8.1",
"tsx": "^3.12.2",
"typescript": "^4.6.4",
"typescript": "^4.9.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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Loading