-
Notifications
You must be signed in to change notification settings - Fork 47.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
DevTools: Reload all roots after Fast Refresh force remount (#21516)
Works around the corrupted Store state by detecting a broken Fast Refresh remount and forcefully dropping the root and re-mounting the entire tree. This prevents Fibers from getting duplicated in the Store (and in the Components tree). The benefit of this approach is that it doesn't rely on an update or change in behavior to Fast Refresh. (This workaround is pretty dirty, but since it's a DEV-only code path, it's probably okay.) Note that this change doesn't fix all of the reported issues (see #21442 (comment)) but it does fix some of them. This commit also slightly refactors the way DevTools assigns and manages unique IDs for Fibers in the backend by removing the indirection of a "primary Fiber" and instead mapping both the primary and alternate. It also removes the previous cache-on-read behavior of getFiberID and splits the method into three separate functions for different use cases: * getOrGenerateFiberID – Like the previous function, this method returns an ID or generates and caches a new one if the Fiber hasn't been seen before. * getFiberIDUnsafe – This function returns an ID if one has already been generated or null if not. (It can be used to e.g. log a message about a Fiber without potentially causing it to leak.) * getFiberIDThrows – This function returns an ID if one has already been generated or it throws. (It can be used to guarantee expected behavior rather than to silently cause a leak.)
- Loading branch information
Brian Vaughn
authored
May 18, 2021
1 parent
7bef382
commit 1e3383a
Showing
4 changed files
with
692 additions
and
345 deletions.
There are no files selected for viewing
260 changes: 260 additions & 0 deletions
260
packages/react-devtools-shared/src/__tests__/FastRefreshDevToolsIntegration-test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,260 @@ | ||
/** | ||
* Copyright (c) Facebook, Inc. and its affiliates. | ||
* | ||
* This source code is licensed under the MIT license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
* | ||
* @flow | ||
*/ | ||
|
||
describe('Fast Refresh', () => { | ||
let React; | ||
let ReactDOM; | ||
let ReactFreshRuntime; | ||
let act; | ||
let babel; | ||
let container; | ||
let exportsObj; | ||
let freshPlugin; | ||
let store; | ||
let withErrorsOrWarningsIgnored; | ||
|
||
afterEach(() => { | ||
jest.resetModules(); | ||
}); | ||
|
||
beforeEach(() => { | ||
exportsObj = undefined; | ||
container = document.createElement('div'); | ||
|
||
babel = require('@babel/core'); | ||
freshPlugin = require('react-refresh/babel'); | ||
|
||
store = global.store; | ||
|
||
React = require('react'); | ||
|
||
ReactFreshRuntime = require('react-refresh/runtime'); | ||
ReactFreshRuntime.injectIntoGlobalHook(global); | ||
|
||
ReactDOM = require('react-dom'); | ||
|
||
const utils = require('./utils'); | ||
act = utils.act; | ||
withErrorsOrWarningsIgnored = utils.withErrorsOrWarningsIgnored; | ||
}); | ||
|
||
function execute(source) { | ||
const compiled = babel.transform(source, { | ||
babelrc: false, | ||
presets: ['@babel/react'], | ||
plugins: [ | ||
[freshPlugin, {skipEnvCheck: true}], | ||
'@babel/plugin-transform-modules-commonjs', | ||
'@babel/plugin-transform-destructuring', | ||
].filter(Boolean), | ||
}).code; | ||
exportsObj = {}; | ||
// eslint-disable-next-line no-new-func | ||
new Function( | ||
'global', | ||
'React', | ||
'exports', | ||
'$RefreshReg$', | ||
'$RefreshSig$', | ||
compiled, | ||
)(global, React, exportsObj, $RefreshReg$, $RefreshSig$); | ||
// Module systems will register exports as a fallback. | ||
// This is useful for cases when e.g. a class is exported, | ||
// and we don't want to propagate the update beyond this module. | ||
$RefreshReg$(exportsObj.default, 'exports.default'); | ||
return exportsObj.default; | ||
} | ||
|
||
function render(source) { | ||
const Component = execute(source); | ||
act(() => { | ||
ReactDOM.render(<Component />, container); | ||
}); | ||
// Module initialization shouldn't be counted as a hot update. | ||
expect(ReactFreshRuntime.performReactRefresh()).toBe(null); | ||
} | ||
|
||
function patch(source) { | ||
const prevExports = exportsObj; | ||
execute(source); | ||
const nextExports = exportsObj; | ||
|
||
// Check if exported families have changed. | ||
// (In a real module system we'd do this for *all* exports.) | ||
// For example, this can happen if you convert a class to a function. | ||
// Or if you wrap something in a HOC. | ||
const didExportsChange = | ||
ReactFreshRuntime.getFamilyByType(prevExports.default) !== | ||
ReactFreshRuntime.getFamilyByType(nextExports.default); | ||
if (didExportsChange) { | ||
// In a real module system, we would propagate such updates upwards, | ||
// and re-execute modules that imported this one. (Just like if we edited them.) | ||
// This makes adding/removing/renaming exports re-render references to them. | ||
// Here, we'll just force a re-render using the newer type to emulate this. | ||
const NextComponent = nextExports.default; | ||
act(() => { | ||
ReactDOM.render(<NextComponent />, container); | ||
}); | ||
} | ||
act(() => { | ||
const result = ReactFreshRuntime.performReactRefresh(); | ||
if (!didExportsChange) { | ||
// Normally we expect that some components got updated in our tests. | ||
expect(result).not.toBe(null); | ||
} else { | ||
// However, we have tests where we convert functions to classes, | ||
// and in those cases it's expected nothing would get updated. | ||
// (Instead, the export change branch above would take care of it.) | ||
} | ||
}); | ||
expect(ReactFreshRuntime._getMountedRootCount()).toBe(1); | ||
} | ||
|
||
function $RefreshReg$(type, id) { | ||
ReactFreshRuntime.register(type, id); | ||
} | ||
|
||
function $RefreshSig$() { | ||
return ReactFreshRuntime.createSignatureFunctionForTransform(); | ||
} | ||
|
||
it('should not break the DevTools store', () => { | ||
render(` | ||
function Parent() { | ||
return <Child key="A" />; | ||
}; | ||
function Child() { | ||
return <div />; | ||
}; | ||
export default Parent; | ||
`); | ||
expect(store).toMatchInlineSnapshot(` | ||
[root] | ||
▾ <Parent> | ||
<Child key="A"> | ||
`); | ||
|
||
let element = container.firstChild; | ||
expect(container.firstChild).not.toBe(null); | ||
|
||
patch(` | ||
function Parent() { | ||
return <Child key="A" />; | ||
}; | ||
function Child() { | ||
return <div />; | ||
}; | ||
export default Parent; | ||
`); | ||
expect(store).toMatchInlineSnapshot(` | ||
[root] | ||
▾ <Parent> | ||
<Child key="A"> | ||
`); | ||
|
||
// State is preserved; this verifies that Fast Refresh is wired up. | ||
expect(container.firstChild).toBe(element); | ||
element = container.firstChild; | ||
|
||
patch(` | ||
function Parent() { | ||
return <Child key="B" />; | ||
}; | ||
function Child() { | ||
return <div />; | ||
}; | ||
export default Parent; | ||
`); | ||
expect(store).toMatchInlineSnapshot(` | ||
[root] | ||
▾ <Parent> | ||
<Child key="B"> | ||
`); | ||
|
||
// State is reset because hooks changed. | ||
expect(container.firstChild).not.toBe(element); | ||
}); | ||
|
||
it('should not break when there are warnings in between patching', () => { | ||
withErrorsOrWarningsIgnored(['Expected warning during render'], () => { | ||
render(` | ||
const {useState} = React; | ||
export default function Component() { | ||
const [state, setState] = useState(1); | ||
console.warn("Expected warning during render"); | ||
return null; | ||
} | ||
`); | ||
}); | ||
expect(store).toMatchInlineSnapshot(` | ||
✕ 0, ⚠ 1 | ||
[root] | ||
<Component> ⚠ | ||
`); | ||
|
||
withErrorsOrWarningsIgnored(['Expected warning during render'], () => { | ||
patch(` | ||
const {useEffect, useState} = React; | ||
export default function Component() { | ||
const [state, setState] = useState(1); | ||
console.warn("Expected warning during render"); | ||
return null; | ||
} | ||
`); | ||
}); | ||
expect(store).toMatchInlineSnapshot(` | ||
✕ 0, ⚠ 2 | ||
[root] | ||
<Component> ⚠ | ||
`); | ||
|
||
withErrorsOrWarningsIgnored(['Expected warning during render'], () => { | ||
patch(` | ||
const {useEffect, useState} = React; | ||
export default function Component() { | ||
const [state, setState] = useState(1); | ||
useEffect(() => {}); | ||
console.warn("Expected warning during render"); | ||
return null; | ||
} | ||
`); | ||
}); | ||
expect(store).toMatchInlineSnapshot(` | ||
✕ 0, ⚠ 1 | ||
[root] | ||
<Component> ⚠ | ||
`); | ||
|
||
withErrorsOrWarningsIgnored(['Expected warning during render'], () => { | ||
patch(` | ||
const {useEffect, useState} = React; | ||
export default function Component() { | ||
const [state, setState] = useState(1); | ||
console.warn("Expected warning during render"); | ||
return null; | ||
} | ||
`); | ||
}); | ||
expect(store).toMatchInlineSnapshot(` | ||
✕ 0, ⚠ 1 | ||
[root] | ||
<Component> ⚠ | ||
`); | ||
}); | ||
}); |
Oops, something went wrong.