diff --git a/packages/react-dom/src/__tests__/ReactFresh-test.internal.js b/packages/react-dom/src/__tests__/ReactFresh-test.internal.js new file mode 100644 index 0000000000..4aa0d30bfa --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactFresh-test.internal.js @@ -0,0 +1,2036 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +'use strict'; + +let React; +let ReactDOM; +let Scheduler; +let act; + +describe('ReactFresh', () => { + let container; + let familiesByID; + let familiesByType; + let newFamilies; + let updatedFamilies; + let performHotReload; + let signaturesByType; + + beforeEach(() => { + let scheduleHotUpdate; + let lastRoot; + global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + supportsFiber: true, + inject: injected => { + scheduleHotUpdate = injected.scheduleHotUpdate; + }, + onCommitFiberRoot: (id, root) => { + lastRoot = root; + }, + onCommitFiberUnmount: () => {}, + }; + + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + act = require('react-dom/test-utils').act; + container = document.createElement('div'); + document.body.appendChild(container); + + familiesByID = new Map(); + familiesByType = new WeakMap(); + + if (__DEV__) { + performHotReload = function(staleFamilies) { + scheduleHotUpdate({ + root: lastRoot, + familiesByType, + updatedFamilies, + staleFamilies, + }); + }; + } + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function prepare(version) { + newFamilies = new Set(); + updatedFamilies = new Set(); + signaturesByType = new Map(); + const Component = version(); + + // Fill in the signatures. + for (let family of newFamilies) { + const latestSignature = signaturesByType.get(family.currentType) || null; + family.currentSignature = latestSignature; + } + + newFamilies = null; + updatedFamilies = null; + signaturesByType = null; + + return Component; + } + + function render(version, props) { + const Component = prepare(version); + act(() => { + ReactDOM.render(, container); + }); + return Component; + } + + function patch(version) { + // Will be filled in by __register__ calls in user code. + newFamilies = new Set(); + updatedFamilies = new Set(); + signaturesByType = new Map(); + const Component = version(); + + // Fill in the signatures. + for (let family of newFamilies) { + const latestSignature = signaturesByType.get(family.currentType) || null; + family.currentSignature = latestSignature; + } + // Now that all registration and signatures are collected, + // find which registrations changed their signatures since last time. + const staleFamilies = new Set(); + for (let family of updatedFamilies) { + const latestSignature = signaturesByType.get(family.currentType) || null; + if (family.currentSignature !== latestSignature) { + family.currentSignature = latestSignature; + staleFamilies.add(family); + } + } + + performHotReload(staleFamilies); + newFamilies = null; + updatedFamilies = null; + signaturesByType = null; + return Component; + } + + function __register__(type, id) { + if (familiesByType.has(type)) { + return; + } + let family = familiesByID.get(id); + let isNew = false; + if (family === undefined) { + isNew = true; + family = {currentType: type, currentSignature: null}; + familiesByID.set(id, family); + } + const prevType = family.currentType; + if (isNew) { + // The first time a type is registered, we don't need + // any special reconciliation logic. So we won't add it to the map. + // Instead, this will happen the firt time it is edited. + newFamilies.add(family); + } else { + family.currentType = type; + // Point both previous and next types to this family. + familiesByType.set(prevType, family); + familiesByType.set(type, family); + updatedFamilies.add(family); + } + + if (typeof type === 'object' && type !== null) { + switch (type.$$typeof) { + case Symbol.for('react.forward_ref'): + __register__(type.render, id + '$render'); + break; + case Symbol.for('react.memo'): + __register__(type.type, id + '$type'); + break; + } + } + } + + function __signature__(type, signature) { + signaturesByType.set(type, signature); + } + + it('can preserve state for compatible types', () => { + if (__DEV__) { + let HelloV1 = render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + return Hello; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update. + let HelloV2 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + return Hello; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Bump the state again. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Perform top-down renders with both fresh and stale types. + // Neither should change the state or color. + // They should always resolve to the latest version. + render(() => HelloV1); + render(() => HelloV2); + render(() => HelloV1); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Bump the state again. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('3'); + expect(el.style.color).toBe('red'); + + // Finally, a render with incompatible type should reset it. + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + // No register call. + // This is considered a new type. + return Hello; + }); + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('0'); + expect(newEl.style.color).toBe('blue'); + } + }); + + it('can preserve state for forwardRef', () => { + if (__DEV__) { + let OuterV1 = render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.forwardRef(() => ); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update. + let OuterV2 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.forwardRef(() => ); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Bump the state again. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Perform top-down renders with both fresh and stale types. + // Neither should change the state or color. + // They should always resolve to the latest version. + render(() => OuterV1); + render(() => OuterV2); + render(() => OuterV1); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Finally, a render with incompatible type should reset it. + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + // Note: no forwardRef wrapper this time. + return Hello; + }); + + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('0'); + expect(newEl.style.color).toBe('blue'); + } + }); + + it('should not consider two forwardRefs around the same type to be equivalent', () => { + if (__DEV__) { + let ParentV1 = render( + () => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + function renderInner() { + return ; + } + // Both of these are wrappers around the same inner function. + // They should be treated as distinct types across reloads. + let ForwardRefA = React.forwardRef(renderInner); + __register__(ForwardRefA, 'ForwardRefA'); + let ForwardRefB = React.forwardRef(renderInner); + __register__(ForwardRefB, 'ForwardRefB'); + + function Parent({cond}) { + return cond ? : ; + } + __register__(Parent, 'Parent'); + + return Parent; + }, + {cond: true}, + ); + + // Bump the state before switching up types. + let el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Switching up the inner types should reset the state. + render(() => ParentV1, {cond: false}); + expect(el).not.toBe(container.firstChild); + el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Switch them up back again. + render(() => ParentV1, {cond: true}); + expect(el).not.toBe(container.firstChild); + el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + + // Now bump up the state to prepare for patching. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Patch to change the color. + let ParentV2 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + function renderInner() { + return ; + } + // Both of these are wrappers around the same inner function. + // They should be treated as distinct types across reloads. + let ForwardRefA = React.forwardRef(renderInner); + __register__(ForwardRefA, 'ForwardRefA'); + let ForwardRefB = React.forwardRef(renderInner); + __register__(ForwardRefB, 'ForwardRefB'); + + function Parent({cond}) { + return cond ? : ; + } + __register__(Parent, 'Parent'); + + return Parent; + }); + + // The state should be intact; the color should change. + expect(el).toBe(container.firstChild); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Switching up the condition should still reset the state. + render(() => ParentV2, {cond: false}); + expect(el).not.toBe(container.firstChild); + el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('red'); + + // Now bump up the state to prepare for top-level renders. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el).toBe(container.firstChild); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Finally, verify using top-level render with stale type keeps state. + render(() => ParentV1); + render(() => ParentV2); + render(() => ParentV1); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + } + }); + + it('can update forwardRef render function with its wrapper', () => { + if (__DEV__) { + render(() => { + function Hello({color}) { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.forwardRef(() => ); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update. + patch(() => { + function Hello({color}) { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.forwardRef(() => ); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + } + }); + + it('can update forwardRef render function in isolation', () => { + if (__DEV__) { + render(() => { + function Hello({color}) { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + function renderHello() { + return ; + } + __register__(renderHello, 'renderHello'); + + return React.forwardRef(renderHello); + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update of just the rendering function. + patch(() => { + function Hello({color}) { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + function renderHello() { + return ; + } + __register__(renderHello, 'renderHello'); + + // Not updating the wrapper. + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + } + }); + + it('can preserve state for simple memo', () => { + if (__DEV__) { + let OuterV1 = render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.memo(Hello); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update. + let OuterV2 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.memo(Hello); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Bump the state again. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Perform top-down renders with both fresh and stale types. + // Neither should change the state or color. + // They should always resolve to the latest version. + render(() => OuterV1); + render(() => OuterV2); + render(() => OuterV1); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Finally, a render with incompatible type should reset it. + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + // Note: no wrapper this time. + return Hello; + }); + + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('0'); + expect(newEl.style.color).toBe('blue'); + } + }); + + it('can preserve state for memo with custom comparison', () => { + if (__DEV__) { + let OuterV1 = render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + + const Outer = React.memo(Hello, () => true); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update. + let OuterV2 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + + const Outer = React.memo(Hello, () => true); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Bump the state again. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Perform top-down renders with both fresh and stale types. + // Neither should change the state or color. + // They should always resolve to the latest version. + render(() => OuterV1); + render(() => OuterV2); + render(() => OuterV1); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Finally, a render with incompatible type should reset it. + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + // Note: no wrapper this time. + return Hello; + }); + + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('0'); + expect(newEl.style.color).toBe('blue'); + } + }); + + it('can update simple memo function in isolation', () => { + if (__DEV__) { + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + return React.memo(Hello); + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update of just the rendering function. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + // Not updating the wrapper. + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + } + }); + + it('can preserve state for memo(forwardRef)', () => { + if (__DEV__) { + let OuterV1 = render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.memo(React.forwardRef(() => )); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update. + let OuterV2 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + const Outer = React.memo(React.forwardRef(() => )); + __register__(Outer, 'Outer'); + return Outer; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Bump the state again. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Perform top-down renders with both fresh and stale types. + // Neither should change the state or color. + // They should always resolve to the latest version. + render(() => OuterV1); + render(() => OuterV2); + render(() => OuterV1); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('2'); + expect(el.style.color).toBe('red'); + + // Finally, a render with incompatible type should reset it. + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + // Note: no wrapper this time. + return Hello; + }); + + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('0'); + expect(newEl.style.color).toBe('blue'); + } + }); + + it('does not re-render ancestor components unnecessarily during a hot update', () => { + if (__DEV__) { + let appRenders = 0; + + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + function App() { + appRenders++; + return ; + } + __register__(App, 'App'); + return App; + }); + + expect(appRenders).toBe(1); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // No re-renders from the top. + expect(appRenders).toBe(1); + + // Perform a hot update for Hello only. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Still no re-renders from the top. + expect(appRenders).toBe(1); + + // Bump the state. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('2'); + + // Still no re-renders from the top. + expect(appRenders).toBe(1); + } + }); + + it('can force remount by changing signature', () => { + if (__DEV__) { + let HelloV1 = render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + // When this changes, we'll expect a remount: + __signature__(Hello, '1'); + return Hello; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update. + let HelloV2 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + // The signature hasn't changed since the last time: + __signature__(Hello, '1'); + return Hello; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Perform a hot update. + let HelloV3 = patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + // We're changing the signature now so it will remount: + __register__(Hello, 'Hello'); + __signature__(Hello, '2'); + return Hello; + }); + + // Expect a remount. + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('0'); + expect(newEl.style.color).toBe('yellow'); + + // Bump state again. + act(() => { + newEl.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(newEl.textContent).toBe('1'); + expect(newEl.style.color).toBe('yellow'); + + // Perform top-down renders with both fresh and stale types. + // Neither should change the state or color. + // They should always resolve to the latest version. + render(() => HelloV1); + render(() => HelloV2); + render(() => HelloV3); + render(() => HelloV2); + render(() => HelloV1); + expect(container.firstChild).toBe(newEl); + expect(newEl.textContent).toBe('1'); + expect(newEl.style.color).toBe('yellow'); + + // Verify we can patch again while preserving the signature. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + // Same signature as last time. + __register__(Hello, 'Hello'); + __signature__(Hello, '2'); + return Hello; + }); + + expect(container.firstChild).toBe(newEl); + expect(newEl.textContent).toBe('1'); + expect(newEl.style.color).toBe('purple'); + + // Check removing the signature also causes a remount. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + // No signature this time. + __register__(Hello, 'Hello'); + return Hello; + }); + + // Expect a remount. + expect(container.firstChild).not.toBe(newEl); + const finalEl = container.firstChild; + expect(finalEl.textContent).toBe('0'); + expect(finalEl.style.color).toBe('orange'); + } + }); + + it('keeps a valid tree when forcing remount', () => { + if (__DEV__) { + let HelloV1 = prepare(() => { + function Hello() { + return null; + } + __register__(Hello, 'Hello'); + __signature__(Hello, '1'); + return Hello; + }); + + const Bailout = React.memo(({children}) => { + return children; + }); + + // Each of those renders three instances of HelloV1, + // but in different ways. + let trees = [ +
+ +
+ + + + +
+
, +
+
+ + + + +
+
, +
+ + + + +
, +
+ + + + +
, +
+
foo
+ +
+ +
+ + +
, +
+ + + Hello + + , + + + + + , +
, + + + + + + + + + + + , +
+ + + + + +
, +
+ + {null} + + {null} + + + +
, +
+ + + + + +
, +
+ {[[]]} + + + {[null]} + + +
, +
+ {['foo', , null, ]} + + {[null]} + + +
, + + + + + hi + + + + , + ]; + + // First, check that each tree handles remounts in isolation. + ReactDOM.render(null, container); + for (let i = 0; i < trees.length; i++) { + runRemountingStressTest(trees[i]); + } + + // Then check that each tree is resilient to updates from another tree. + for (let i = 0; i < trees.length; i++) { + for (let j = 0; j < trees.length; j++) { + ReactDOM.render(null, container); + // Intentionally don't clean up between the tests: + runRemountingStressTest(trees[i]); + runRemountingStressTest(trees[j]); + runRemountingStressTest(trees[i]); + } + } + } + }); + + function runRemountingStressTest(tree) { + patch(() => { + function Hello({children}) { + return
{children}
; + } + __register__(Hello, 'Hello'); + __signature__(Hello, '1'); + return Hello; + }); + + ReactDOM.render(tree, container); + const elements = container.querySelectorAll('section'); + // Each tree above products exactly three
elements: + expect(elements.length).toBe(3); + elements.forEach(el => { + expect(el.dataset.color).toBe('blue'); + }); + + // Patch color without changing the signature. + patch(() => { + function Hello({children}) { + return
{children}
; + } + __register__(Hello, 'Hello'); + __signature__(Hello, '1'); + return Hello; + }); + + const elementsAfterPatch = container.querySelectorAll('section'); + expect(elementsAfterPatch.length).toBe(3); + elementsAfterPatch.forEach((el, index) => { + // The signature hasn't changed so we expect DOM nodes to stay the same. + expect(el).toBe(elements[index]); + // However, the color should have changed: + expect(el.dataset.color).toBe('red'); + }); + + // Patch color *and* change the signature. + patch(() => { + function Hello({children}) { + return
{children}
; + } + __register__(Hello, 'Hello'); + __signature__(Hello, '2'); // Remount + return Hello; + }); + + const elementsAfterRemount = container.querySelectorAll('section'); + expect(elementsAfterRemount.length).toBe(3); + elementsAfterRemount.forEach((el, index) => { + // The signature changed so we expect DOM nodes to be different. + expect(el).not.toBe(elements[index]); + // They should all be using the new color: + expect(el.dataset.color).toBe('orange'); + }); + + // Now patch color but *don't* change the signature. + patch(() => { + function Hello({children}) { + return
{children}
; + } + __register__(Hello, 'Hello'); + __signature__(Hello, '2'); // Same signature as before + return Hello; + }); + + expect(container.querySelectorAll('section').length).toBe(3); + container.querySelectorAll('section').forEach((el, index) => { + // The signature didn't change so DOM nodes should stay the same. + expect(el).toBe(elementsAfterRemount[index]); + // They should all be using the new color: + expect(el.dataset.color).toBe('black'); + }); + + // Do another render just in case. + ReactDOM.render(tree, container); + expect(container.querySelectorAll('section').length).toBe(3); + container.querySelectorAll('section').forEach((el, index) => { + expect(el).toBe(elementsAfterRemount[index]); + expect(el.dataset.color).toBe('black'); + }); + } + + it('can remount on signature change within a wrapper', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => Hello); + } + }); + + it('can remount on signature change within a simple memo wrapper', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => React.memo(Hello)); + } + }); + + it('can remount on signature change within forwardRef', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => React.forwardRef(Hello)); + } + }); + + it('can remount on signature change within forwardRef render function', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => React.forwardRef(() => )); + } + }); + + it('can remount on signature change within nested memo', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => + React.memo(React.memo(React.memo(Hello))), + ); + } + }); + + it('can remount on signature change within a memo wrapper and custom comparison', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => React.memo(Hello, () => true)); + } + }); + + it('can remount on signature change within a class', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + const child = ; + return class Wrapper extends React.PureComponent { + render() { + return child; + } + }; + }); + } + }); + + it('can remount on signature change within a context provider', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + const Context = React.createContext(); + const child = ( + + + + ); + return function Wrapper() { + return child; + }; + }); + } + }); + + it('can remount on signature change within a context consumer', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + const Context = React.createContext(); + const child = {() => }; + return function Wrapper() { + return child; + }; + }); + } + }); + + it('can remount on signature change within a suspense node', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + // TODO: we'll probably want to test fallback trees too. + const child = ( + + + + ); + return function Wrapper() { + return child; + }; + }); + } + }); + + it('can remount on signature change within a mode node', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + const child = ( + + + + ); + return function Wrapper() { + return child; + }; + }); + } + }); + + it('can remount on signature change within a fragment node', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + const child = ( + + + + ); + return function Wrapper() { + return child; + }; + }); + } + }); + + it('can remount on signature change within multiple siblings', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + const child = ( + + + + + + + + ); + return function Wrapper() { + return child; + }; + }); + } + }); + + it('can remount on signature change within a profiler node', () => { + if (__DEV__) { + testRemountingWithWrapper(Hello => { + const child = ; + return function Wrapper() { + return ( + {}} id="foo"> + {child} + + ); + }; + }); + } + }); + + function testRemountingWithWrapper(wrap) { + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + // When this changes, we'll expect a remount: + __signature__(Hello, '1'); + + // Use the passed wrapper. + // This will be different in every test. + return wrap(Hello); + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('1'); + + // Perform a hot update that doesn't remount. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + // The signature hasn't changed since the last time: + __signature__(Hello, '1'); + return Hello; + }); + + // Assert the state was preserved but color changed. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('1'); + expect(el.style.color).toBe('red'); + + // Perform a hot update that remounts. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + // We're changing the signature now so it will remount: + __register__(Hello, 'Hello'); + __signature__(Hello, '2'); + return Hello; + }); + + // Expect a remount. + expect(container.firstChild).not.toBe(el); + const newEl = container.firstChild; + expect(newEl.textContent).toBe('0'); + expect(newEl.style.color).toBe('yellow'); + + // Bump state again. + act(() => { + newEl.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(newEl.textContent).toBe('1'); + expect(newEl.style.color).toBe('yellow'); + + // Verify we can patch again while preserving the signature. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + // Same signature as last time. + __register__(Hello, 'Hello'); + __signature__(Hello, '2'); + return Hello; + }); + + expect(container.firstChild).toBe(newEl); + expect(newEl.textContent).toBe('1'); + expect(newEl.style.color).toBe('purple'); + + // Check removing the signature also causes a remount. + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + // No signature this time. + __register__(Hello, 'Hello'); + return Hello; + }); + + // Expect a remount. + expect(container.firstChild).not.toBe(newEl); + const finalEl = container.firstChild; + expect(finalEl.textContent).toBe('0'); + expect(finalEl.style.color).toBe('orange'); + } + + it('resets hooks with dependencies on hot reload', () => { + if (__DEV__) { + let useEffectWithEmptyArrayCalls = 0; + + render(() => { + function Hello() { + const [val, setVal] = React.useState(0); + const tranformed = React.useMemo(() => val * 2, [val]); + const handleClick = React.useCallback(() => setVal(v => v + 1), []); + + React.useEffect(() => { + useEffectWithEmptyArrayCalls++; + }, []); + + return ( +

+ {tranformed} +

+ ); + } + __register__(Hello, 'Hello'); + return Hello; + }); + + // Bump the state before patching. + const el = container.firstChild; + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('blue'); + expect(useEffectWithEmptyArrayCalls).toBe(1); // useEffect ran + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('2'); // val * 2 + expect(useEffectWithEmptyArrayCalls).toBe(1); // useEffect didn't re-run + + // Perform a hot update. + act(() => { + patch(() => { + function Hello() { + const [val, setVal] = React.useState(0); + const tranformed = React.useMemo(() => val * 10, [val]); + const handleClick = React.useCallback(() => setVal(v => v - 1), []); + + React.useEffect(() => { + useEffectWithEmptyArrayCalls++; + }, []); + + return ( +

+ {tranformed} +

+ ); + } + __register__(Hello, 'Hello'); + return Hello; + }); + }); + + // Assert the state was preserved but memo was evicted. + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('10'); // val * 10 + expect(el.style.color).toBe('red'); + expect(useEffectWithEmptyArrayCalls).toBe(2); // useEffect re-ran + + // This should fire the new callback which decreases the counter. + act(() => { + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + expect(el.textContent).toBe('0'); + expect(el.style.color).toBe('red'); + expect(useEffectWithEmptyArrayCalls).toBe(2); // useEffect didn't re-run + } + }); + + it('can hot reload offscreen components', () => { + if (__DEV__) { + const AppV1 = prepare(() => { + function Hello() { + React.useLayoutEffect(() => { + Scheduler.yieldValue('Hello#layout'); + }); + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + + return function App({offscreen}) { + React.useLayoutEffect(() => { + Scheduler.yieldValue('App#layout'); + }); + return ( + + ); + }; + }); + + const root = ReactDOM.unstable_createRoot(container); + root.render(); + expect(Scheduler).toFlushAndYieldThrough(['App#layout']); + const el = container.firstChild; + expect(el.hidden).toBe(true); + expect(el.firstChild).toBe(null); // Offscreen content not flushed yet. + + // Perform a hot update. + patch(() => { + function Hello() { + React.useLayoutEffect(() => { + Scheduler.yieldValue('Hello#layout'); + }); + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + }); + + // It's still offscreen so we don't see anything. + expect(container.firstChild).toBe(el); + expect(el.hidden).toBe(true); + expect(el.firstChild).toBe(null); + + // Process the offscreen updates. + expect(Scheduler).toFlushAndYieldThrough(['Hello#layout']); + expect(container.firstChild).toBe(el); + expect(el.firstChild.textContent).toBe('0'); + expect(el.firstChild.style.color).toBe('red'); + + el.firstChild.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(el.firstChild.textContent).toBe('0'); + expect(el.firstChild.style.color).toBe('red'); + expect(Scheduler).toFlushAndYieldThrough(['Hello#layout']); + expect(el.firstChild.textContent).toBe('1'); + expect(el.firstChild.style.color).toBe('red'); + + // Hot reload while we're offscreen. + patch(() => { + function Hello() { + React.useLayoutEffect(() => { + Scheduler.yieldValue('Hello#layout'); + }); + const [val, setVal] = React.useState(0); + return ( +

setVal(val + 1)}> + {val} +

+ ); + } + __register__(Hello, 'Hello'); + }); + + // It's still offscreen so we don't see the updates. + expect(container.firstChild).toBe(el); + expect(el.firstChild.textContent).toBe('1'); + expect(el.firstChild.style.color).toBe('red'); + + // Process the offscreen updates. + expect(Scheduler).toFlushAndYieldThrough(['Hello#layout']); + expect(container.firstChild).toBe(el); + expect(el.firstChild.textContent).toBe('1'); + expect(el.firstChild.style.color).toBe('orange'); + } + }); + + it('remounts failed error boundaries (componentDidCatch)', () => { + if (__DEV__) { + render(() => { + function Hello() { + return

Hi

; + } + __register__(Hello, 'Hello'); + + class Boundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + if (this.state.error) { + return

Oops: {this.state.error.message}

; + } + return this.props.children; + } + } + + function App() { + return ( + +

A

+ + + +

B

+
+ ); + } + + return App; + }); + + expect(container.innerHTML).toBe('

A

Hi

B

'); + const firstP = container.firstChild; + const secondP = firstP.nextSibling.nextSibling; + + // Perform a hot update that fails. + patch(() => { + function Hello() { + throw new Error('No'); + } + __register__(Hello, 'Hello'); + }); + + expect(container.innerHTML).toBe('

A

Oops: No

B

'); + expect(container.firstChild).toBe(firstP); + expect(container.firstChild.nextSibling.nextSibling).toBe(secondP); + + // Perform a hot update that fixes the error. + patch(() => { + function Hello() { + return

Fixed!

; + } + __register__(Hello, 'Hello'); + }); + + // This should remount the error boundary (but not anything above it). + expect(container.innerHTML).toBe('

A

Fixed!

B

'); + expect(container.firstChild).toBe(firstP); + expect(container.firstChild.nextSibling.nextSibling).toBe(secondP); + + // Verify next hot reload doesn't remount anything. + const helloNode = container.firstChild.nextSibling; + patch(() => { + function Hello() { + return

Nice.

; + } + __register__(Hello, 'Hello'); + }); + expect(container.firstChild.nextSibling).toBe(helloNode); + expect(helloNode.textContent).toBe('Nice.'); + } + }); + + it('remounts failed error boundaries (getDerivedStateFromError)', () => { + if (__DEV__) { + render(() => { + function Hello() { + return

Hi

; + } + __register__(Hello, 'Hello'); + + class Boundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return

Oops: {this.state.error.message}

; + } + return this.props.children; + } + } + + function App() { + return ( + +

A

+ + + +

B

+
+ ); + } + + return App; + }); + + expect(container.innerHTML).toBe('

A

Hi

B

'); + const firstP = container.firstChild; + const secondP = firstP.nextSibling.nextSibling; + + // Perform a hot update that fails. + patch(() => { + function Hello() { + throw new Error('No'); + } + __register__(Hello, 'Hello'); + }); + + expect(container.innerHTML).toBe('

A

Oops: No

B

'); + expect(container.firstChild).toBe(firstP); + expect(container.firstChild.nextSibling.nextSibling).toBe(secondP); + + // Perform a hot update that fixes the error. + patch(() => { + function Hello() { + return

Fixed!

; + } + __register__(Hello, 'Hello'); + }); + + // This should remount the error boundary (but not anything above it). + expect(container.innerHTML).toBe('

A

Fixed!

B

'); + expect(container.firstChild).toBe(firstP); + expect(container.firstChild.nextSibling.nextSibling).toBe(secondP); + + // Verify next hot reload doesn't remount anything. + const helloNode = container.firstChild.nextSibling; + patch(() => { + function Hello() { + return

Nice.

; + } + __register__(Hello, 'Hello'); + }); + expect(container.firstChild.nextSibling).toBe(helloNode); + expect(helloNode.textContent).toBe('Nice.'); + } + }); + + it('remounts error boundaries that failed asynchronously after hot update', () => { + if (__DEV__) { + render(() => { + function Hello() { + const [x] = React.useState(''); + React.useEffect(() => {}, []); + x.slice(); // Doesn't throw initially. + return

Hi

; + } + __register__(Hello, 'Hello'); + + class Boundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return

Oops: {this.state.error.message}

; + } + return this.props.children; + } + } + + function App() { + return ( + +

A

+ + + +

B

+
+ ); + } + + return App; + }); + + expect(container.innerHTML).toBe('

A

Hi

B

'); + const firstP = container.firstChild; + const secondP = firstP.nextSibling.nextSibling; + + // Perform a hot update that fails. + act(() => { + patch(() => { + function Hello() { + const [x, setX] = React.useState(''); + React.useEffect(() => { + setTimeout(() => { + setX(42); // This will crash next render. + }, 1); + }, []); + x.slice(); + return

Hi

; + } + __register__(Hello, 'Hello'); + }); + }); + + expect(container.innerHTML).toBe('

A

Hi

B

'); + // Run timeout inside effect: + act(() => { + jest.runAllTimers(); + }); + expect(container.innerHTML).toBe( + '

A

Oops: x.slice is not a function

B

', + ); + expect(container.firstChild).toBe(firstP); + expect(container.firstChild.nextSibling.nextSibling).toBe(secondP); + + // Perform a hot update that fixes the error. + patch(() => { + function Hello() { + const [x] = React.useState(''); + React.useEffect(() => {}, []); // Removes the bad effect code. + x.slice(); // Doesn't throw initially. + return

Fixed!

; + } + __register__(Hello, 'Hello'); + }); + + // This should remount the error boundary (but not anything above it). + expect(container.innerHTML).toBe('

A

Fixed!

B

'); + expect(container.firstChild).toBe(firstP); + expect(container.firstChild.nextSibling.nextSibling).toBe(secondP); + + // Verify next hot reload doesn't remount anything. + const helloNode = container.firstChild.nextSibling; + patch(() => { + function Hello() { + const [x] = React.useState(''); + React.useEffect(() => {}, []); + x.slice(); + return

Nice.

; + } + __register__(Hello, 'Hello'); + }); + expect(container.firstChild.nextSibling).toBe(helloNode); + expect(helloNode.textContent).toBe('Nice.'); + } + }); +}); diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index f51fdcfe5a..3d9def376b 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -43,6 +43,7 @@ import { getCurrentFiberStackInDev, getStackByFiberInDevAndProd, } from './ReactCurrentFiber'; +import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading'; import {StrictMode} from './ReactTypeOfMode'; let didWarnAboutMaps; @@ -378,7 +379,12 @@ function ChildReconciler(shouldTrackSideEffects) { element: ReactElement, expirationTime: ExpirationTime, ): Fiber { - if (current !== null && current.elementType === element.type) { + if ( + current !== null && + (current.elementType === element.type || + // Keep this check inline so it only runs on the false path: + (__DEV__ ? isCompatibleFamilyForHotReloading(current, element) : false)) + ) { // Move based on index const existing = useFiber(current, element.props, expirationTime); existing.ref = coerceRef(returnFiber, current, element); @@ -1121,7 +1127,11 @@ function ChildReconciler(shouldTrackSideEffects) { if ( child.tag === Fragment ? element.type === REACT_FRAGMENT_TYPE - : child.elementType === element.type + : child.elementType === element.type || + // Keep this check inline so it only runs on the false path: + (__DEV__ + ? isCompatibleFamilyForHotReloading(child, element) + : false) ) { deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber( diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 1330887c14..589c482bc1 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -45,6 +45,7 @@ import { SuspenseComponent, FunctionComponent, MemoComponent, + SimpleMemoComponent, LazyComponent, EventComponent, EventTarget, @@ -52,6 +53,10 @@ import { import getComponentName from 'shared/getComponentName'; import {isDevToolsPresent} from './ReactFiberDevToolsHook'; +import { + resolveFunctionForHotReloading, + resolveForwardRefForHotReloading, +} from './ReactFiberHotReloading'; import {NoWork} from './ReactFiberExpirationTime'; import { NoMode, @@ -218,6 +223,7 @@ export type Fiber = {| _debugSource?: Source | null, _debugOwner?: Fiber | null, _debugIsCurrentlyTiming?: boolean, + _debugNeedsRemount?: boolean, // Used to verify that the order of hooks does not change between renders. _debugHookTypes?: Array | null, @@ -302,6 +308,7 @@ function FiberNode( this._debugSource = null; this._debugOwner = null; this._debugIsCurrentlyTiming = false; + this._debugNeedsRemount = false; this._debugHookTypes = null; if (!hasBadMapPolyfill && typeof Object.preventExtensions === 'function') { Object.preventExtensions(this); @@ -434,6 +441,22 @@ export function createWorkInProgress( workInProgress.treeBaseDuration = current.treeBaseDuration; } + if (__DEV__) { + workInProgress._debugNeedsRemount = current._debugNeedsRemount; + switch (workInProgress.tag) { + case IndeterminateComponent: + case FunctionComponent: + case SimpleMemoComponent: + workInProgress.type = resolveFunctionForHotReloading(current.type); + break; + case ForwardRef: + workInProgress.type = resolveForwardRefForHotReloading(current.type); + break; + default: + break; + } + } + return workInProgress; } @@ -473,6 +496,10 @@ export function createFiberFromTypeAndProps( if (typeof type === 'function') { if (shouldConstruct(type)) { fiberTag = ClassComponent; + } else { + if (__DEV__) { + resolvedType = resolveFunctionForHotReloading(resolvedType); + } } } else if (typeof type === 'string') { fiberTag = HostComponent; @@ -509,6 +536,9 @@ export function createFiberFromTypeAndProps( break getTag; case REACT_FORWARD_REF_TYPE: fiberTag = ForwardRef; + if (__DEV__) { + resolvedType = resolveForwardRefForHotReloading(resolvedType); + } break getTag; case REACT_MEMO_TYPE: fiberTag = MemoComponent; @@ -777,6 +807,7 @@ export function assignFiberPropertiesInDEV( target._debugSource = source._debugSource; target._debugOwner = source._debugOwner; target._debugIsCurrentlyTiming = source._debugIsCurrentlyTiming; + target._debugNeedsRemount = source._debugNeedsRemount; target._debugHookTypes = source._debugHookTypes; return target; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 56208b1c43..71b931f6bb 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -71,6 +71,7 @@ import { getCurrentFiberStackInDev, } from './ReactCurrentFiber'; import {startWorkTimer, cancelWorkTimer} from './ReactDebugFiberPerf'; +import {resolveFunctionForHotReloading} from './ReactFiberHotReloading'; import { mountChildFibers, @@ -358,18 +359,22 @@ function updateMemoComponent( // SimpleMemoComponent codepath doesn't resolve outer props either. Component.defaultProps === undefined ) { + let resolvedType = type; + if (__DEV__) { + resolvedType = resolveFunctionForHotReloading(type); + } // If this is a plain function component without default props, // and with only the default shallow comparison, we upgrade it // to a SimpleMemoComponent to allow fast path updates. workInProgress.tag = SimpleMemoComponent; - workInProgress.type = type; + workInProgress.type = resolvedType; if (__DEV__) { validateFunctionComponentInDev(workInProgress, type); } return updateSimpleMemoComponent( current, workInProgress, - type, + resolvedType, nextProps, updateExpirationTime, renderExpirationTime, @@ -486,7 +491,9 @@ function updateSimpleMemoComponent( const prevProps = current.memoizedProps; if ( shallowEqual(prevProps, nextProps) && - current.ref === workInProgress.ref + current.ref === workInProgress.ref && + // Prevent bailout if the implementation changed due to hot reload: + (__DEV__ ? workInProgress.type === current.type : true) ) { didReceiveUpdate = false; if (updateExpirationTime < renderExpirationTime) { @@ -1023,6 +1030,9 @@ function mountLazyComponent( // Cancel and resume right after we know the tag. cancelWorkTimer(workInProgress); let Component = readLazyComponentType(elementType); + if (__DEV__) { + // TODO: resolve type for hot reloading. + } // Store the unwrapped component in the type. workInProgress.type = Component; const resolvedTag = (workInProgress.tag = resolveLazyComponentTag(Component)); @@ -2106,6 +2116,68 @@ function bailoutOnAlreadyFinishedWork( } } +function remountFiber( + current: Fiber, + oldWorkInProgress: Fiber, + newWorkInProgress: Fiber, +): Fiber | null { + if (__DEV__) { + const returnFiber = oldWorkInProgress.return; + if (returnFiber === null) { + throw new Error('Cannot swap the root fiber.'); + } + + // Disconnect from the old current. + // It will get deleted. + current.alternate = null; + oldWorkInProgress.alternate = null; + + // Connect to the new tree. + newWorkInProgress.index = oldWorkInProgress.index; + newWorkInProgress.sibling = oldWorkInProgress.sibling; + newWorkInProgress.return = oldWorkInProgress.return; + + // Replace the child/sibling pointers above it. + if (oldWorkInProgress === returnFiber.child) { + returnFiber.child = newWorkInProgress; + } else { + let prevSibling = returnFiber.child; + if (prevSibling === null) { + throw new Error('Expected parent to have a child.'); + } + while (prevSibling.sibling !== oldWorkInProgress) { + prevSibling = prevSibling.sibling; + if (prevSibling === null) { + throw new Error('Expected to find the previous sibling.'); + } + } + prevSibling.sibling = newWorkInProgress; + } + + // Delete the old fiber and place the new one. + // Since the old fiber is disconnected, we have to schedule it manually. + const last = returnFiber.lastEffect; + if (last !== null) { + last.nextEffect = current; + returnFiber.lastEffect = current; + } else { + returnFiber.firstEffect = returnFiber.lastEffect = current; + } + current.nextEffect = null; + current.effectTag = Deletion; + + newWorkInProgress.effectTag |= Placement; + + // Restart work from the new fiber. + return newWorkInProgress; + } else { + throw new Error( + 'Did not expect this call in production. ' + + 'This is a bug in React. Please file an issue.', + ); + } +} + function beginWork( current: Fiber | null, workInProgress: Fiber, @@ -2113,11 +2185,34 @@ function beginWork( ): Fiber | null { const updateExpirationTime = workInProgress.expirationTime; + if (__DEV__) { + if (workInProgress._debugNeedsRemount && current !== null) { + // This will restart the begin phase with a new fiber. + return remountFiber( + current, + workInProgress, + createFiberFromTypeAndProps( + workInProgress.type, + workInProgress.key, + workInProgress.pendingProps, + workInProgress._debugOwner || null, + workInProgress.mode, + workInProgress.expirationTime, + ), + ); + } + } + if (current !== null) { const oldProps = current.memoizedProps; const newProps = workInProgress.pendingProps; - if (oldProps !== newProps || hasLegacyContextChanged()) { + if ( + oldProps !== newProps || + hasLegacyContextChanged() || + // Force a re-render if the implementation changed due to hot reload: + (__DEV__ ? workInProgress.type !== current.type : false) + ) { // If props or context changed, mark the fiber as having performed work. // This may be unset if the props are determined to be equal later (memo). didReceiveUpdate = true; @@ -2256,11 +2351,10 @@ function beginWork( switch (workInProgress.tag) { case IndeterminateComponent: { - const elementType = workInProgress.elementType; return mountIndeterminateComponent( current, workInProgress, - elementType, + workInProgress.type, renderExpirationTime, ); } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 69147a0f71..f7eec92b3d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -189,6 +189,11 @@ let currentHookNameInDev: ?HookType = null; let hookTypesDev: Array | null = null; let hookTypesUpdateIndexDev: number = -1; +// In DEV, this tracks whether currently rendering component needs to ignore +// the dependencies for Hooks that need them (e.g. useEffect or useMemo). +// When true, such Hooks will always be "remounted". Only used during hot reload. +let ignorePreviousDependencies: boolean = false; + function mountHookTypesDev() { if (__DEV__) { const hookName = ((currentHookNameInDev: any): HookType); @@ -296,6 +301,13 @@ function areHookInputsEqual( nextDeps: Array, prevDeps: Array | null, ) { + if (__DEV__) { + if (ignorePreviousDependencies) { + // Only true when this component is being hot reloaded. + return false; + } + } + if (prevDeps === null) { if (__DEV__) { warning( @@ -352,6 +364,9 @@ export function renderWithHooks( ? ((current._debugHookTypes: any): Array) : null; hookTypesUpdateIndexDev = -1; + // Used for hot reloading: + ignorePreviousDependencies = + current !== null && current.type !== workInProgress.type; } // The following should have already been reset diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.js b/packages/react-reconciler/src/ReactFiberHotReloading.js new file mode 100644 index 0000000000..1804ba6fa5 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberHotReloading.js @@ -0,0 +1,264 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactElement} from 'shared/ReactElementType'; +import type {Fiber} from './ReactFiber'; +import type {FiberRoot} from './ReactFiberRoot'; + +import { + flushSync, + scheduleWork, + flushPassiveEffects, +} from './ReactFiberScheduler'; +import {Sync} from './ReactFiberExpirationTime'; +import { + FunctionComponent, + ForwardRef, + MemoComponent, + SimpleMemoComponent, +} from 'shared/ReactWorkTags'; +import {REACT_FORWARD_REF_TYPE, REACT_MEMO_TYPE} from 'shared/ReactSymbols'; + +type Family = {| + currentType: any, + currentSignature: null | string, +|}; + +type HotUpdate = {| + familiesByType: WeakMap, + root: FiberRoot, + staleFamilies: Set, + updatedFamilies: Set, +|}; + +let familiesByType: WeakMap | null = null; +// $FlowFixMe Flow gets confused by a WeakSet feature check below. +let failedBoundaries: WeakSet | null = null; + +export function resolveFunctionForHotReloading(type: any): any { + if (__DEV__) { + if (familiesByType === null) { + // Hot reloading is disabled. + return type; + } + let family = familiesByType.get(type); + if (family === undefined) { + return type; + } + // Use the latest known implementation. + return family.currentType; + } else { + return type; + } +} + +export function resolveForwardRefForHotReloading(type: any): any { + if (__DEV__) { + if (familiesByType === null) { + // Hot reloading is disabled. + return type; + } + let family = familiesByType.get(type); + if (family === undefined) { + // Check if we're dealing with a real forwardRef. Don't want to crash early. + if ( + type !== null && + type !== undefined && + typeof type.render === 'function' + ) { + // ForwardRef is special because its resolved .type is an object, + // but it's possible that we only have its inner render function in the map. + // If that inner render function is different, we'll build a new forwardRef type. + const currentRender = resolveFunctionForHotReloading(type.render); + if (type.render !== currentRender) { + const syntheticType = { + $$typeof: REACT_FORWARD_REF_TYPE, + render: currentRender, + }; + if (type.displayName !== undefined) { + (syntheticType: any).displayName = type.displayName; + } + return syntheticType; + } + } + return type; + } + // Use the latest known implementation. + return family.currentType; + } else { + return type; + } +} + +export function isCompatibleFamilyForHotReloading( + fiber: Fiber, + element: ReactElement, +): boolean { + if (__DEV__) { + if (familiesByType === null) { + // Hot reloading is disabled. + return false; + } + + const prevType = fiber.elementType; + const nextType = element.type; + // If we got here, we know types aren't === equal. + let needsCompareFamilies = false; + switch (fiber.tag) { + case FunctionComponent: { + if (typeof nextType === 'function') { + needsCompareFamilies = true; + } + break; + } + case ForwardRef: { + if ( + typeof nextType === 'object' && + nextType !== null && + nextType.$$typeof === REACT_FORWARD_REF_TYPE + ) { + needsCompareFamilies = true; + } + break; + } + case MemoComponent: + case SimpleMemoComponent: { + if ( + typeof nextType === 'object' && + nextType !== null && + nextType.$$typeof === REACT_MEMO_TYPE + ) { + // TODO: if it was but can no longer be simple, + // we shouldn't set this. + needsCompareFamilies = true; + } + break; + } + // TODO: maybe support lazy? + default: + return false; + } + + // Check if both types have a family and it's the same one. + if (needsCompareFamilies) { + // Note: memo() and forwardRef() we'll compare outer rather than inner type. + // This means both of them need to be registered to preserve state. + // If we unwrapped and compared the inner types for wrappers instead, + // then we would risk falsely saying two separate memo(Foo) + // calls are equivalent because they wrap the same Foo function. + const prevFamily = familiesByType.get(prevType); + if ( + prevFamily !== undefined && + prevFamily === familiesByType.get(nextType) + ) { + return true; + } + } + return false; + } else { + return false; + } +} + +export function markFailedErrorBoundaryForHotReloading(fiber: Fiber) { + if (__DEV__) { + if (familiesByType === null) { + // Not hot reloading. + return; + } + if (typeof WeakSet !== 'function') { + return; + } + if (failedBoundaries === null) { + failedBoundaries = new WeakSet(); + } + failedBoundaries.add(fiber); + } +} + +export function scheduleHotUpdate(hotUpdate: HotUpdate): void { + if (__DEV__) { + // TODO: warn if its identity changes over time? + familiesByType = hotUpdate.familiesByType; + + const {root, staleFamilies, updatedFamilies} = hotUpdate; + flushPassiveEffects(); + flushSync(() => { + scheduleFibersWithFamiliesRecursively( + root.current, + updatedFamilies, + staleFamilies, + ); + }); + } +} + +function scheduleFibersWithFamiliesRecursively( + fiber: Fiber, + updatedFamilies: Set, + staleFamilies: Set, +) { + if (__DEV__) { + const {alternate, child, sibling, tag, type} = fiber; + + let candidateType = null; + switch (tag) { + case FunctionComponent: + case SimpleMemoComponent: + candidateType = type; + break; + case ForwardRef: + candidateType = type.render; + break; + default: + break; + } + + if (familiesByType === null) { + throw new Error('Expected familiesByType to be set during hot reload.'); + } + + if (candidateType !== null) { + const family = familiesByType.get(candidateType); + if (family !== undefined) { + if (staleFamilies.has(family)) { + fiber._debugNeedsRemount = true; + scheduleWork(fiber, Sync); + } else if (updatedFamilies.has(family)) { + scheduleWork(fiber, Sync); + } + } + } + + if (failedBoundaries !== null) { + if ( + failedBoundaries.has(fiber) || + (alternate !== null && failedBoundaries.has(alternate)) + ) { + fiber._debugNeedsRemount = true; + scheduleWork(fiber, Sync); + } + } + + if (child !== null) { + scheduleFibersWithFamiliesRecursively( + child, + updatedFamilies, + staleFamilies, + ); + } + if (sibling !== null) { + scheduleFibersWithFamiliesRecursively( + sibling, + updatedFamilies, + staleFamilies, + ); + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index ae3d449339..84291f4b7a 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -67,6 +67,7 @@ import {StrictMode} from './ReactTypeOfMode'; import {Sync} from './ReactFiberExpirationTime'; import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags'; import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; +import {scheduleHotUpdate} from './ReactFiberHotReloading'; type OpaqueRoot = FiberRoot; @@ -460,6 +461,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { return injectInternals({ ...devToolsConfig, + scheduleHotUpdate: __DEV__ ? scheduleHotUpdate : null, overrideHookState, overrideProps, setSuspenseHandler, diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 4db7a3a76a..f15815ffe3 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -56,6 +56,7 @@ import { import {logError} from './ReactFiberCommitWork'; import {getStackByFiberInDevAndProd} from './ReactCurrentFiber'; import {popHostContainer, popHostContext} from './ReactFiberHostContext'; +import {markFailedErrorBoundaryForHotReloading} from './ReactFiberHotReloading'; import { suspenseStackCursor, InvisibleParentSuspenseContext, @@ -122,6 +123,9 @@ function createClassErrorUpdate( const inst = fiber.stateNode; if (inst !== null && typeof inst.componentDidCatch === 'function') { update.callback = function callback() { + if (__DEV__) { + markFailedErrorBoundaryForHotReloading(fiber); + } if (typeof getDerivedStateFromError !== 'function') { // To preserve the preexisting retry behavior of error boundaries, // we keep track of which ones already failed during this batch. @@ -150,6 +154,10 @@ function createClassErrorUpdate( } } }; + } else if (__DEV__) { + update.callback = () => { + markFailedErrorBoundaryForHotReloading(fiber); + }; } return update; } diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorReplay-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorReplay-test.internal.js index 6e6215efcb..9a497f074d 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorReplay-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorReplay-test.internal.js @@ -10,21 +10,22 @@ 'use strict'; describe('ReactIncrementalErrorReplay-test', () => { + const React = require('react'); + const ReactTestRenderer = require('react-test-renderer'); + it('copies all keys when stashing potentially failing work', () => { // Note: this test is fragile and relies on internals. // We almost always try to avoid such tests, but here the cost of // the list getting out of sync (and causing subtle bugs in rare cases) // is higher than the cost of maintaining the test. const { - // Any Fiber factory function will do. - createHostRootFiber, // This is the method we're going to test. // If this is no longer used, you can delete this test file. assignFiberPropertiesInDEV, } = require('../ReactFiber'); // Get a real fiber. - const realFiber = createHostRootFiber(false); + const realFiber = ReactTestRenderer.create(
).root._currentFiber(); const stash = assignFiberPropertiesInDEV(null, realFiber); // Verify we get all the same fields.