diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.js b/packages/react-reconciler/src/ReactFiberHotReloading.js index ae7c9bd7e7..03b108b244 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.js @@ -38,16 +38,32 @@ export type Family = {| current: any, |}; -export type HotUpdate = {| - resolveFamily: (any => Family | void) | null, +export type RefreshUpdate = {| staleFamilies: Set, updatedFamilies: Set, |}; -let resolveFamily: (any => Family | void) | null = null; +// Resolves type to a family. +type RefreshHandler = any => Family | void; + +// Used by React Refresh runtime through DevTools Global Hook. +export type SetRefreshHandler = (handler: RefreshHandler | null) => void; +export type ScheduleRefresh = (root: FiberRoot, update: RefreshUpdate) => void; +export type FindHostInstancesForRefresh = ( + root: FiberRoot, + families: Array, +) => Set; + +let resolveFamily: RefreshHandler | null = null; // $FlowFixMe Flow gets confused by a WeakSet feature check below. let failedBoundaries: WeakSet | null = null; +export let setRefreshHandler = (handler: RefreshHandler | null): void => { + if (__DEV__) { + resolveFamily = handler; + } +}; + export function resolveFunctionForHotReloading(type: any): any { if (__DEV__) { if (resolveFamily === null) { @@ -192,7 +208,7 @@ export function isCompatibleFamilyForHotReloading( export function markFailedErrorBoundaryForHotReloading(fiber: Fiber) { if (__DEV__) { if (resolveFamily === null) { - // Not hot reloading. + // Hot reloading is disabled. return; } if (typeof WeakSet !== 'function') { @@ -205,12 +221,16 @@ export function markFailedErrorBoundaryForHotReloading(fiber: Fiber) { } } -export function scheduleHotUpdate(root: FiberRoot, hotUpdate: HotUpdate): void { +export let scheduleRefresh: ScheduleRefresh = ( + root: FiberRoot, + update: RefreshUpdate, +): void => { if (__DEV__) { - // TODO: warn if its identity changes over time? - resolveFamily = hotUpdate.resolveFamily; - - const {staleFamilies, updatedFamilies} = hotUpdate; + if (resolveFamily === null) { + // Hot reloading is disabled. + return; + } + const {staleFamilies, updatedFamilies} = update; flushPassiveEffects(); flushSync(() => { scheduleFibersWithFamiliesRecursively( @@ -220,7 +240,7 @@ export function scheduleHotUpdate(root: FiberRoot, hotUpdate: HotUpdate): void { ); }); } -} +}; function scheduleFibersWithFamiliesRecursively( fiber: Fiber, @@ -292,10 +312,10 @@ function scheduleFibersWithFamiliesRecursively( } } -export function findHostInstancesForHotUpdate( +export let findHostInstancesForRefresh: FindHostInstancesForRefresh = ( root: FiberRoot, families: Array, -): Set { +): Set => { if (__DEV__) { const hostInstances = new Set(); const types = new Set(families.map(family => family.current)); @@ -307,10 +327,10 @@ export function findHostInstancesForHotUpdate( return hostInstances; } else { throw new Error( - 'Did not expect findHostInstancesForHotUpdate to be called in production.', + 'Did not expect findHostInstancesForRefresh to be called in production.', ); } -} +}; function findHostInstancesForMatchingFibersRecursively( fiber: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 5300872062..52a5b45ed9 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -71,8 +71,9 @@ import {Sync} from './ReactFiberExpirationTime'; import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags'; import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; import { - scheduleHotUpdate, - findHostInstancesForHotUpdate, + scheduleRefresh, + setRefreshHandler, + findHostInstancesForRefresh, } from './ReactFiberHotReloading'; type OpaqueRoot = FiberRoot; @@ -475,10 +476,6 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { return injectInternals({ ...devToolsConfig, - findHostInstancesForHotUpdate: __DEV__ - ? findHostInstancesForHotUpdate - : null, - scheduleHotUpdate: __DEV__ ? scheduleHotUpdate : null, overrideHookState, overrideProps, setSuspenseHandler, @@ -498,5 +495,9 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { } return findFiberByHostInstance(instance); }, + // React Refresh + findHostInstancesForRefresh: __DEV__ ? findHostInstancesForRefresh : null, + scheduleRefresh: __DEV__ ? scheduleRefresh : null, + setRefreshHandler: __DEV__ ? setRefreshHandler : null, }); } diff --git a/packages/react-refresh/src/ReactFreshRuntime.js b/packages/react-refresh/src/ReactFreshRuntime.js index 2ccfe7eece..17060d59bc 100644 --- a/packages/react-refresh/src/ReactFreshRuntime.js +++ b/packages/react-refresh/src/ReactFreshRuntime.js @@ -7,12 +7,18 @@ * @flow */ +import type {Instance} from 'react-reconciler/src/ReactFiberHostConfig'; +import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot'; import type { Family, - HotUpdate, + RefreshUpdate, + ScheduleRefresh, + FindHostInstancesForRefresh, + SetRefreshHandler, } from 'react-reconciler/src/ReactFiberHotReloading'; import {REACT_MEMO_TYPE, REACT_FORWARD_REF_TYPE} from 'shared/ReactSymbols'; +import warningWithoutStack from 'shared/warningWithoutStack'; type Signature = {| ownKey: string, @@ -38,10 +44,17 @@ WeakMap | Map = new PossiblyWeakMap(); const familiesByType: // $FlowIssue WeakMap | Map = new PossiblyWeakMap(); -// This is cleared on every prepareUpdate() call. +// This is cleared on every performReactRefresh() call. // It is an array of [Family, NextType] tuples. let pendingUpdates: Array<[Family, any]> = []; +// This is injected by the renderer via DevTools global hook. +let setRefreshHandler: null | SetRefreshHandler = null; +let scheduleRefresh: null | ScheduleRefresh = null; +let findHostInstancesForRefresh: null | FindHostInstancesForRefresh = null; + +let mountedRoots = new Set(); + function computeFullKey(signature: Signature): string { if (signature.fullKey !== null) { return signature.fullKey; @@ -123,76 +136,132 @@ function resolveFamily(type) { return familiesByType.get(type); } -export function prepareUpdate(): HotUpdate | null { - if (pendingUpdates.length === 0) { - return null; - } - - const staleFamilies = new Set(); - const updatedFamilies = new Set(); - - const updates = pendingUpdates; - pendingUpdates = []; - updates.forEach(([family, nextType]) => { - // Now that we got a real edit, we can create associations - // that will be read by the React reconciler. - const prevType = family.current; - familiesByType.set(prevType, family); - familiesByType.set(nextType, family); - family.current = nextType; - - // Determine whether this should be a re-render or a re-mount. - if (canPreserveStateBetween(prevType, nextType)) { - updatedFamilies.add(family); - } else { - staleFamilies.add(family); +export function performReactRefresh(): boolean { + if (__DEV__) { + if (pendingUpdates.length === 0) { + return false; } - }); - return { - resolveFamily, - updatedFamilies, - staleFamilies, - }; + const staleFamilies = new Set(); + const updatedFamilies = new Set(); + + const updates = pendingUpdates; + pendingUpdates = []; + updates.forEach(([family, nextType]) => { + // Now that we got a real edit, we can create associations + // that will be read by the React reconciler. + const prevType = family.current; + familiesByType.set(prevType, family); + familiesByType.set(nextType, family); + family.current = nextType; + + // Determine whether this should be a re-render or a re-mount. + if (canPreserveStateBetween(prevType, nextType)) { + updatedFamilies.add(family); + } else { + staleFamilies.add(family); + } + }); + + const update: RefreshUpdate = { + updatedFamilies, + staleFamilies, + }; + + if (typeof setRefreshHandler !== 'function') { + warningWithoutStack( + false, + 'Could not find the setRefreshHandler() implementation. ' + + 'This likely means that injectIntoGlobalHook() was either ' + + 'called before the global DevTools hook was set up, or after the ' + + 'renderer has already initialized. Please file an issue with a reproducing case.', + ); + return false; + } + + if (typeof scheduleRefresh !== 'function') { + warningWithoutStack( + false, + 'Could not find the scheduleRefresh() implementation. ' + + 'This likely means that injectIntoGlobalHook() was either ' + + 'called before the global DevTools hook was set up, or after the ' + + 'renderer has already initialized. Please file an issue with a reproducing case.', + ); + return false; + } + const scheduleRefreshForRoot = scheduleRefresh; + + // Even if there are no roots, set the handler on first update. + // This ensures that if *new* roots are mounted, they'll use the resolve handler. + setRefreshHandler(resolveFamily); + + let didError = false; + let firstError = null; + mountedRoots.forEach(root => { + try { + scheduleRefreshForRoot(root, update); + } catch (err) { + if (!didError) { + didError = true; + firstError = err; + } + // Keep trying other roots. + } + }); + if (didError) { + throw firstError; + } + return true; + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } } export function register(type: any, id: string): void { - if (type === null) { - return; - } - if (typeof type !== 'function' && typeof type !== 'object') { - return; - } - - // This can happen in an edge case, e.g. if we register - // return value of a HOC but it returns a cached component. - // Ignore anything but the first registration for each type. - if (allTypes.has(type)) { - return; - } - allTypes.add(type); - - // Create family or remember to update it. - // None of this bookkeeping affects reconciliation - // until the first prepareUpdate() call above. - let family = allFamiliesByID.get(id); - if (family === undefined) { - family = {current: type}; - allFamiliesByID.set(id, family); - } else { - pendingUpdates.push([family, type]); - } - - // Visit inner types because we might not have registered them. - if (typeof type === 'object' && type !== null) { - switch (type.$$typeof) { - case REACT_FORWARD_REF_TYPE: - register(type.render, id + '$render'); - break; - case REACT_MEMO_TYPE: - register(type.type, id + '$type'); - break; + if (__DEV__) { + if (type === null) { + return; } + if (typeof type !== 'function' && typeof type !== 'object') { + return; + } + + // This can happen in an edge case, e.g. if we register + // return value of a HOC but it returns a cached component. + // Ignore anything but the first registration for each type. + if (allTypes.has(type)) { + return; + } + allTypes.add(type); + + // Create family or remember to update it. + // None of this bookkeeping affects reconciliation + // until the first prepareUpdate() call above. + let family = allFamiliesByID.get(id); + if (family === undefined) { + family = {current: type}; + allFamiliesByID.set(id, family); + } else { + pendingUpdates.push([family, type]); + } + + // Visit inner types because we might not have registered them. + if (typeof type === 'object' && type !== null) { + switch (type.$$typeof) { + case REACT_FORWARD_REF_TYPE: + register(type.render, id + '$render'); + break; + case REACT_MEMO_TYPE: + register(type.type, id + '$type'); + break; + } + } + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); } } @@ -202,23 +271,260 @@ export function setSignature( forceReset?: boolean = false, getCustomHooks?: () => Array, ): void { - allSignaturesByType.set(type, { - forceReset, - ownKey: key, - fullKey: null, - getCustomHooks: getCustomHooks || (() => []), - }); + if (__DEV__) { + allSignaturesByType.set(type, { + forceReset, + ownKey: key, + fullKey: null, + getCustomHooks: getCustomHooks || (() => []), + }); + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } } // This is lazily called during first render for a type. // It captures Hook list at that time so inline requires don't break comparisons. export function collectCustomHooksForSignature(type: any) { - const signature = allSignaturesByType.get(type); - if (signature !== undefined) { - computeFullKey(signature); + if (__DEV__) { + const signature = allSignaturesByType.get(type); + if (signature !== undefined) { + computeFullKey(signature); + } + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); } } export function getFamilyByID(id: string): Family | void { - return allFamiliesByID.get(id); + if (__DEV__) { + return allFamiliesByID.get(id); + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } +} + +export function findAffectedHostInstances( + families: Array, +): Set { + if (__DEV__) { + if (typeof findHostInstancesForRefresh !== 'function') { + warningWithoutStack( + false, + 'Could not find the findHostInstancesForRefresh() implementation. ' + + 'This likely means that injectIntoGlobalHook() was either ' + + 'called before the global DevTools hook was set up, or after the ' + + 'renderer has already initialized. Please file an issue with a reproducing case.', + ); + return new Set(); + } + const findInstances = findHostInstancesForRefresh; + let affectedInstances = new Set(); + mountedRoots.forEach(root => { + const instancesForRoot = findInstances(root, families); + instancesForRoot.forEach(inst => { + affectedInstances.add(inst); + }); + }); + return affectedInstances; + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } +} + +export function injectIntoGlobalHook(globalObject: any): void { + if (__DEV__) { + // For React Native, the global hook will be set up by require('react-devtools-core'). + // That code will run before us. So we need to monkeypatch functions on existing hook. + + // For React Web, the global hook will be set up by the extension. + // This will also run before us. + let hook = globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (hook === undefined) { + // However, if there is no DevTools extension, we'll need to set up the global hook ourselves. + // Note that in this case it's important that renderer code runs *after* this method call. + // Otherwise, the renderer will think that there is no global hook, and won't do the injection. + globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook = { + supportsFiber: true, + inject() {}, + onCommitFiberRoot(id: mixed, root: FiberRoot) {}, + onCommitFiberUnmount() {}, + }; + } + + // Here, we just want to get a reference to scheduleRefresh. + const oldInject = hook.inject; + hook.inject = function(injected) { + findHostInstancesForRefresh = ((injected: any) + .findHostInstancesForRefresh: FindHostInstancesForRefresh); + scheduleRefresh = ((injected: any).scheduleRefresh: ScheduleRefresh); + setRefreshHandler = ((injected: any) + .setRefreshHandler: SetRefreshHandler); + return oldInject.apply(this, arguments); + }; + + // We also want to track currently mounted roots. + const oldOnCommitFiberRoot = hook.onCommitFiberRoot; + hook.onCommitFiberRoot = function(id: mixed, root: FiberRoot) { + const current = root.current; + const alternate = current.alternate; + + // We need to determine whether this root has just (un)mounted. + // This logic is copy-pasted from similar logic in the DevTools backend. + // If this breaks with some refactoring, you'll want to update DevTools too. + + if (alternate !== null) { + const wasMounted = + alternate.memoizedState != null && + alternate.memoizedState.element != null; + const isMounted = + current.memoizedState != null && + current.memoizedState.element != null; + + if (!wasMounted && isMounted) { + // Mount a new root. + mountedRoots.add(root); + } else if (wasMounted && isMounted) { + // Update an existing root. + // This doesn't affect our mounted root Set. + } else if (wasMounted && !isMounted) { + // Unmount an existing root. + mountedRoots.delete(root); + } + } else { + // Mount a new root. + mountedRoots.add(root); + } + + return oldOnCommitFiberRoot.apply(this, arguments); + }; + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } +} + +// Exposed for testing. +export function _getMountedRootCount() { + if (__DEV__) { + return mountedRoots.size; + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } +} + +// This is a wrapper over more primitive functions for setting signature. +// Signatures let us decide whether the Hook order has changed on refresh. +// +// This function is intended to be used as a transform target, e.g.: +// var _s = createSignatureFunctionForTransform() +// +// function Hello() { +// const [foo, setFoo] = useState(0); +// const value = useCustomHook(); +// _s(); /* Second call triggers collecting the custom Hook list. +// * This doesn't happen during the module evaluation because we +// * don't want to change the module order with inline requires. +// * Next calls are noops. */ +// return

Hi

; +// } +// +// /* First call specifies the signature: */ +// _s( +// Hello, +// 'useState{[foo, setFoo]}(0)', +// () => [useCustomHook], /* Lazy to avoid triggering inline requires */ +// ); +export function createSignatureFunctionForTransform() { + if (__DEV__) { + let call = 0; + let savedType; + let hasCustomHooks; + return function( + type: T, + key: string, + forceReset?: boolean, + getCustomHooks?: () => Array, + ): T { + switch (call++) { + case 0: + savedType = type; + hasCustomHooks = typeof getCustomHooks === 'function'; + setSignature(type, key, forceReset, getCustomHooks); + break; + case 1: + if (hasCustomHooks) { + collectCustomHooksForSignature(savedType); + } + break; + } + return type; + }; + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } +} + +export function isLikelyComponentType(type: any): boolean { + if (__DEV__) { + switch (typeof type) { + case 'function': { + // First, deal with classes. + if (type.prototype != null) { + if (type.prototype.isReactComponent) { + // React class. + return true; + } + const ownNames = Object.getOwnPropertyNames(type.prototype); + if (ownNames.length > 1 || ownNames[0] !== 'constructor') { + // This looks like a class. + return false; + } + // eslint-disable-next-line no-proto + if (type.prototype.__proto__ !== Object.prototype) { + // It has a superclass. + return false; + } + // Pass through. + // This looks like a regular function with empty prototype. + } + // For plain functions and arrows, use name as a heuristic. + const name = type.name || type.displayName; + return typeof name === 'string' && /^[A-Z]/.test(name); + } + case 'object': { + if (type != null) { + switch (type.$$typeof) { + case REACT_FORWARD_REF_TYPE: + case REACT_MEMO_TYPE: + // Definitely React components. + return true; + default: + return false; + } + } + return false; + } + default: { + return false; + } + } + } else { + throw new Error( + 'Unexpected call to React Refresh in a production environment.', + ); + } } diff --git a/packages/react-refresh/src/__tests__/ReactFresh-test.js b/packages/react-refresh/src/__tests__/ReactFresh-test.js index f7ebc8dd8d..e9afe7dcc9 100644 --- a/packages/react-refresh/src/__tests__/ReactFresh-test.js +++ b/packages/react-refresh/src/__tests__/ReactFresh-test.js @@ -16,38 +16,34 @@ let ReactDOM; let ReactFreshRuntime; let Scheduler; let act; +let createReactClass; describe('ReactFresh', () => { let container; - let lastRoot; - let findHostInstancesForHotUpdate; - let scheduleHotUpdate; beforeEach(() => { - global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { - supportsFiber: true, - inject: injected => { - scheduleHotUpdate = injected.scheduleHotUpdate; - findHostInstancesForHotUpdate = injected.findHostInstancesForHotUpdate; - }, - onCommitFiberRoot: (id, root) => { - lastRoot = root; - }, - onCommitFiberUnmount: () => {}, - }; - - jest.resetModules(); - React = require('react'); - ReactDOM = require('react-dom'); - ReactFreshRuntime = require('react-refresh/runtime'); - Scheduler = require('scheduler'); - act = require('react-dom/test-utils').act; - container = document.createElement('div'); - document.body.appendChild(container); + if (__DEV__) { + jest.resetModules(); + React = require('react'); + ReactFreshRuntime = require('react-refresh/runtime'); + ReactFreshRuntime.injectIntoGlobalHook(global); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + act = require('react-dom/test-utils').act; + createReactClass = require('create-react-class/factory')( + React.Component, + React.isValidElement, + new React.Component().updater, + ); + container = document.createElement('div'); + document.body.appendChild(container); + } }); afterEach(() => { - document.body.removeChild(container); + if (__DEV__) { + document.body.removeChild(container); + } }); function prepare(version) { @@ -65,8 +61,7 @@ describe('ReactFresh', () => { function patch(version) { const Component = version(); - const hotUpdate = ReactFreshRuntime.prepareUpdate(); - scheduleHotUpdate(lastRoot, hotUpdate); + ReactFreshRuntime.performReactRefresh(); return Component; } @@ -1919,7 +1914,7 @@ describe('ReactFresh', () => { ReactDOM.render(tree, container); const elements = container.querySelectorAll('section'); - // Each tree above products exactly three
elements: + // Each tree above produces exactly three
elements: expect(elements.length).toBe(3); elements.forEach(el => { expect(el.dataset.color).toBe('blue'); @@ -3037,11 +3032,238 @@ describe('ReactFresh', () => { function testFindHostInstancesForFamilies(families, expectedNodes) { const foundInstances = Array.from( - findHostInstancesForHotUpdate(lastRoot, families), + ReactFreshRuntime.findAffectedHostInstances(families), ); expect(foundInstances.length).toEqual(expectedNodes.length); foundInstances.forEach((node, i) => { expect(node).toBe(expectedNodes[i]); }); } + + it('can update multiple roots independently', () => { + if (__DEV__) { + // Declare the first version. + const HelloV1 = () => { + const [val, setVal] = React.useState(0); + return ( +

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

+ ); + }; + __register__(HelloV1, 'Hello'); + + // Perform a hot update before any roots exist. + const HelloV2 = () => { + const [val, setVal] = React.useState(0); + return ( +

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

+ ); + }; + __register__(HelloV2, 'Hello'); + ReactFreshRuntime.performReactRefresh(); + + // Mount three roots. + let cont1 = document.createElement('div'); + let cont2 = document.createElement('div'); + let cont3 = document.createElement('div'); + document.body.appendChild(cont1); + document.body.appendChild(cont2); + document.body.appendChild(cont3); + try { + ReactDOM.render(, cont1); + ReactDOM.render(, cont2); + ReactDOM.render(, cont3); + + // Expect we see the V2 color. + expect(cont1.firstChild.style.color).toBe('red'); + expect(cont2.firstChild.style.color).toBe('red'); + expect(cont3.firstChild.style.color).toBe('red'); + expect(cont1.firstChild.textContent).toBe('0'); + expect(cont2.firstChild.textContent).toBe('0'); + expect(cont3.firstChild.textContent).toBe('0'); + + // Bump the state for each of them. + act(() => { + cont1.firstChild.dispatchEvent( + new MouseEvent('click', {bubbles: true}), + ); + cont2.firstChild.dispatchEvent( + new MouseEvent('click', {bubbles: true}), + ); + cont3.firstChild.dispatchEvent( + new MouseEvent('click', {bubbles: true}), + ); + }); + expect(cont1.firstChild.style.color).toBe('red'); + expect(cont2.firstChild.style.color).toBe('red'); + expect(cont3.firstChild.style.color).toBe('red'); + expect(cont1.firstChild.textContent).toBe('1'); + expect(cont2.firstChild.textContent).toBe('1'); + expect(cont3.firstChild.textContent).toBe('1'); + + // Perform another hot update. + const HelloV3 = () => { + const [val, setVal] = React.useState(0); + return ( +

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

+ ); + }; + __register__(HelloV3, 'Hello'); + ReactFreshRuntime.performReactRefresh(); + + // It should affect all roots. + expect(cont1.firstChild.style.color).toBe('green'); + expect(cont2.firstChild.style.color).toBe('green'); + expect(cont3.firstChild.style.color).toBe('green'); + expect(cont1.firstChild.textContent).toBe('1'); + expect(cont2.firstChild.textContent).toBe('1'); + expect(cont3.firstChild.textContent).toBe('1'); + + // Unmount the second root. + ReactDOM.unmountComponentAtNode(cont2); + // Make the first root throw and unmount on hot update. + const HelloV4 = ({id}) => { + if (id === 1) { + throw new Error('Oops.'); + } + const [val, setVal] = React.useState(0); + return ( +

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

+ ); + }; + __register__(HelloV4, 'Hello'); + expect(() => { + ReactFreshRuntime.performReactRefresh(); + }).toThrow('Oops.'); + + // Still, we expect the last root to be updated. + expect(cont1.innerHTML).toBe(''); + expect(cont2.innerHTML).toBe(''); + expect(cont3.firstChild.style.color).toBe('orange'); + expect(cont3.firstChild.textContent).toBe('1'); + } finally { + document.body.removeChild(cont1); + document.body.removeChild(cont2); + document.body.removeChild(cont3); + } + } + }); + + // Module runtimes can use this to decide whether + // to propagate an update up to the modules that imported it, + // or to stop at the current module because it's a component. + // This can't and doesn't need to be 100% precise. + it('can detect likely component types', () => { + function useTheme() {} + function Widget() {} + + if (__DEV__) { + expect(ReactFreshRuntime.isLikelyComponentType(false)).toBe(false); + expect(ReactFreshRuntime.isLikelyComponentType(null)).toBe(false); + expect(ReactFreshRuntime.isLikelyComponentType('foo')).toBe(false); + + // We need to hit a balance here. + // If we lean towards assuming everything is a component, + // editing modules that export plain functions won't trigger + // a proper reload because we will bottle up the update. + // So we're being somewhat conservative. + expect(ReactFreshRuntime.isLikelyComponentType(() => {})).toBe(false); + expect(ReactFreshRuntime.isLikelyComponentType(function() {})).toBe( + false, + ); + expect( + ReactFreshRuntime.isLikelyComponentType(function lightenColor() {}), + ).toBe(false); + const loadUser = () => {}; + expect(ReactFreshRuntime.isLikelyComponentType(loadUser)).toBe(false); + const useStore = () => {}; + expect(ReactFreshRuntime.isLikelyComponentType(useStore)).toBe(false); + expect(ReactFreshRuntime.isLikelyComponentType(useTheme)).toBe(false); + + // These seem like function components. + let Button = () => {}; + expect(ReactFreshRuntime.isLikelyComponentType(Button)).toBe(true); + expect(ReactFreshRuntime.isLikelyComponentType(Widget)).toBe(true); + let anon = (() => () => {})(); + anon.displayName = 'Foo'; + expect(ReactFreshRuntime.isLikelyComponentType(anon)).toBe(true); + + // These seem like class components. + class Btn extends React.Component {} + class PureBtn extends React.PureComponent {} + expect(ReactFreshRuntime.isLikelyComponentType(Btn)).toBe(true); + expect(ReactFreshRuntime.isLikelyComponentType(PureBtn)).toBe(true); + expect( + ReactFreshRuntime.isLikelyComponentType( + createReactClass({render() {}}), + ), + ).toBe(true); + + // These don't. + class Figure { + move() {} + } + expect(ReactFreshRuntime.isLikelyComponentType(Figure)).toBe(false); + class Point extends Figure {} + expect(ReactFreshRuntime.isLikelyComponentType(Point)).toBe(false); + + // Run the same tests without Babel. + // This tests real arrow functions and classes, as implemented in Node. + + // eslint-disable-next-line no-new-func + new Function( + 'global', + 'React', + 'ReactFreshRuntime', + 'expect', + 'createReactClass', + ` + expect(ReactFreshRuntime.isLikelyComponentType(() => {})).toBe(false); + expect(ReactFreshRuntime.isLikelyComponentType(function() {})).toBe(false); + expect( + ReactFreshRuntime.isLikelyComponentType(function lightenColor() {}), + ).toBe(false); + const loadUser = () => {}; + expect(ReactFreshRuntime.isLikelyComponentType(loadUser)).toBe(false); + const useStore = () => {}; + expect(ReactFreshRuntime.isLikelyComponentType(useStore)).toBe(false); + function useTheme() {} + expect(ReactFreshRuntime.isLikelyComponentType(useTheme)).toBe(false); + + // These seem like function components. + let Button = () => {}; + expect(ReactFreshRuntime.isLikelyComponentType(Button)).toBe(true); + function Widget() {} + expect(ReactFreshRuntime.isLikelyComponentType(Widget)).toBe(true); + let anon = (() => () => {})(); + anon.displayName = 'Foo'; + expect(ReactFreshRuntime.isLikelyComponentType(anon)).toBe(true); + + // These seem like class components. + class Btn extends React.Component {} + class PureBtn extends React.PureComponent {} + expect(ReactFreshRuntime.isLikelyComponentType(Btn)).toBe(true); + expect(ReactFreshRuntime.isLikelyComponentType(PureBtn)).toBe(true); + expect( + ReactFreshRuntime.isLikelyComponentType(createReactClass({render() {}})), + ).toBe(true); + + // These don't. + class Figure { + move() {} + } + expect(ReactFreshRuntime.isLikelyComponentType(Figure)).toBe(false); + class Point extends Figure {} + expect(ReactFreshRuntime.isLikelyComponentType(Point)).toBe(false); + `, + )(global, React, ReactFreshRuntime, expect, createReactClass); + } + }); }); diff --git a/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js b/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js index 4725523e7b..cfe8575265 100644 --- a/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js +++ b/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js @@ -21,32 +21,27 @@ let freshPlugin = require('react-refresh/babel'); describe('ReactFreshIntegration', () => { let container; - let lastRoot; - let scheduleHotUpdate; beforeEach(() => { - 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'); - ReactFreshRuntime = require('react-refresh/runtime'); - act = require('react-dom/test-utils').act; - container = document.createElement('div'); - document.body.appendChild(container); + if (__DEV__) { + jest.resetModules(); + React = require('react'); + ReactFreshRuntime = require('react-refresh/runtime'); + ReactFreshRuntime.injectIntoGlobalHook(global); + ReactDOM = require('react-dom'); + act = require('react-dom/test-utils').act; + container = document.createElement('div'); + document.body.appendChild(container); + } }); afterEach(() => { - document.body.removeChild(container); + if (__DEV__) { + ReactDOM.unmountComponentAtNode(container); + // Ensure we don't leak memory by holding onto dead roots. + expect(ReactFreshRuntime._getMountedRootCount()).toBe(0); + document.body.removeChild(container); + } }); describe('with compiled destructuring', () => { @@ -87,15 +82,15 @@ describe('ReactFreshIntegration', () => { ReactDOM.render(, container); }); // Module initialization shouldn't be counted as a hot update. - expect(ReactFreshRuntime.prepareUpdate()).toBe(null); + expect(ReactFreshRuntime.performReactRefresh()).toBe(false); } function patch(source) { execute(source); - const hotUpdate = ReactFreshRuntime.prepareUpdate(); act(() => { - scheduleHotUpdate(lastRoot, hotUpdate); + expect(ReactFreshRuntime.performReactRefresh()).toBe(true); }); + expect(ReactFreshRuntime._getMountedRootCount()).toBe(1); } function __register__(type, id) { @@ -103,29 +98,7 @@ describe('ReactFreshIntegration', () => { } 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; - }; + return ReactFreshRuntime.createSignatureFunctionForTransform(); } it('reloads function declarations', () => {