diff --git a/packages/react-fresh/src/ReactFreshBabelPlugin.js b/packages/react-fresh/src/ReactFreshBabelPlugin.js
index 9acd098658..96eab42908 100644
--- a/packages/react-fresh/src/ReactFreshBabelPlugin.js
+++ b/packages/react-fresh/src/ReactFreshBabelPlugin.js
@@ -175,6 +175,17 @@ export default function(babel) {
// TODO: if there is no LHS, consider some other heuristic.
key = hookCallPath.parentPath.get('id').getSource();
}
+
+ // Some built-in Hooks reset on edits to arguments.
+ const args = hookCallPath.get('arguments');
+ if (hookName === 'useState' && args.length > 0) {
+ // useState second argument is initial state.
+ key += '(' + args[0].getSource() + ')';
+ } else if (hookName === 'useReducer' && args.length > 1) {
+ // useReducer second argument is initial state.
+ key += '(' + args[1].getSource() + ')';
+ }
+
hookCallsForFn.push({
name: hookName,
callee: hookCallPath.node.callee,
diff --git a/packages/react-fresh/src/ReactFreshRuntime.js b/packages/react-fresh/src/ReactFreshRuntime.js
index 9898b69a2b..24f434702e 100644
--- a/packages/react-fresh/src/ReactFreshRuntime.js
+++ b/packages/react-fresh/src/ReactFreshRuntime.js
@@ -115,7 +115,11 @@ function resolveFamily(type) {
return familiesByType.get(type);
}
-export function prepareUpdate(): HotUpdate {
+export function prepareUpdate(): HotUpdate | null {
+ if (pendingUpdates.length === 0) {
+ return null;
+ }
+
const staleFamilies = new Set();
const updatedFamilies = new Set();
@@ -206,3 +210,7 @@ export function collectCustomHooksForSignature(type: any) {
computeFullKey(signature);
}
}
+
+export function getFamilyByID(id: string): Family | void {
+ return allFamiliesByID.get(id);
+}
diff --git a/packages/react-fresh/src/__tests__/ReactFresh-test.js b/packages/react-fresh/src/__tests__/ReactFresh-test.js
index 0cc1a6a544..5ff47ae115 100644
--- a/packages/react-fresh/src/__tests__/ReactFresh-test.js
+++ b/packages/react-fresh/src/__tests__/ReactFresh-test.js
@@ -20,6 +20,7 @@ let act;
describe('ReactFresh', () => {
let container;
let lastRoot;
+ let findHostNodesForHotUpdate;
let scheduleHotUpdate;
beforeEach(() => {
@@ -27,6 +28,7 @@ describe('ReactFresh', () => {
supportsFiber: true,
inject: injected => {
scheduleHotUpdate = injected.scheduleHotUpdate;
+ findHostNodesForHotUpdate = injected.findHostNodesForHotUpdate;
},
onCommitFiberRoot: (id, root) => {
lastRoot = root;
@@ -2934,4 +2936,112 @@ describe('ReactFresh', () => {
expect(finalEl.textContent).toBe('1');
}
});
+
+ it('can find host nodes for a family', () => {
+ if (__DEV__) {
+ render(() => {
+ function Child({children}) {
+ return
{children}
;
+ }
+ __register__(Child, 'Child');
+
+ function Parent({children}) {
+ return (
+
+ );
+ }
+ __register__(Parent, 'Parent');
+
+ function App() {
+ return (
+
+ );
+ }
+ __register__(App, 'App');
+
+ class Cls extends React.Component {
+ render() {
+ return this.props.children;
+ }
+ }
+
+ function Indirection({children}) {
+ return children;
+ }
+
+ function Empty() {
+ return null;
+ }
+ __register__(Empty, 'Empty');
+
+ function Frag() {
+ return (
+
+
+
+
+ );
+ }
+ __register__(Frag, 'Frag');
+
+ return App;
+ });
+
+ const parentFamily = ReactFreshRuntime.getFamilyByID('Parent');
+ const childFamily = ReactFreshRuntime.getFamilyByID('Child');
+ const emptyFamily = ReactFreshRuntime.getFamilyByID('Empty');
+
+ testFindNodesForFamilies(
+ [parentFamily],
+ container.querySelectorAll('.Parent'),
+ );
+
+ testFindNodesForFamilies(
+ [childFamily],
+ container.querySelectorAll('.Child'),
+ );
+
+ // When searching for both Parent and Child,
+ // we'll stop visual highlighting at the Parent.
+ testFindNodesForFamilies(
+ [parentFamily, childFamily],
+ container.querySelectorAll('.Parent'),
+ );
+
+ // When we can't find host nodes, use the closest parent.
+ testFindNodesForFamilies(
+ [emptyFamily],
+ container.querySelectorAll('.App'),
+ );
+ }
+ });
+
+ function testFindNodesForFamilies(families, expectedNodes) {
+ const foundNodes = Array.from(
+ findHostNodesForHotUpdate(lastRoot, families),
+ );
+ expect(foundNodes.length).toEqual(expectedNodes.length);
+ foundNodes.forEach((node, i) => {
+ expect(node).toBe(expectedNodes[i]);
+ });
+ }
});
diff --git a/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js b/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js
index f89eee4d36..ce4d088679 100644
--- a/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js
+++ b/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js
@@ -73,6 +73,8 @@ describe('ReactFreshIntegration', () => {
act(() => {
ReactDOM.render(, container);
});
+ // Module initialization shouldn't be counted as a hot update.
+ expect(ReactFreshRuntime.prepareUpdate()).toBe(null);
}
function patch(source) {
@@ -275,9 +277,10 @@ describe('ReactFreshIntegration', () => {
if (__DEV__) {
render(`
const {useState} = React;
+ const S = 1;
export default function App() {
- const [foo, setFoo] = useState(1);
+ const [foo, setFoo] = useState(S);
return A{foo}
;
}
`);
@@ -286,9 +289,10 @@ describe('ReactFreshIntegration', () => {
patch(`
const {useState} = React;
+ const S = 2;
export default function App() {
- const [foo, setFoo] = useState('ignored');
+ const [foo, setFoo] = useState(S);
return B{foo}
;
}
`);
@@ -298,16 +302,17 @@ describe('ReactFreshIntegration', () => {
patch(`
const {useState} = React;
+ const S = 3;
export default function App() {
- const [bar, setBar] = useState(2);
+ const [bar, setBar] = useState(S);
return C{bar}
;
}
`);
// Different state variable name, so state is reset.
expect(container.firstChild).not.toBe(el);
const newEl = container.firstChild;
- expect(newEl.textContent).toBe('C2');
+ expect(newEl.textContent).toBe('C3');
}
});
@@ -315,10 +320,11 @@ describe('ReactFreshIntegration', () => {
if (__DEV__) {
render(`
const {useState} = React;
+ const S = 1;
function hoc(Wrapped) {
return function Generated() {
- const [foo, setFoo] = useState(1);
+ const [foo, setFoo] = useState(S);
return ;
};
}
@@ -332,10 +338,11 @@ describe('ReactFreshIntegration', () => {
patch(`
const {useState} = React;
+ const S = 2;
function hoc(Wrapped) {
return function Generated() {
- const [foo, setFoo] = useState('ignored');
+ const [foo, setFoo] = useState(S);
return ;
};
}
@@ -350,10 +357,11 @@ describe('ReactFreshIntegration', () => {
patch(`
const {useState} = React;
+ const S = 3;
function hoc(Wrapped) {
return function Generated() {
- const [bar, setBar] = useState(2);
+ const [bar, setBar] = useState(S);
return ;
};
}
@@ -365,7 +373,7 @@ describe('ReactFreshIntegration', () => {
// Different state variable name, so state is reset.
expect(container.firstChild).not.toBe(el);
const newEl = container.firstChild;
- expect(newEl.textContent).toBe('C2');
+ expect(newEl.textContent).toBe('C3');
}
});
@@ -373,10 +381,11 @@ describe('ReactFreshIntegration', () => {
if (__DEV__) {
render(`
const {useState} = React;
+ const S = 1;
function hoc(Wrapped) {
return function Generated() {
- const [foo, setFoo] = useState(1);
+ const [foo, setFoo] = useState(S);
return ;
};
}
@@ -392,10 +401,11 @@ describe('ReactFreshIntegration', () => {
patch(`
const {useState} = React;
+ const S = 2;
function hoc(Wrapped) {
return function Generated() {
- const [foo, setFoo] = useState('ignored');
+ const [foo, setFoo] = useState(S);
return ;
};
}
@@ -412,10 +422,11 @@ describe('ReactFreshIntegration', () => {
patch(`
const {useState} = React;
+ const S = 3;
function hoc(Wrapped) {
return function Generated() {
- const [bar, setBar] = useState(2);
+ const [bar, setBar] = useState(S);
return ;
};
}
@@ -429,7 +440,7 @@ describe('ReactFreshIntegration', () => {
// Different state variable name, so state is reset.
expect(container.firstChild).not.toBe(el);
const newEl = container.firstChild;
- expect(newEl.textContent).toBe('C2');
+ expect(newEl.textContent).toBe('C3');
}
});
@@ -754,6 +765,114 @@ describe('ReactFreshIntegration', () => {
});
it('resets state on every edit with @hot reset annotation', () => {
+ if (__DEV__) {
+ render(`
+ const {useState} = React;
+ const S = 1;
+
+ export default function App() {
+ const [foo, setFoo] = useState(S);
+ return A{foo}
;
+ }
+ `);
+ let el = container.firstChild;
+ expect(el.textContent).toBe('A1');
+
+ patch(`
+ const {useState} = React;
+ const S = 2;
+
+ export default function App() {
+ const [foo, setFoo] = useState(S);
+ return B{foo}
;
+ }
+ `);
+ // Same state variable name, so state is preserved.
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('B1');
+
+ patch(`
+ const {useState} = React;
+ const S = 3;
+
+ /* @hot reset */
+
+ export default function App() {
+ const [foo, setFoo] = useState(S);
+ return C{foo}
;
+ }
+ `);
+ // Found remount annotation, so state is reset.
+ expect(container.firstChild).not.toBe(el);
+ el = container.firstChild;
+ expect(el.textContent).toBe('C3');
+
+ patch(`
+ const {useState} = React;
+ const S = 4;
+
+ export default function App() {
+
+ // @hot reset
+
+ const [foo, setFoo] = useState(S);
+ return D{foo}
;
+ }
+ `);
+ // Found remount annotation, so state is reset.
+ expect(container.firstChild).not.toBe(el);
+ el = container.firstChild;
+ expect(el.textContent).toBe('D4');
+
+ patch(`
+ const {useState} = React;
+ const S = 5;
+
+ export default function App() {
+ const [foo, setFoo] = useState(S);
+ return E{foo}
;
+ }
+ `);
+ // There is no remount annotation anymore,
+ // so preserve the previous state.
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('E4');
+
+ patch(`
+ const {useState} = React;
+ const S = 6;
+
+ export default function App() {
+ const [foo, setFoo] = useState(S);
+ return F{foo}
;
+ }
+ `);
+ // Continue editing.
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('F4');
+
+ patch(`
+ const {useState} = React;
+ const S = 7;
+
+ export default function App() {
+
+ /* @hot reset */
+
+ const [foo, setFoo] = useState(S);
+ return G{foo}
;
+ }
+ `);
+ // Force remount one last time.
+ expect(container.firstChild).not.toBe(el);
+ el = container.firstChild;
+ expect(el.textContent).toBe('G7');
+ }
+ });
+
+ // This is best effort for simple cases.
+ // We won't attempt to resolve identifiers.
+ it('resets state when useState initial state is edited', () => {
if (__DEV__) {
render(`
const {useState} = React;
@@ -770,85 +889,68 @@ describe('ReactFreshIntegration', () => {
const {useState} = React;
export default function App() {
- const [foo, setFoo] = useState('ignored');
+ const [foo, setFoo] = useState(1);
return B{foo}
;
}
`);
- // Same state variable name, so state is preserved.
+ // Same initial state, so it's preserved.
expect(container.firstChild).toBe(el);
expect(el.textContent).toBe('B1');
patch(`
const {useState} = React;
- /* @hot reset */
-
export default function App() {
- const [bar, setBar] = useState(2);
- return C{bar}
;
+ const [foo, setFoo] = useState(2);
+ return C{foo}
;
}
`);
- // Found remount annotation, so state is reset.
+ // Different initial state, so state is reset.
expect(container.firstChild).not.toBe(el);
el = container.firstChild;
expect(el.textContent).toBe('C2');
+ }
+ });
- patch(`
- const {useState} = React;
+ // This is best effort for simple cases.
+ // We won't attempt to resolve identifiers.
+ it('resets state when useReducer initial state is edited', () => {
+ if (__DEV__) {
+ render(`
+ const {useReducer} = React;
export default function App() {
-
- // @hot reset
-
- const [bar, setBar] = useState(3);
- return D{bar}
;
+ const [foo, setFoo] = useReducer(x => x, 1);
+ return A{foo}
;
}
`);
- // Found remount annotation, so state is reset.
+ let el = container.firstChild;
+ expect(el.textContent).toBe('A1');
+
+ patch(`
+ const {useReducer} = React;
+
+ export default function App() {
+ const [foo, setFoo] = useReducer(x => x, 1);
+ return B{foo}
;
+ }
+ `);
+ // Same initial state, so it's preserved.
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('B1');
+
+ patch(`
+ const {useReducer} = React;
+
+ export default function App() {
+ const [foo, setFoo] = useReducer(x => x, 2);
+ return C{foo}
;
+ }
+ `);
+ // Different initial state, so state is reset.
expect(container.firstChild).not.toBe(el);
el = container.firstChild;
- expect(el.textContent).toBe('D3');
-
- patch(`
- const {useState} = React;
-
- export default function App() {
- const [bar, setBar] = useState(4);
- return E{bar}
;
- }
- `);
- // There is no remount annotation anymore,
- // so preserve the previous state.
- expect(container.firstChild).toBe(el);
- expect(el.textContent).toBe('E3');
-
- patch(`
- const {useState} = React;
-
- export default function App() {
- const [bar, setBar] = useState(4);
- return F{bar}
;
- }
- `);
- // Continue editing.
- expect(container.firstChild).toBe(el);
- expect(el.textContent).toBe('F3');
-
- patch(`
- const {useState} = React;
-
- export default function App() {
-
- /* @hot reset */
-
- const [bar, setBar] = useState(5);
- return G{bar}
;
- }
- `);
- // Force remount one last time.
- expect(container.firstChild).not.toBe(el);
- el = container.firstChild;
- expect(el.textContent).toBe('G5');
+ expect(el.textContent).toBe('C2');
}
});
diff --git a/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap b/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap
index b43b2e04b7..d18cd4ba61 100644
--- a/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap
+++ b/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap
@@ -11,7 +11,7 @@ export default function App() {
return {foo}
;
}
-_s(App, "useState{[foo, setFoo]}\\nuseEffect{}");
+_s(App, "useState{[foo, setFoo](0)}\\nuseEffect{}");
_c = App;
@@ -30,7 +30,7 @@ export const A = _c3 = React.memo(_c2 = React.forwardRef(_c = _s((props, ref) =>
const [foo, setFoo] = useState(0);
React.useEffect(() => {});
return {foo}
;
-}, "useState{[foo, setFoo]}\\nuseEffect{}")));
+}, "useState{[foo, setFoo](0)}\\nuseEffect{}")));
export const B = _c6 = React.memo(_c5 = React.forwardRef(_c4 = _s2(function (props, ref) {
_s2();
@@ -38,7 +38,7 @@ export const B = _c6 = React.memo(_c5 = React.forwardRef(_c4 = _s2(function (pro
const [foo, setFoo] = useState(0);
React.useEffect(() => {});
return {foo}
;
-}, "useState{[foo, setFoo]}\\nuseEffect{}")));
+}, "useState{[foo, setFoo](0)}\\nuseEffect{}")));
function hoc() {
var _s3 = __signature__();
@@ -49,7 +49,7 @@ function hoc() {
const [foo, setFoo] = useState(0);
React.useEffect(() => {});
return {foo}
;
- }, "useState{[foo, setFoo]}\\nuseEffect{}");
+ }, "useState{[foo, setFoo](0)}\\nuseEffect{}");
}
export let C = hoc();
@@ -87,7 +87,7 @@ export default function App() {
return foo;
}
- _s(useFancyState, 'useState{[foo, setFoo]}\\nuseFancyEffect{}', true);
+ _s(useFancyState, 'useState{[foo, setFoo](0)}\\nuseFancyEffect{}', true);
const bar = useFancyState();
const baz = FancyHook.useThing();
@@ -169,7 +169,7 @@ function useFancyState() {
return foo;
}
-_s(useFancyState, "useState{[foo, setFoo]}\\nuseFancyEffect{}", false, () => [useFancyEffect]);
+_s(useFancyState, "useState{[foo, setFoo](0)}\\nuseFancyEffect{}", false, () => [useFancyEffect]);
const useFancyEffect = () => {
_s2();
diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.js b/packages/react-reconciler/src/ReactFiberHotReloading.js
index 60f625bd51..9645fdca2b 100644
--- a/packages/react-reconciler/src/ReactFiberHotReloading.js
+++ b/packages/react-reconciler/src/ReactFiberHotReloading.js
@@ -10,6 +10,7 @@
import type {ReactElement} from 'shared/ReactElementType';
import type {Fiber} from './ReactFiber';
import type {FiberRoot} from './ReactFiberRoot';
+import type {Instance} from './ReactFiberHostConfig';
import {
flushSync,
@@ -21,6 +22,9 @@ import {
ClassComponent,
FunctionComponent,
ForwardRef,
+ HostComponent,
+ HostPortal,
+ HostRoot,
MemoComponent,
SimpleMemoComponent,
} from 'shared/ReactWorkTags';
@@ -287,3 +291,134 @@ function scheduleFibersWithFamiliesRecursively(
}
}
}
+
+export function findHostNodesForHotUpdate(
+ root: FiberRoot,
+ families: Array,
+): Set {
+ if (__DEV__) {
+ const hostNodes = new Set();
+ const types = new Set(families.map(family => family.current));
+ findHostNodesForMatchingFibersRecursively(root.current, types, hostNodes);
+ return hostNodes;
+ } else {
+ throw new Error(
+ 'Did not expect findHostNodesForHotUpdate to be called in production.',
+ );
+ }
+}
+
+function findHostNodesForMatchingFibersRecursively(
+ fiber: Fiber,
+ types: Set,
+ hostNodes: Set,
+) {
+ if (__DEV__) {
+ const {child, sibling, tag, type} = fiber;
+
+ let candidateType = null;
+ switch (tag) {
+ case FunctionComponent:
+ case SimpleMemoComponent:
+ case ClassComponent:
+ candidateType = type;
+ break;
+ case ForwardRef:
+ candidateType = type.render;
+ break;
+ default:
+ break;
+ }
+
+ let didMatch = false;
+ if (candidateType !== null) {
+ if (types.has(candidateType)) {
+ didMatch = true;
+ }
+ }
+
+ if (didMatch) {
+ // We have a match. This only drills down to the closest host components.
+ // There's no need to search deeper because for the purpose of giving
+ // visual feedback, "flashing" outermost parent rectangles is sufficient.
+ findHostNodesForFiberShallowly(fiber, hostNodes);
+ } else {
+ // If there's no match, maybe there will be one further down in the child tree.
+ if (child !== null) {
+ findHostNodesForMatchingFibersRecursively(child, types, hostNodes);
+ }
+ }
+
+ if (sibling !== null) {
+ findHostNodesForMatchingFibersRecursively(sibling, types, hostNodes);
+ }
+ }
+}
+
+function findHostNodesForFiberShallowly(
+ fiber: Fiber,
+ hostNodes: Set,
+): void {
+ if (__DEV__) {
+ const foundHostNodes = findChildHostNodesForFiberShallowly(
+ fiber,
+ hostNodes,
+ );
+ if (foundHostNodes) {
+ return;
+ }
+ // If we didn't find any host children, fallback to closest host parent.
+ let node = fiber;
+ while (true) {
+ switch (node.tag) {
+ case HostComponent:
+ hostNodes.add(node.stateNode);
+ return;
+ case HostPortal:
+ hostNodes.add(node.stateNode.containerInfo);
+ return;
+ case HostRoot:
+ hostNodes.add(node.stateNode.containerInfo);
+ return;
+ }
+ if (node.return === null) {
+ throw new Error('Expected to reach root first.');
+ }
+ node = node.return;
+ }
+ }
+}
+
+function findChildHostNodesForFiberShallowly(
+ fiber: Fiber,
+ hostNodes: Set,
+): boolean {
+ if (__DEV__) {
+ let node: Fiber = fiber;
+ let foundHostNodes = false;
+ while (true) {
+ if (node.tag === HostComponent) {
+ // We got a match.
+ foundHostNodes = true;
+ hostNodes.add(node.stateNode);
+ // There may still be more, so keep searching.
+ } else if (node.child !== null) {
+ node.child.return = node;
+ node = node.child;
+ continue;
+ }
+ if (node === fiber) {
+ return foundHostNodes;
+ }
+ while (node.sibling === null) {
+ if (node.return === null || node.return === fiber) {
+ return foundHostNodes;
+ }
+ node = node.return;
+ }
+ node.sibling.return = node.return;
+ node = node.sibling;
+ }
+ }
+ return false;
+}
diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js
index de84dd7b67..df1828a233 100644
--- a/packages/react-reconciler/src/ReactFiberReconciler.js
+++ b/packages/react-reconciler/src/ReactFiberReconciler.js
@@ -70,7 +70,10 @@ import {StrictMode} from './ReactTypeOfMode';
import {Sync} from './ReactFiberExpirationTime';
import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags';
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
-import {scheduleHotUpdate} from './ReactFiberHotReloading';
+import {
+ scheduleHotUpdate,
+ findHostNodesForHotUpdate,
+} from './ReactFiberHotReloading';
type OpaqueRoot = FiberRoot;
@@ -472,6 +475,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean {
return injectInternals({
...devToolsConfig,
+ findHostNodesForHotUpdate: __DEV__ ? findHostNodesForHotUpdate : null,
scheduleHotUpdate: __DEV__ ? scheduleHotUpdate : null,
overrideHookState,
overrideProps,