Skip to content

Commit e976b06

Browse files
IanVSaleclarson
andauthored
fix(react): conditionally self-accept fast-refresh HMR (#10239)
Co-authored-by: Alec Larson <[email protected]>
1 parent cba13e8 commit e976b06

File tree

7 files changed

+160
-17
lines changed

7 files changed

+160
-17
lines changed

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

+46-8
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,57 @@ if (import.meta.hot) {
5858
window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
5959
}`.replace(/[\n]+/gm, '')
6060

61-
const footer = `
62-
if (import.meta.hot) {
63-
window.$RefreshReg$ = prevRefreshReg;
64-
window.$RefreshSig$ = prevRefreshSig;
65-
66-
__ACCEPT__
61+
const timeout = `
6762
if (!window.__vite_plugin_react_timeout) {
6863
window.__vite_plugin_react_timeout = setTimeout(() => {
6964
window.__vite_plugin_react_timeout = 0;
7065
RefreshRuntime.performReactRefresh();
7166
}, 30);
7267
}
68+
`
69+
70+
const footer = `
71+
if (import.meta.hot) {
72+
window.$RefreshReg$ = prevRefreshReg;
73+
window.$RefreshSig$ = prevRefreshSig;
74+
75+
__ACCEPT__
7376
}`
7477

78+
const checkAndAccept = `
79+
function isReactRefreshBoundary(mod) {
80+
if (mod == null || typeof mod !== 'object') {
81+
return false;
82+
}
83+
let hasExports = false;
84+
let areAllExportsComponents = true;
85+
for (const exportName in mod) {
86+
hasExports = true;
87+
if (exportName === '__esModule') {
88+
continue;
89+
}
90+
const desc = Object.getOwnPropertyDescriptor(mod, exportName);
91+
if (desc && desc.get) {
92+
// Don't invoke getters as they may have side effects.
93+
return false;
94+
}
95+
const exportValue = mod[exportName];
96+
if (!RefreshRuntime.isLikelyComponentType(exportValue)) {
97+
areAllExportsComponents = false;
98+
}
99+
}
100+
return hasExports && areAllExportsComponents;
101+
}
102+
103+
import.meta.hot.accept(mod => {
104+
if (isReactRefreshBoundary(mod)) {
105+
${timeout}
106+
} else {
107+
import.meta.hot.invalidate();
108+
}
109+
});
110+
`
111+
75112
export function addRefreshWrapper(
76113
code: string,
77114
id: string,
@@ -80,12 +117,13 @@ export function addRefreshWrapper(
80117
return (
81118
header.replace('__SOURCE__', JSON.stringify(id)) +
82119
code +
83-
footer.replace('__ACCEPT__', accept ? 'import.meta.hot.accept();' : '')
120+
footer.replace('__ACCEPT__', accept ? checkAndAccept : timeout)
84121
)
85122
}
86123

87124
export function isRefreshBoundary(ast: t.File): boolean {
88-
// Every export must be a React component.
125+
// Every export must be a potential React component.
126+
// We'll also perform a runtime check that's more robust as well (isLikelyComponentType).
89127
return ast.program.body.every((node) => {
90128
if (node.type !== 'ExportNamedDeclaration') {
91129
return true

playground/react/App.jsx

+21-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { useState } from 'react'
2-
import Dummy from './components/Dummy?qs-should-not-break-plugin-react'
32
import Button from 'jsx-entry'
3+
import Dummy from './components/Dummy?qs-should-not-break-plugin-react'
4+
import Parent from './hmr/parent'
5+
import { CountProvider } from './context/CountProvider'
6+
import { ContextButton } from './context/ContextButton'
47

58
function App() {
69
const [count, setCount] = useState(0)
@@ -9,10 +12,16 @@ function App() {
912
<header className="App-header">
1013
<h1>Hello Vite + React</h1>
1114
<p>
12-
<button onClick={() => setCount((count) => count + 1)}>
15+
<button
16+
id="state-button"
17+
onClick={() => setCount((count) => count + 1)}
18+
>
1319
count is: {count}
1420
</button>
1521
</p>
22+
<p>
23+
<ContextButton />
24+
</p>
1625
<p>
1726
Edit <code>App.jsx</code> and save to test HMR updates.
1827
</p>
@@ -27,9 +36,18 @@ function App() {
2736
</header>
2837

2938
<Dummy />
39+
<Parent />
3040
<Button>button</Button>
3141
</div>
3242
)
3343
}
3444

35-
export default App
45+
function AppWithProviders() {
46+
return (
47+
<CountProvider>
48+
<App />
49+
</CountProvider>
50+
)
51+
}
52+
53+
export default AppWithProviders

playground/react/__tests__/react.spec.ts

+56-6
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,35 @@
11
import { expect, test } from 'vitest'
2-
import { editFile, isServe, page, untilUpdated } from '~utils'
2+
import {
3+
browserLogs,
4+
editFile,
5+
isBuild,
6+
isServe,
7+
page,
8+
untilUpdated
9+
} from '~utils'
310

411
test('should render', async () => {
512
expect(await page.textContent('h1')).toMatch('Hello Vite + React')
613
})
714

815
test('should update', async () => {
9-
expect(await page.textContent('button')).toMatch('count is: 0')
10-
await page.click('button')
11-
expect(await page.textContent('button')).toMatch('count is: 1')
16+
expect(await page.textContent('#state-button')).toMatch('count is: 0')
17+
await page.click('#state-button')
18+
expect(await page.textContent('#state-button')).toMatch('count is: 1')
1219
})
1320

1421
test('should hmr', async () => {
1522
editFile('App.jsx', (code) => code.replace('Vite + React', 'Updated'))
1623
await untilUpdated(() => page.textContent('h1'), 'Hello Updated')
1724
// preserve state
18-
expect(await page.textContent('button')).toMatch('count is: 1')
25+
expect(await page.textContent('#state-button')).toMatch('count is: 1')
1926
})
2027

2128
test.runIf(isServe)(
2229
'should have annotated jsx with file location metadata',
2330
async () => {
2431
const meta = await page.evaluate(() => {
25-
const button = document.querySelector('button')
32+
const button = document.querySelector('#state-button')
2633
const key = Object.keys(button).find(
2734
(key) => key.indexOf('__reactFiber') === 0
2835
)
@@ -37,3 +44,46 @@ test.runIf(isServe)(
3744
])
3845
}
3946
)
47+
48+
if (!isBuild) {
49+
// #9869
50+
test('should only hmr files with exported react components', async () => {
51+
browserLogs.length = 0
52+
editFile('hmr/no-exported-comp.jsx', (code) =>
53+
code.replace('An Object', 'Updated')
54+
)
55+
await untilUpdated(() => page.textContent('#parent'), 'Updated')
56+
expect(browserLogs).toMatchObject([
57+
'[vite] hot updated: /hmr/no-exported-comp.jsx',
58+
'[vite] hot updated: /hmr/parent.jsx',
59+
'Parent rendered'
60+
])
61+
browserLogs.length = 0
62+
})
63+
64+
// #3301
65+
test('should hmr react context', async () => {
66+
browserLogs.length = 0
67+
expect(await page.textContent('#context-button')).toMatch(
68+
'context-based count is: 0'
69+
)
70+
await page.click('#context-button')
71+
expect(await page.textContent('#context-button')).toMatch(
72+
'context-based count is: 1'
73+
)
74+
editFile('context/CountProvider.jsx', (code) =>
75+
code.replace('context provider', 'context provider updated')
76+
)
77+
await untilUpdated(
78+
() => page.textContent('#context-provider'),
79+
'context provider updated'
80+
)
81+
expect(browserLogs).toMatchObject([
82+
'[vite] hot updated: /context/CountProvider.jsx',
83+
'[vite] hot updated: /App.jsx',
84+
'[vite] hot updated: /context/ContextButton.jsx',
85+
'Parent rendered'
86+
])
87+
browserLogs.length = 0
88+
})
89+
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useContext } from 'react'
2+
import { CountContext } from './CountProvider'
3+
4+
export function ContextButton() {
5+
const { count, setCount } = useContext(CountContext)
6+
return (
7+
<button id="context-button" onClick={() => setCount((count) => count + 1)}>
8+
context-based count is: {count}
9+
</button>
10+
)
11+
}
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createContext, useState } from 'react'
2+
export const CountContext = createContext()
3+
4+
export const CountProvider = ({ children }) => {
5+
const [count, setCount] = useState(0)
6+
return (
7+
<CountContext.Provider value={{ count, setCount }}>
8+
{children}
9+
<div id="context-provider">context provider</div>
10+
</CountContext.Provider>
11+
)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// This un-exported react component should not cause this file to be treated
2+
// as an HMR boundary
3+
const Unused = () => <span>An unused react component</span>
4+
5+
export const Foo = {
6+
is: 'An Object'
7+
}

playground/react/hmr/parent.jsx

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Foo } from './no-exported-comp'
2+
3+
export default function Parent() {
4+
console.log('Parent rendered')
5+
6+
return <div id="parent">{Foo.is}</div>
7+
}

0 commit comments

Comments
 (0)