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,