diff --git a/packages/react-fresh/src/ReactFreshBabelPlugin.js b/packages/react-fresh/src/ReactFreshBabelPlugin.js index 96eab42908bb..eb4658556167 100644 --- a/packages/react-fresh/src/ReactFreshBabelPlugin.js +++ b/packages/react-fresh/src/ReactFreshBabelPlugin.js @@ -163,36 +163,6 @@ export default function(babel) { return false; } - let hookCalls = new WeakMap(); - - function recordHookCall(functionNode, hookCallPath, hookName) { - if (!hookCalls.has(functionNode)) { - hookCalls.set(functionNode, []); - } - let hookCallsForFn = hookCalls.get(functionNode); - let key = ''; - if (hookCallPath.parent.type === 'VariableDeclarator') { - // 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, - key, - }); - } - function isBuiltinHook(hookName) { switch (hookName) { case 'useState': @@ -299,9 +269,64 @@ export default function(babel) { let seenForRegistration = new WeakSet(); let seenForSignature = new WeakSet(); - let seenForHookCalls = new WeakSet(); let seenForOutro = new WeakSet(); + let hookCalls = new WeakMap(); + const HookCallsVisitor = { + CallExpression(path) { + const node = path.node; + const callee = node.callee; + + // Note: this visitor MUST NOT mutate the tree in any way. + // It runs early in a separate traversal and should be very fast. + + let name = null; + switch (callee.type) { + case 'Identifier': + name = callee.name; + break; + case 'MemberExpression': + name = callee.property.name; + break; + } + if (name === null || !/^use[A-Z]/.test(name)) { + return; + } + const fnScope = path.scope.getFunctionParent(); + if (fnScope === null) { + return; + } + + // This is a Hook call. Record it. + const fnNode = fnScope.block; + if (!hookCalls.has(fnNode)) { + hookCalls.set(fnNode, []); + } + let hookCallsForFn = hookCalls.get(fnNode); + let key = ''; + if (path.parent.type === 'VariableDeclarator') { + // TODO: if there is no LHS, consider some other heuristic. + key = path.parentPath.get('id').getSource(); + } + + // Some built-in Hooks reset on edits to arguments. + const args = path.get('arguments'); + if (name === 'useState' && args.length > 0) { + // useState second argument is initial state. + key += '(' + args[0].getSource() + ')'; + } else if (name === 'useReducer' && args.length > 1) { + // useReducer second argument is initial state. + key += '(' + args[1].getSource() + ')'; + } + + hookCallsForFn.push({ + callee: path.node.callee, + name, + key, + }); + }, + }; + return { visitor: { ExportDefaultDeclaration(path) { @@ -617,38 +642,14 @@ export default function(babel) { }, ); }, - CallExpression(path) { - const node = path.node; - const callee = node.callee; - - let name = null; - switch (callee.type) { - case 'Identifier': - name = callee.name; - break; - case 'MemberExpression': - name = callee.property.name; - break; - } - if (name === null || !/^use[A-Z]/.test(name)) { - return; - } - - // Make sure we're not recording the same calls twice. - // This can happen if another Babel plugin replaces parents. - if (seenForHookCalls.has(node)) { - return; - } - seenForHookCalls.add(node); - // Don't mutate the tree above this point. - - const fn = path.scope.getFunctionParent(); - if (fn === null) { - return; - } - recordHookCall(fn.block, path, name); - }, Program: { + enter(path) { + // This is a separate early visitor because we need to collect Hook calls + // and "const [foo, setFoo] = ..." signatures before the destructuring + // transform mangles them. This extra traversal is not ideal for perf, + // but it's the best we can do until we stop transpiling destructuring. + path.traverse(HookCallsVisitor); + }, exit(path) { const registrations = registrationsByProgramPath.get(path); if (registrations === undefined) { diff --git a/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js b/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js index ce4d088679b7..50617ad0d2f8 100644 --- a/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js +++ b/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js @@ -49,937 +49,509 @@ describe('ReactFreshIntegration', () => { document.body.removeChild(container); }); - function execute(source) { - const compiled = babel.transform(source, { - babelrc: false, - presets: ['react'], - plugins: [freshPlugin, 'transform-es2015-modules-commonjs'], - }).code; - const exportsObj = {}; - // eslint-disable-next-line no-new-func - new Function( - 'global', - 'React', - 'exports', - '__register__', - '__signature__', - compiled, - )(global, React, exportsObj, __register__, __signature__); - return exportsObj.default; - } - - function render(source) { - const Component = execute(source); - act(() => { - ReactDOM.render(, container); - }); - // Module initialization shouldn't be counted as a hot update. - expect(ReactFreshRuntime.prepareUpdate()).toBe(null); - } - - function patch(source) { - execute(source); - const hotUpdate = ReactFreshRuntime.prepareUpdate(); - act(() => { - scheduleHotUpdate(lastRoot, hotUpdate); - }); - } - - function __register__(type, id) { - ReactFreshRuntime.register(type, id); - } - - function __signature__() { - let call = 0; - let savedType; - let hasCustomHooks; - return function(type, key, forceReset, getCustomHooks) { - switch (call++) { - case 0: - savedType = type; - hasCustomHooks = typeof getCustomHooks === 'function'; - ReactFreshRuntime.setSignature(type, key, forceReset, getCustomHooks); - break; - case 1: - if (hasCustomHooks) { - ReactFreshRuntime.collectCustomHooksForSignature(savedType); - } - break; - } - return type; - }; - } - - it('reloads function declarations', () => { - if (__DEV__) { - render(` - function Parent() { - return ; - }; - - function Child({prop}) { - return

{prop}1

; - }; - - export default Parent; - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - patch(` - function Parent() { - return ; - }; - - function Child({prop}) { - return

{prop}2

; - }; - - export default Parent; - `); - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('B2'); - } + describe('with compiled destructuring', () => { + runTests(true); }); - it('reloads arrow functions', () => { - if (__DEV__) { - render(` - const Parent = () => { - return ; - }; - - const Child = ({prop}) => { - return

{prop}1

; - }; - - export default Parent; - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - patch(` - const Parent = () => { - return ; - }; - - const Child = ({prop}) => { - return

{prop}2

; - }; - - export default Parent; - `); - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('B2'); - } + describe('without compiled destructuring', () => { + runTests(false); }); - it('reloads a combination of memo and forwardRef', () => { - if (__DEV__) { - render(` - const {memo} = React; - - const Parent = memo(React.forwardRef(function (props, ref) { - return ; - })); - - const Child = React.memo(({prop}) => { - return

{prop}1

; - }); - - export default React.memo(Parent); - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - patch(` - const {memo} = React; - - const Parent = memo(React.forwardRef(function (props, ref) { - return ; - })); - - const Child = React.memo(({prop}) => { - return

{prop}2

; - }); - - export default React.memo(Parent); - `); - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('B2'); + function runTests(compileDestructuring) { + function execute(source) { + const compiled = babel.transform(source, { + babelrc: false, + presets: ['react'], + plugins: [ + freshPlugin, + 'transform-es2015-modules-commonjs', + compileDestructuring && 'transform-es2015-destructuring', + ].filter(Boolean), + }).code; + const exportsObj = {}; + // eslint-disable-next-line no-new-func + new Function( + 'global', + 'React', + 'exports', + '__register__', + '__signature__', + compiled, + )(global, React, exportsObj, __register__, __signature__); + return exportsObj.default; } - }); - it('reloads default export with named memo', () => { - if (__DEV__) { - render(` - const {memo} = React; - - const Child = React.memo(({prop}) => { - return

{prop}1

; - }); - - export default memo(React.forwardRef(function Parent(props, ref) { - return ; - })); - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - patch(` - const {memo} = React; - - const Child = React.memo(({prop}) => { - return

{prop}2

; - }); - - export default memo(React.forwardRef(function Parent(props, ref) { - return ; - })); - `); - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('B2'); + function render(source) { + const Component = execute(source); + act(() => { + ReactDOM.render(, container); + }); + // Module initialization shouldn't be counted as a hot update. + expect(ReactFreshRuntime.prepareUpdate()).toBe(null); } - }); - it('reloads HOCs if they return functions', () => { - if (__DEV__) { - render(` - function hoc(letter) { - return function() { - return

{letter}1

; - } - } - - export default function Parent() { - return ; - } - - const Child = hoc('A'); - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - patch(` - function hoc(letter) { - return function() { - return

{letter}2

; - } - } - - export default function Parent() { - return React.createElement(Child); - } - - const Child = hoc('B'); - `); - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('B2'); + function patch(source) { + execute(source); + const hotUpdate = ReactFreshRuntime.prepareUpdate(); + act(() => { + scheduleHotUpdate(lastRoot, hotUpdate); + }); } - }); - it('resets state when renaming a state variable', () => { - if (__DEV__) { - render(` - const {useState} = React; - const S = 1; - - export default function App() { - const [foo, setFoo] = useState(S); - return

A{foo}

; - } - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - - patch(` - const {useState} = React; - const S = 2; + function __register__(type, id) { + ReactFreshRuntime.register(type, id); + } - export default function App() { - const [foo, setFoo] = useState(S); - return

B{foo}

; + function __signature__() { + let call = 0; + let savedType; + let hasCustomHooks; + return function(type, key, forceReset, getCustomHooks) { + switch (call++) { + case 0: + savedType = type; + hasCustomHooks = typeof getCustomHooks === 'function'; + ReactFreshRuntime.setSignature( + type, + key, + forceReset, + getCustomHooks, + ); + break; + case 1: + if (hasCustomHooks) { + ReactFreshRuntime.collectCustomHooksForSignature(savedType); + } + break; } - `); - // 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; - - export default function App() { - 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('C3'); + return type; + }; } - }); - it('resets state when renaming a state variable in a HOC', () => { - if (__DEV__) { - render(` - const {useState} = React; - const S = 1; - - function hoc(Wrapped) { - return function Generated() { - const [foo, setFoo] = useState(S); - return ; + it('reloads function declarations', () => { + if (__DEV__) { + render(` + function Parent() { + return ; }; - } - - export default hoc(({ value }) => { - return

A{value}

; - }); - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - - patch(` - const {useState} = React; - const S = 2; - function hoc(Wrapped) { - return function Generated() { - const [foo, setFoo] = useState(S); - return ; + function Child({prop}) { + return

{prop}1

; }; - } - export default hoc(({ value }) => { - return

B{value}

; - }); - `); - // 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; - - function hoc(Wrapped) { - return function Generated() { - const [bar, setBar] = useState(S); - return ; + export default Parent; + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); + patch(` + function Parent() { + return ; }; - } - export default hoc(({ value }) => { - return

C{value}

; - }); - `); - // Different state variable name, so state is reset. - expect(container.firstChild).not.toBe(el); - const newEl = container.firstChild; - expect(newEl.textContent).toBe('C3'); - } - }); - - it('resets state when renaming a state variable in a HOC with indirection', () => { - if (__DEV__) { - render(` - const {useState} = React; - const S = 1; - - function hoc(Wrapped) { - return function Generated() { - const [foo, setFoo] = useState(S); - return ; + function Child({prop}) { + return

{prop}2

; }; - } - - function Indirection({ value }) { - return

A{value}

; - } - - export default hoc(Indirection); - `); - const el = container.firstChild; - expect(el.textContent).toBe('A1'); - patch(` - const {useState} = React; - const S = 2; + export default Parent; + `); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B2'); + } + }); - function hoc(Wrapped) { - return function Generated() { - const [foo, setFoo] = useState(S); - return ; + it('reloads arrow functions', () => { + if (__DEV__) { + render(` + const Parent = () => { + return ; }; - } - - function Indirection({ value }) { - return

B{value}

; - } - - export default hoc(Indirection); - `); - // 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; - function hoc(Wrapped) { - return function Generated() { - const [bar, setBar] = useState(S); - return ; + const Child = ({prop}) => { + return

{prop}1

; }; - } - - function Indirection({ value }) { - return

C{value}

; - } - export default hoc(Indirection); - `); - // Different state variable name, so state is reset. - expect(container.firstChild).not.toBe(el); - const newEl = container.firstChild; - expect(newEl.textContent).toBe('C3'); - } - }); - - it('resets effects while preserving state', () => { - if (__DEV__) { - render(` - const {useState} = React; + export default Parent; + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); + patch(` + const Parent = () => { + return ; + }; - export default function App() { - const [value, setValue] = useState(0); - return

A{value}

; - } - `); - let el = container.firstChild; - expect(el.textContent).toBe('A0'); - - // Add an effect. - patch(` - const {useState} = React; - - export default function App() { - const [value, setValue] = useState(0); - React.useEffect(() => { - const id = setInterval(() => { - setValue(v => v + 1); - }, 1000); - return () => clearInterval(id); - }, []); - return

B{value}

; - } - `); - // We added an effect, thereby changing Hook order. - // This causes a remount. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; - expect(el.textContent).toBe('B0'); + const Child = ({prop}) => { + return

{prop}2

; + }; - act(() => { - jest.advanceTimersByTime(1000); - }); - expect(el.textContent).toBe('B1'); - - patch(` - const {useState} = React; - - export default function App() { - const [value, setValue] = useState(0); - React.useEffect(() => { - const id = setInterval(() => { - setValue(v => v + 10); - }, 1000); - return () => clearInterval(id); - }, []); - return

C{value}

; - } - `); - // Same Hooks are called, so state is preserved. - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('C1'); + export default Parent; + `); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B2'); + } + }); - // Effects are always reset, so timer was reinstalled. - // The new version increments by 10 rather than 1. - act(() => { - jest.advanceTimersByTime(1000); - }); - expect(el.textContent).toBe('C11'); + it('reloads a combination of memo and forwardRef', () => { + if (__DEV__) { + render(` + const {memo} = React; - patch(` - const {useState} = React; + const Parent = memo(React.forwardRef(function (props, ref) { + return ; + })); - export default function App() { - const [value, setValue] = useState(0); - return

D{value}

; - } - `); - // Removing the effect changes the signature - // and causes the component to remount. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; - expect(el.textContent).toBe('D0'); - } - }); + const Child = React.memo(({prop}) => { + return

{prop}1

; + }); - it('does not get confused when custom hooks are reordered', () => { - if (__DEV__) { - render(` - function useFancyState(initialState) { - return React.useState(initialState); - } + export default React.memo(Parent); + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); + patch(` + const {memo} = React; - const App = () => { - const [x, setX] = useFancyState('X'); - const [y, setY] = useFancyState('Y'); - return

A{x}{y}

; - }; + const Parent = memo(React.forwardRef(function (props, ref) { + return ; + })); - export default App; - `); - let el = container.firstChild; - expect(el.textContent).toBe('AXY'); + const Child = React.memo(({prop}) => { + return

{prop}2

; + }); - patch(` - function useFancyState(initialState) { - return React.useState(initialState); - } + export default React.memo(Parent); + `); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B2'); + } + }); - const App = () => { - const [x, setX] = useFancyState('X'); - const [y, setY] = useFancyState('Y'); - return

B{x}{y}

; - }; - - export default App; - `); - // Same state variables, so no remount. - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('BXY'); - - patch(` - function useFancyState(initialState) { - return React.useState(initialState); - } + it('reloads default export with named memo', () => { + if (__DEV__) { + render(` + const {memo} = React; - const App = () => { - const [y, setY] = useFancyState('Y'); - const [x, setX] = useFancyState('X'); - return

B{x}{y}

; - }; - - export default App; - `); - // Hooks were re-ordered. This causes a remount. - // Therefore, Hook calls don't accidentally share state. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; - expect(el.textContent).toBe('BXY'); - } - }); + const Child = React.memo(({prop}) => { + return

{prop}1

; + }); - it('does not get confused by Hooks defined inline', () => { - // This is not a recommended pattern but at least it shouldn't break. - if (__DEV__) { - render(` - const App = () => { - const useFancyState = (initialState) => { - const result = React.useState(initialState); - return result; - }; - const [x, setX] = useFancyState('X1'); - const [y, setY] = useFancyState('Y1'); - return

A{x}{y}

; - }; - - export default App; - `); - let el = container.firstChild; - expect(el.textContent).toBe('AX1Y1'); - - patch(` - const App = () => { - const useFancyState = (initialState) => { - const result = React.useState(initialState); - return result; - }; - const [x, setX] = useFancyState('X2'); - const [y, setY] = useFancyState('Y2'); - return

B{x}{y}

; - }; - - export default App; - `); - // Remount even though nothing changed because - // the custom Hook is inside -- and so we don't - // really know whether its signature has changed. - // We could potentially make it work, but for now - // let's assert we don't crash with confusing errors. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; - expect(el.textContent).toBe('BX2Y2'); - } - }); + export default memo(React.forwardRef(function Parent(props, ref) { + return ; + })); + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); + patch(` + const {memo} = React; - it('remounts component if custom hook it uses changes order', () => { - if (__DEV__) { - render(` - const App = () => { - const [x, setX] = useFancyState('X'); - const [y, setY] = useFancyState('Y'); - return

A{x}{y}

; - }; - - const useFancyState = (initialState) => { - const result = useIndirection(initialState); - return result; - }; - - function useIndirection(initialState) { - return React.useState(initialState); - } + const Child = React.memo(({prop}) => { + return

{prop}2

; + }); - export default App; - `); - let el = container.firstChild; - expect(el.textContent).toBe('AXY'); - - patch(` - const App = () => { - const [x, setX] = useFancyState('X'); - const [y, setY] = useFancyState('Y'); - return

B{x}{y}

; - }; - - const useFancyState = (initialState) => { - const result = useIndirection(); - return result; - }; - - function useIndirection(initialState) { - return React.useState(initialState); - } + export default memo(React.forwardRef(function Parent(props, ref) { + return ; + })); + `); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B2'); + } + }); - export default App; - `); - // We didn't change anything except the header text. - // So we don't expect a remount. - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('BXY'); - - patch(` - const App = () => { - const [x, setX] = useFancyState('X'); - const [y, setY] = useFancyState('Y'); - return

C{x}{y}

; - }; - - const useFancyState = (initialState) => { - const result = useIndirection(initialState); - return result; - }; - - function useIndirection(initialState) { - React.useEffect(() => {}); - return React.useState(initialState); - } + it('reloads HOCs if they return functions', () => { + if (__DEV__) { + render(` + function hoc(letter) { + return function() { + return

{letter}1

; + } + } - export default App; - `); - // The useIndirection Hook added an affect, - // so we had to remount the component. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; - expect(el.textContent).toBe('CXY'); - - patch(` - const App = () => { - const [x, setX] = useFancyState('X'); - const [y, setY] = useFancyState('Y'); - return

D{x}{y}

; - }; - - const useFancyState = (initialState) => { - const result = useIndirection(); - return result; - }; - - function useIndirection(initialState) { - React.useEffect(() => {}); - return React.useState(initialState); - } + export default function Parent() { + return ; + } - export default App; - `); - // We didn't change anything except the header text. - // So we don't expect a remount. - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('DXY'); - } - }); + const Child = hoc('A'); + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); + patch(` + function hoc(letter) { + return function() { + return

{letter}2

; + } + } - it('does not lose the inferred arrow names', () => { - if (__DEV__) { - render(` - const Parent = () => { - return ; - }; - - const Child = () => { - useMyThing(); - return

{Parent.name} {Child.name} {useMyThing.name}

; - }; - - const useMyThing = () => { - React.useState(); - }; - - export default Parent; - `); - expect(container.textContent).toBe('Parent Child useMyThing'); - } - }); + export default function Parent() { + return React.createElement(Child); + } - it('does not lose the inferred function names', () => { - if (__DEV__) { - render(` - var Parent = function() { - return ; - }; - - var Child = function() { - useMyThing(); - return

{Parent.name} {Child.name} {useMyThing.name}

; - }; - - var useMyThing = function() { - React.useState(); - }; - - export default Parent; - `); - expect(container.textContent).toBe('Parent Child useMyThing'); - } - }); + const Child = hoc('B'); + `); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B2'); + } + }); - it('resets state on every edit with @hot reset annotation', () => { - if (__DEV__) { - render(` - const {useState} = React; - const S = 1; + it('resets state when renaming a state variable', () => { + 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'); + export default function App() { + const [foo, setFoo] = useState(S); + return

A{foo}

; + } + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); - patch(` - const {useState} = React; - const S = 2; + 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'); + 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; + patch(` + const {useState} = React; + const S = 3; - /* @hot reset */ + export default function App() { + 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('C3'); + } + }); - 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'); + it('resets state when renaming a state variable in a HOC', () => { + if (__DEV__) { + render(` + const {useState} = React; + const S = 1; + + function hoc(Wrapped) { + return function Generated() { + const [foo, setFoo] = useState(S); + return ; + }; + } - patch(` - const {useState} = React; - const S = 4; + export default hoc(({ value }) => { + return

A{value}

; + }); + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); - export default function App() { + patch(` + const {useState} = React; + const S = 2; + + function hoc(Wrapped) { + return function Generated() { + const [foo, setFoo] = useState(S); + return ; + }; + } - // @hot reset + export default hoc(({ value }) => { + return

B{value}

; + }); + `); + // Same state variable name, so state is preserved. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B1'); - 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 = 3; + + function hoc(Wrapped) { + return function Generated() { + const [bar, setBar] = useState(S); + return ; + }; + } - patch(` - const {useState} = React; - const S = 7; + export default hoc(({ value }) => { + return

C{value}

; + }); + `); + // Different state variable name, so state is reset. + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('C3'); + } + }); - export default function App() { + it('resets state when renaming a state variable in a HOC with indirection', () => { + if (__DEV__) { + render(` + const {useState} = React; + const S = 1; + + function hoc(Wrapped) { + return function Generated() { + const [foo, setFoo] = useState(S); + return ; + }; + } - /* @hot reset */ + function Indirection({ value }) { + return

A{value}

; + } - 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'); - } - }); + export default hoc(Indirection); + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); - // 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; + patch(` + const {useState} = React; + const S = 2; + + function hoc(Wrapped) { + return function Generated() { + const [foo, setFoo] = useState(S); + return ; + }; + } - export default function App() { - const [foo, setFoo] = useState(1); - return

A{foo}

; - } - `); - let el = container.firstChild; - expect(el.textContent).toBe('A1'); + function Indirection({ value }) { + return

B{value}

; + } - patch(` - const {useState} = React; + export default hoc(Indirection); + `); + // Same state variable name, so state is preserved. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B1'); - export default function App() { - const [foo, setFoo] = useState(1); - return

B{foo}

; - } - `); - // Same initial state, so it's preserved. - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('B1'); + patch(` + const {useState} = React; + const S = 3; + + function hoc(Wrapped) { + return function Generated() { + const [bar, setBar] = useState(S); + return ; + }; + } - patch(` - const {useState} = React; + function Indirection({ value }) { + return

C{value}

; + } - export default function App() { - const [foo, setFoo] = useState(2); - return

C{foo}

; - } - `); - // Different initial state, so state is reset. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; - expect(el.textContent).toBe('C2'); - } - }); + export default hoc(Indirection); + `); + // Different state variable name, so state is reset. + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('C3'); + } + }); - // 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; + it('resets effects while preserving state', () => { + if (__DEV__) { + render(` + const {useState} = React; - export default function App() { - const [foo, setFoo] = useReducer(x => x, 1); - return

A{foo}

; - } - `); - let el = container.firstChild; - expect(el.textContent).toBe('A1'); + export default function App() { + const [value, setValue] = useState(0); + return

A{value}

; + } + `); + let el = container.firstChild; + expect(el.textContent).toBe('A0'); - patch(` - const {useReducer} = React; + // Add an effect. + patch(` + const {useState} = React; + + export default function App() { + const [value, setValue] = useState(0); + React.useEffect(() => { + const id = setInterval(() => { + setValue(v => v + 1); + }, 1000); + return () => clearInterval(id); + }, []); + return

B{value}

; + } + `); + // We added an effect, thereby changing Hook order. + // This causes a remount. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('B0'); - 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'); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(el.textContent).toBe('B1'); - patch(` - const {useReducer} = React; + patch(` + const {useState} = React; + + export default function App() { + const [value, setValue] = useState(0); + React.useEffect(() => { + const id = setInterval(() => { + setValue(v => v + 10); + }, 1000); + return () => clearInterval(id); + }, []); + return

C{value}

; + } + `); + // Same Hooks are called, so state is preserved. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('C1'); - 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('C2'); - } - }); + // Effects are always reset, so timer was reinstalled. + // The new version increments by 10 rather than 1. + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(el.textContent).toBe('C11'); - describe('with inline requires', () => { - beforeEach(() => { - global.FakeModuleSystem = {}; - }); + patch(` + const {useState} = React; - afterEach(() => { - delete global.FakeModuleSystem; + export default function App() { + const [value, setValue] = useState(0); + return

D{value}

; + } + `); + // Removing the effect changes the signature + // and causes the component to remount. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('D0'); + } }); - it('remounts component if custom hook it uses changes order on first edit', () => { - // This test verifies that remounting works even if calls to custom Hooks - // were transformed with an inline requires transform, like we have on RN. - // Inline requires make it harder to compare previous and next signatures - // because useFancyState inline require always resolves to the newest version. - // We're not actually using inline requires in the test, but it has similar semantics. + it('does not get confused when custom hooks are reordered', () => { if (__DEV__) { render(` - const FakeModuleSystem = global.FakeModuleSystem; - - FakeModuleSystem.useFancyState = function(initialState) { + function useFancyState(initialState) { return React.useState(initialState); - }; + } - const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); + const App = () => { + const [x, setX] = useFancyState('X'); + const [y, setY] = useFancyState('Y'); return

A{x}{y}

; }; @@ -989,124 +561,174 @@ describe('ReactFreshIntegration', () => { expect(el.textContent).toBe('AXY'); patch(` - const FakeModuleSystem = global.FakeModuleSystem; - - FakeModuleSystem.useFancyState = function(initialState) { - React.useEffect(() => {}); + function useFancyState(initialState) { return React.useState(initialState); - }; + } const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); + const [x, setX] = useFancyState('X'); + const [y, setY] = useFancyState('Y'); return

B{x}{y}

; }; export default App; `); - // The useFancyState Hook added an effect, - // so we had to remount the component. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; + // Same state variables, so no remount. + expect(container.firstChild).toBe(el); expect(el.textContent).toBe('BXY'); patch(` - const FakeModuleSystem = global.FakeModuleSystem; - - FakeModuleSystem.useFancyState = function(initialState) { - React.useEffect(() => {}); + function useFancyState(initialState) { return React.useState(initialState); - }; + } const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); - return

C{x}{y}

; + const [y, setY] = useFancyState('Y'); + const [x, setX] = useFancyState('X'); + return

B{x}{y}

; }; export default App; `); - // We didn't change anything except the header text. - // So we don't expect a remount. - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('CXY'); + // Hooks were re-ordered. This causes a remount. + // Therefore, Hook calls don't accidentally share state. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('BXY'); } }); - it('remounts component if custom hook it uses changes order on second edit', () => { + it('does not get confused by Hooks defined inline', () => { + // This is not a recommended pattern but at least it shouldn't break. if (__DEV__) { render(` - const FakeModuleSystem = global.FakeModuleSystem; + const App = () => { + const useFancyState = (initialState) => { + const result = React.useState(initialState); + return result; + }; + const [x, setX] = useFancyState('X1'); + const [y, setY] = useFancyState('Y1'); + return

A{x}{y}

; + }; - FakeModuleSystem.useFancyState = function(initialState) { - return React.useState(initialState); + export default App; + `); + let el = container.firstChild; + expect(el.textContent).toBe('AX1Y1'); + + patch(` + const App = () => { + const useFancyState = (initialState) => { + const result = React.useState(initialState); + return result; + }; + const [x, setX] = useFancyState('X2'); + const [y, setY] = useFancyState('Y2'); + return

B{x}{y}

; }; + export default App; + `); + // Remount even though nothing changed because + // the custom Hook is inside -- and so we don't + // really know whether its signature has changed. + // We could potentially make it work, but for now + // let's assert we don't crash with confusing errors. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('BX2Y2'); + } + }); + + it('remounts component if custom hook it uses changes order', () => { + if (__DEV__) { + render(` const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); + const [x, setX] = useFancyState('X'); + const [y, setY] = useFancyState('Y'); return

A{x}{y}

; }; + const useFancyState = (initialState) => { + const result = useIndirection(initialState); + return result; + }; + + function useIndirection(initialState) { + return React.useState(initialState); + } + export default App; `); let el = container.firstChild; expect(el.textContent).toBe('AXY'); patch(` - const FakeModuleSystem = global.FakeModuleSystem; - - FakeModuleSystem.useFancyState = function(initialState) { - return React.useState(initialState); - }; - const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); + const [x, setX] = useFancyState('X'); + const [y, setY] = useFancyState('Y'); return

B{x}{y}

; }; + const useFancyState = (initialState) => { + const result = useIndirection(); + return result; + }; + + function useIndirection(initialState) { + return React.useState(initialState); + } + export default App; `); + // We didn't change anything except the header text. + // So we don't expect a remount. expect(container.firstChild).toBe(el); expect(el.textContent).toBe('BXY'); patch(` - const FakeModuleSystem = global.FakeModuleSystem; - - FakeModuleSystem.useFancyState = function(initialState) { - React.useEffect(() => {}); - return React.useState(initialState); - }; - const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); + const [x, setX] = useFancyState('X'); + const [y, setY] = useFancyState('Y'); return

C{x}{y}

; }; + const useFancyState = (initialState) => { + const result = useIndirection(initialState); + return result; + }; + + function useIndirection(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + } + export default App; `); - // The useFancyState Hook added an effect, + // The useIndirection Hook added an affect, // so we had to remount the component. expect(container.firstChild).not.toBe(el); el = container.firstChild; expect(el.textContent).toBe('CXY'); patch(` - const FakeModuleSystem = global.FakeModuleSystem; - - FakeModuleSystem.useFancyState = function(initialState) { - React.useEffect(() => {}); - return React.useState(initialState); - }; - const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); + const [x, setX] = useFancyState('X'); + const [y, setY] = useFancyState('Y'); return

D{x}{y}

; }; + const useFancyState = (initialState) => { + const result = useIndirection(); + return result; + }; + + function useIndirection(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + } + export default App; `); // We didn't change anything except the header text. @@ -1116,140 +738,537 @@ describe('ReactFreshIntegration', () => { } }); - it('recovers if evaluating Hook list throws', () => { + it('does not lose the inferred arrow names', () => { + if (__DEV__) { + render(` + const Parent = () => { + return ; + }; + + const Child = () => { + useMyThing(); + return

{Parent.name} {Child.name} {useMyThing.name}

; + }; + + const useMyThing = () => { + React.useState(); + }; + + export default Parent; + `); + expect(container.textContent).toBe('Parent Child useMyThing'); + } + }); + + it('does not lose the inferred function names', () => { if (__DEV__) { render(` - let FakeModuleSystem = null; + var Parent = function() { + return ; + }; + + var Child = function() { + useMyThing(); + return

{Parent.name} {Child.name} {useMyThing.name}

; + }; + + var useMyThing = function() { + React.useState(); + }; - global.FakeModuleSystem.useFancyState = function(initialState) { - return React.useState(initialState); - }; + export default Parent; + `); + expect(container.textContent).toBe('Parent Child useMyThing'); + } + }); - const App = () => { - FakeModuleSystem = global.FakeModuleSystem; - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); - return

A{x}{y}

; - }; + it('resets state on every edit with @hot reset annotation', () => { + if (__DEV__) { + render(` + const {useState} = React; + const S = 1; - export default App; - `); + export default function App() { + const [foo, setFoo] = useState(S); + return

A{foo}

; + } + `); let el = container.firstChild; - expect(el.textContent).toBe('AXY'); + expect(el.textContent).toBe('A1'); patch(` - let FakeModuleSystem = null; - - global.FakeModuleSystem.useFancyState = function(initialState) { - React.useEffect(() => {}); - return React.useState(initialState); - }; - - const App = () => { - FakeModuleSystem = global.FakeModuleSystem; - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); - return

B{x}{y}

; - }; - - export default App; - `); - // We couldn't evaluate the Hook signatures - // so we had to remount the component. + 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('BXY'); + 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'); } }); - it('remounts component if custom hook it uses changes order behind an indirection', () => { + // 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 FakeModuleSystem = global.FakeModuleSystem; + const {useState} = React; - FakeModuleSystem.useFancyState = function(initialState) { - return FakeModuleSystem.useIndirection(initialState); - }; + export default function App() { + const [foo, setFoo] = useState(1); + return

A{foo}

; + } + `); + let el = container.firstChild; + expect(el.textContent).toBe('A1'); - FakeModuleSystem.useIndirection = function(initialState) { - return FakeModuleSystem.useOtherIndirection(initialState); - }; + patch(` + const {useState} = React; - FakeModuleSystem.useOtherIndirection = function(initialState) { - return React.useState(initialState); - }; + export default function App() { + const [foo, setFoo] = useState(1); + return

B{foo}

; + } + `); + // Same initial state, so it's preserved. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B1'); - const App = () => { - const [x, setX] = FakeModuleSystem.useFancyState('X'); - const [y, setY] = FakeModuleSystem.useFancyState('Y'); - return

A{x}{y}

; - }; + patch(` + const {useState} = React; - export default App; + export default function App() { + const [foo, setFoo] = useState(2); + return

C{foo}

; + } + `); + // Different initial state, so state is reset. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('C2'); + } + }); + + // 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() { + const [foo, setFoo] = useReducer(x => x, 1); + return

A{foo}

; + } `); let el = container.firstChild; - expect(el.textContent).toBe('AXY'); + expect(el.textContent).toBe('A1'); patch(` - const FakeModuleSystem = global.FakeModuleSystem; + const {useReducer} = React; - FakeModuleSystem.useFancyState = function(initialState) { - return FakeModuleSystem.useIndirection(initialState); - }; + 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'); - FakeModuleSystem.useIndirection = function(initialState) { - return FakeModuleSystem.useOtherIndirection(initialState); - }; + patch(` + const {useReducer} = React; - FakeModuleSystem.useOtherIndirection = function(initialState) { - React.useEffect(() => {}); + 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('C2'); + } + }); + + describe('with inline requires', () => { + beforeEach(() => { + global.FakeModuleSystem = {}; + }); + + afterEach(() => { + delete global.FakeModuleSystem; + }); + + it('remounts component if custom hook it uses changes order on first edit', () => { + // This test verifies that remounting works even if calls to custom Hooks + // were transformed with an inline requires transform, like we have on RN. + // Inline requires make it harder to compare previous and next signatures + // because useFancyState inline require always resolves to the newest version. + // We're not actually using inline requires in the test, but it has similar semantics. + if (__DEV__) { + render(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

A{x}{y}

; + }; + + export default App; + `); + let el = container.firstChild; + expect(el.textContent).toBe('AXY'); + + patch(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

B{x}{y}

; + }; + + export default App; + `); + // The useFancyState Hook added an effect, + // so we had to remount the component. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('BXY'); + + patch(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

C{x}{y}

; + }; + + export default App; + `); + // We didn't change anything except the header text. + // So we don't expect a remount. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('CXY'); + } + }); + + it('remounts component if custom hook it uses changes order on second edit', () => { + if (__DEV__) { + render(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

A{x}{y}

; + }; + + export default App; + `); + let el = container.firstChild; + expect(el.textContent).toBe('AXY'); + + patch(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

B{x}{y}

; + }; + + export default App; + `); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('BXY'); + + patch(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

C{x}{y}

; + }; + + export default App; + `); + // The useFancyState Hook added an effect, + // so we had to remount the component. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('CXY'); + + patch(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

D{x}{y}

; + }; + + export default App; + `); + // We didn't change anything except the header text. + // So we don't expect a remount. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('DXY'); + } + }); + + it('recovers if evaluating Hook list throws', () => { + if (__DEV__) { + render(` + let FakeModuleSystem = null; + + global.FakeModuleSystem.useFancyState = function(initialState) { return React.useState(initialState); }; const App = () => { + FakeModuleSystem = global.FakeModuleSystem; const [x, setX] = FakeModuleSystem.useFancyState('X'); const [y, setY] = FakeModuleSystem.useFancyState('Y'); - return

B{x}{y}

; + return

A{x}{y}

; }; export default App; `); + let el = container.firstChild; + expect(el.textContent).toBe('AXY'); - // The useFancyState Hook added an effect, - // so we had to remount the component. - expect(container.firstChild).not.toBe(el); - el = container.firstChild; - expect(el.textContent).toBe('BXY'); - - patch(` - const FakeModuleSystem = global.FakeModuleSystem; - - FakeModuleSystem.useFancyState = function(initialState) { - return FakeModuleSystem.useIndirection(initialState); - }; - - FakeModuleSystem.useIndirection = function(initialState) { - return FakeModuleSystem.useOtherIndirection(initialState); - }; + patch(` + let FakeModuleSystem = null; - FakeModuleSystem.useOtherIndirection = function(initialState) { + global.FakeModuleSystem.useFancyState = function(initialState) { React.useEffect(() => {}); return React.useState(initialState); }; const App = () => { + FakeModuleSystem = global.FakeModuleSystem; const [x, setX] = FakeModuleSystem.useFancyState('X'); const [y, setY] = FakeModuleSystem.useFancyState('Y'); - return

C{x}{y}

; + return

B{x}{y}

; }; export default App; `); - // We didn't change anything except the header text. - // So we don't expect a remount. - expect(container.firstChild).toBe(el); - expect(el.textContent).toBe('CXY'); - } + // We couldn't evaluate the Hook signatures + // so we had to remount the component. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('BXY'); + } + }); + + it('remounts component if custom hook it uses changes order behind an indirection', () => { + if (__DEV__) { + render(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + return FakeModuleSystem.useIndirection(initialState); + }; + + FakeModuleSystem.useIndirection = function(initialState) { + return FakeModuleSystem.useOtherIndirection(initialState); + }; + + FakeModuleSystem.useOtherIndirection = function(initialState) { + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

A{x}{y}

; + }; + + export default App; + `); + let el = container.firstChild; + expect(el.textContent).toBe('AXY'); + + patch(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + return FakeModuleSystem.useIndirection(initialState); + }; + + FakeModuleSystem.useIndirection = function(initialState) { + return FakeModuleSystem.useOtherIndirection(initialState); + }; + + FakeModuleSystem.useOtherIndirection = function(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

B{x}{y}

; + }; + + export default App; + `); + + // The useFancyState Hook added an effect, + // so we had to remount the component. + expect(container.firstChild).not.toBe(el); + el = container.firstChild; + expect(el.textContent).toBe('BXY'); + + patch(` + const FakeModuleSystem = global.FakeModuleSystem; + + FakeModuleSystem.useFancyState = function(initialState) { + return FakeModuleSystem.useIndirection(initialState); + }; + + FakeModuleSystem.useIndirection = function(initialState) { + return FakeModuleSystem.useOtherIndirection(initialState); + }; + + FakeModuleSystem.useOtherIndirection = function(initialState) { + React.useEffect(() => {}); + return React.useState(initialState); + }; + + const App = () => { + const [x, setX] = FakeModuleSystem.useFancyState('X'); + const [y, setY] = FakeModuleSystem.useFancyState('Y'); + return

C{x}{y}

; + }; + + export default App; + `); + // We didn't change anything except the header text. + // So we don't expect a remount. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('CXY'); + } + }); }); - }); + } });