main
js 733 lines 22.7 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
11 import type {
12 Family,
13 RefreshUpdate,
14 ScheduleRefresh,
15 ScheduleRoot,
16 SetRefreshHandler,
17 } from 'react-reconciler/src/ReactFiberHotReloading';
18 import type {ReactNodeList} from 'shared/ReactTypes';
19
20 import {REACT_MEMO_TYPE, REACT_FORWARD_REF_TYPE} from 'shared/ReactSymbols';
21
22 type Signature = {
23 ownKey: string,
24 forceReset: boolean,
25 fullKey: string | null, // Contains keys of nested Hooks. Computed lazily.
26 getCustomHooks: () => Array<Function>,
27 };
28
29 type RendererHelpers = {
30 scheduleRefresh: ScheduleRefresh,
31 scheduleRoot: ScheduleRoot,
32 setRefreshHandler: SetRefreshHandler,
33 };
34
35 if (!__DEV__) {
36 throw new Error(
37 'React Refresh runtime should not be included in the production bundle.',
38 );
39 }
40
41 // In old environments, we'll leak previous types after every edit.
42 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
43
44 // We never remove these associations.
45 // It's OK to reference families, but use WeakMap/Set for types.
46 const allFamiliesByID: Map<string, Family> = new Map();
47 const allFamiliesByType: WeakMap<any, Family> | Map<any, Family> =
48 new PossiblyWeakMap();
49 const allSignaturesByType: WeakMap<any, Signature> | Map<any, Signature> =
50 new PossiblyWeakMap();
51 // This WeakMap is read by React, so we only put families
52 // that have actually been edited here. This keeps checks fast.
53 const updatedFamiliesByType: WeakMap<any, Family> | Map<any, Family> =
54 new PossiblyWeakMap();
55
56 // This is cleared on every performReactRefresh() call.
57 // It is an array of [Family, NextType] tuples.
58 let pendingUpdates: Array<[Family, any]> = [];
59
60 // This is injected by the renderer via DevTools global hook.
61 const helpersByRendererID: Map<number, RendererHelpers> = new Map();
62
63 const helpersByRoot: Map<FiberRoot, RendererHelpers> = new Map();
64
65 // We keep track of mounted roots so we can schedule updates.
66 const mountedRoots: Set<FiberRoot> = new Set();
67 // If a root captures an error, we remember it so we can retry on edit.
68 const failedRoots: Set<FiberRoot> = new Set();
69
70 // In environments that support WeakMap, we also remember the last element for every root.
71 // It needs to be weak because we do this even for roots that failed to mount.
72 // If there is no WeakMap, we won't attempt to do retrying.
73 const rootElements: WeakMap<any, ReactNodeList> | null =
74 typeof WeakMap === 'function' ? new WeakMap() : null;
75
76 let isPerformingRefresh = false;
77
78 function computeFullKey(signature: Signature): string {
79 if (signature.fullKey !== null) {
80 return signature.fullKey;
81 }
82
83 let fullKey: string = signature.ownKey;
84 let hooks;
85 try {
86 hooks = signature.getCustomHooks();
87 } catch (err) {
88 // This can happen in an edge case, e.g. if expression like Foo.useSomething
89 // depends on Foo which is lazily initialized during rendering.
90 // In that case just assume we'll have to remount.
91 signature.forceReset = true;
92 signature.fullKey = fullKey;
93 return fullKey;
94 }
95
96 for (let i = 0; i < hooks.length; i++) {
97 const hook = hooks[i];
98 if (typeof hook !== 'function') {
99 // Something's wrong. Assume we need to remount.
100 signature.forceReset = true;
101 signature.fullKey = fullKey;
102 return fullKey;
103 }
104 const nestedHookSignature = allSignaturesByType.get(hook);
105 if (nestedHookSignature === undefined) {
106 // No signature means Hook wasn't in the source code, e.g. in a library.
107 // We'll skip it because we can assume it won't change during this session.
108 continue;
109 }
110 const nestedHookKey = computeFullKey(nestedHookSignature);
111 if (nestedHookSignature.forceReset) {
112 signature.forceReset = true;
113 }
114 fullKey += '\n---\n' + nestedHookKey;
115 }
116
117 signature.fullKey = fullKey;
118 return fullKey;
119 }
120
121 function haveEqualSignatures(prevType: any, nextType: any) {
122 const prevSignature = allSignaturesByType.get(prevType);
123 const nextSignature = allSignaturesByType.get(nextType);
124
125 if (prevSignature === undefined && nextSignature === undefined) {
126 return true;
127 }
128 if (prevSignature === undefined || nextSignature === undefined) {
129 return false;
130 }
131 if (computeFullKey(prevSignature) !== computeFullKey(nextSignature)) {
132 return false;
133 }
134 if (nextSignature.forceReset) {
135 return false;
136 }
137
138 return true;
139 }
140
141 function isReactClass(type: any) {
142 return type.prototype && type.prototype.isReactComponent;
143 }
144
145 function canPreserveStateBetween(prevType: any, nextType: any) {
146 if (isReactClass(prevType) || isReactClass(nextType)) {
147 return false;
148 }
149 // A fiber's tag is derived from the kind of its type (a plain function
150 // vs memo vs forwardRef), and the reconciler can only swap implementations
151 // in place within the same tag. If the kind changed, the tree must remount.
152 if (typeof prevType !== typeof nextType) {
153 return false;
154 }
155 if (typeof prevType === 'object' && prevType !== null && nextType !== null) {
156 if (
157 getProperty(prevType, '$$typeof') !== getProperty(nextType, '$$typeof')
158 ) {
159 return false;
160 }
161 // Switching from SimpleMemoComponent to MemoComponent requires a remount;
162 // for symmetry, remount for the reverse too.
163 if (getProperty(prevType, '$$typeof') === REACT_MEMO_TYPE) {
164 if (
165 (getProperty(prevType, 'compare') === null) !==
166 (getProperty(nextType, 'compare') === null)
167 ) {
168 return false;
169 }
170 }
171 }
172 if (haveEqualSignatures(prevType, nextType)) {
173 return true;
174 }
175 return false;
176 }
177
178 function resolveFamily(type: any) {
179 // Only check updated types to keep lookups fast.
180 return updatedFamiliesByType.get(type);
181 }
182
183 // If we didn't care about IE11, we could use new Map/Set(iterable).
184 function cloneMap<K, V>(map: Map<K, V>): Map<K, V> {
185 const clone = new Map<K, V>();
186 map.forEach((value, key) => {
187 clone.set(key, value);
188 });
189 return clone;
190 }
191 function cloneSet<T>(set: Set<T>): Set<T> {
192 const clone = new Set<T>();
193 set.forEach(value => {
194 clone.add(value);
195 });
196 return clone;
197 }
198
199 // This is a safety mechanism to protect against rogue getters and Proxies.
200 function getProperty(object: any, property: string): any {
201 try {
202 return object[property];
203 } catch (err) {
204 // Intentionally ignore.
205 return undefined;
206 }
207 }
208
209 export function performReactRefresh(): RefreshUpdate | null {
210 if (!__DEV__) {
211 throw new Error(
212 'Unexpected call to React Refresh in a production environment.',
213 );
214 }
215 if (pendingUpdates.length === 0) {
216 return null;
217 }
218 if (isPerformingRefresh) {
219 return null;
220 }
221
222 isPerformingRefresh = true;
223 try {
224 const staleFamilies = new Set<Family>();
225 const updatedFamilies = new Set<Family>();
226
227 const updates = pendingUpdates;
228 pendingUpdates = [];
229 updates.forEach(([family, nextType]) => {
230 // Now that we got a real edit, we can create associations
231 // that will be read by the React reconciler.
232 const prevType = family.current;
233 updatedFamiliesByType.set(prevType, family);
234 updatedFamiliesByType.set(nextType, family);
235 family.current = nextType;
236
237 // Determine whether this should be a re-render or a re-mount.
238 if (canPreserveStateBetween(prevType, nextType)) {
239 updatedFamilies.add(family);
240 } else {
241 staleFamilies.add(family);
242 }
243 });
244
245 // TODO: rename these fields to something more meaningful.
246 const update: RefreshUpdate = {
247 updatedFamilies, // Families that will re-render preserving state
248 staleFamilies, // Families that will be remounted
249 };
250
251 helpersByRendererID.forEach(helpers => {
252 // Even if there are no roots, set the handler on first update.
253 // This ensures that if *new* roots are mounted, they'll use the resolve handler.
254 helpers.setRefreshHandler(resolveFamily);
255 });
256
257 let didError = false;
258 let firstError = null;
259
260 // We snapshot maps and sets that are mutated during commits.
261 // If we don't do this, there is a risk they will be mutated while
262 // we iterate over them. For example, trying to recover a failed root
263 // may cause another root to be added to the failed list -- an infinite loop.
264 const failedRootsSnapshot = cloneSet(failedRoots);
265 const mountedRootsSnapshot = cloneSet(mountedRoots);
266 const helpersByRootSnapshot = cloneMap(helpersByRoot);
267
268 failedRootsSnapshot.forEach(root => {
269 const helpers = helpersByRootSnapshot.get(root);
270 if (helpers === undefined) {
271 throw new Error(
272 'Could not find helpers for a root. This is a bug in React Refresh.',
273 );
274 }
275 if (!failedRoots.has(root)) {
276 // No longer failed.
277 }
278 if (rootElements === null) {
279 return;
280 }
281 if (!rootElements.has(root)) {
282 return;
283 }
284 const element = rootElements.get(root);
285 try {
286 helpers.scheduleRoot(root, element);
287 } catch (err) {
288 if (!didError) {
289 didError = true;
290 firstError = err;
291 }
292 // Keep trying other roots.
293 }
294 });
295 mountedRootsSnapshot.forEach(root => {
296 const helpers = helpersByRootSnapshot.get(root);
297 if (helpers === undefined) {
298 throw new Error(
299 'Could not find helpers for a root. This is a bug in React Refresh.',
300 );
301 }
302 if (!mountedRoots.has(root)) {
303 // No longer mounted.
304 }
305 try {
306 helpers.scheduleRefresh(root, update);
307 } catch (err) {
308 if (!didError) {
309 didError = true;
310 firstError = err;
311 }
312 // Keep trying other roots.
313 }
314 });
315 if (didError) {
316 throw firstError;
317 }
318 return update;
319 } finally {
320 isPerformingRefresh = false;
321 }
322 }
323
324 export function register(type: any, id: string): void {
325 if (__DEV__) {
326 if (type === null) {
327 return;
328 }
329 if (typeof type !== 'function' && typeof type !== 'object') {
330 return;
331 }
332
333 // This can happen in an edge case, e.g. if we register
334 // return value of a HOC but it returns a cached component.
335 // Ignore anything but the first registration for each type.
336 if (allFamiliesByType.has(type)) {
337 return;
338 }
339 // Create family or remember to update it.
340 // None of this bookkeeping affects reconciliation
341 // until the first performReactRefresh() call above.
342 let family = allFamiliesByID.get(id);
343 if (family === undefined) {
344 family = {current: type};
345 allFamiliesByID.set(id, family);
346 } else {
347 pendingUpdates.push([family, type]);
348 }
349 allFamiliesByType.set(type, family);
350
351 // Visit inner types because we might not have registered them.
352 if (typeof type === 'object' && type !== null) {
353 switch (getProperty(type, '$$typeof')) {
354 case REACT_FORWARD_REF_TYPE:
355 register(type.render, id + '$render');
356 break;
357 case REACT_MEMO_TYPE:
358 register(type.type, id + '$type');
359 break;
360 }
361 }
362 } else {
363 throw new Error(
364 'Unexpected call to React Refresh in a production environment.',
365 );
366 }
367 }
368
369 export function setSignature(
370 type: any,
371 key: string,
372 forceReset?: boolean = false,
373 getCustomHooks?: () => Array<Function>,
374 ): void {
375 if (__DEV__) {
376 if (!allSignaturesByType.has(type)) {
377 allSignaturesByType.set(type, {
378 forceReset,
379 ownKey: key,
380 fullKey: null,
381 getCustomHooks: getCustomHooks || (() => []),
382 });
383 }
384 // Visit inner types because we might not have signed them.
385 if (typeof type === 'object' && type !== null) {
386 switch (getProperty(type, '$$typeof')) {
387 case REACT_FORWARD_REF_TYPE:
388 setSignature(type.render, key, forceReset, getCustomHooks);
389 break;
390 case REACT_MEMO_TYPE:
391 setSignature(type.type, key, forceReset, getCustomHooks);
392 break;
393 }
394 }
395 } else {
396 throw new Error(
397 'Unexpected call to React Refresh in a production environment.',
398 );
399 }
400 }
401
402 // This is lazily called during first render for a type.
403 // It captures Hook list at that time so inline requires don't break comparisons.
404 export function collectCustomHooksForSignature(type: any) {
405 if (__DEV__) {
406 const signature = allSignaturesByType.get(type);
407 if (signature !== undefined) {
408 computeFullKey(signature);
409 }
410 } else {
411 throw new Error(
412 'Unexpected call to React Refresh in a production environment.',
413 );
414 }
415 }
416
417 export function getFamilyByID(id: string): Family | void {
418 if (__DEV__) {
419 return allFamiliesByID.get(id);
420 } else {
421 throw new Error(
422 'Unexpected call to React Refresh in a production environment.',
423 );
424 }
425 }
426
427 export function getFamilyByType(type: any): Family | void {
428 if (__DEV__) {
429 return allFamiliesByType.get(type);
430 } else {
431 throw new Error(
432 'Unexpected call to React Refresh in a production environment.',
433 );
434 }
435 }
436
437 export function injectIntoGlobalHook(globalObject: any): void {
438 if (__DEV__) {
439 // For React Native, the global hook will be set up by require('react-devtools-core').
440 // That code will run before us. So we need to monkeypatch functions on existing hook.
441
442 // For React Web, the global hook will be set up by the extension.
443 // This will also run before us.
444 let hook = globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__;
445 if (hook === undefined) {
446 // However, if there is no DevTools extension, we'll need to set up the global hook ourselves.
447 // Note that in this case it's important that renderer code runs *after* this method call.
448 // Otherwise, the renderer will think that there is no global hook, and won't do the injection.
449 let nextID = 0;
450 globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook = {
451 renderers: new Map(),
452 supportsFiber: true,
453 inject: injected => nextID++,
454 onScheduleFiberRoot: (
455 id: number,
456 root: FiberRoot,
457 children: ReactNodeList,
458 ) => {},
459 onCommitFiberRoot: (
460 id: number,
461 root: FiberRoot,
462 maybePriorityLevel: mixed,
463 didError: boolean,
464 ) => {},
465 onCommitFiberUnmount() {},
466 };
467 }
468
469 if (hook.isDisabled) {
470 // This isn't a real property on the hook, but it can be set to opt out
471 // of DevTools integration and associated warnings and logs.
472 // Using console['warn'] to evade Babel and ESLint
473 console['warn'](
474 'Something has shimmed the React DevTools global hook (__REACT_DEVTOOLS_GLOBAL_HOOK__). ' +
475 'Fast Refresh is not compatible with this shim and will be disabled.',
476 );
477 return;
478 }
479
480 // Here, we just want to get a reference to scheduleRefresh.
481 const oldInject = hook.inject;
482 hook.inject = function (this: mixed, injected) {
483 const id = oldInject.apply(this, arguments);
484 if (
485 typeof injected.scheduleRefresh === 'function' &&
486 typeof injected.setRefreshHandler === 'function'
487 ) {
488 // This version supports React Refresh.
489 helpersByRendererID.set(id, injected as any as RendererHelpers);
490 }
491 return id;
492 };
493
494 // Do the same for any already injected roots.
495 // This is useful if ReactDOM has already been initialized.
496 // https://github.com/facebook/react/issues/17626
497 hook.renderers.forEach((injected, id) => {
498 if (
499 typeof injected.scheduleRefresh === 'function' &&
500 typeof injected.setRefreshHandler === 'function'
501 ) {
502 // This version supports React Refresh.
503 helpersByRendererID.set(id, injected as any as RendererHelpers);
504 }
505 });
506
507 // We also want to track currently mounted roots.
508 const oldOnCommitFiberRoot = hook.onCommitFiberRoot;
509 const oldOnScheduleFiberRoot = hook.onScheduleFiberRoot || (() => {});
510 hook.onScheduleFiberRoot = function (
511 this: mixed,
512 id: number,
513 root: FiberRoot,
514 children: ReactNodeList,
515 ) {
516 if (!isPerformingRefresh) {
517 // If it was intentionally scheduled, don't attempt to restore.
518 // This includes intentionally scheduled unmounts.
519 failedRoots.delete(root);
520 if (rootElements !== null) {
521 rootElements.set(root, children);
522 }
523 }
524 return oldOnScheduleFiberRoot.apply(this, arguments);
525 };
526 hook.onCommitFiberRoot = function (
527 this: mixed,
528 id: number,
529 root: FiberRoot,
530 maybePriorityLevel: mixed,
531 didError: boolean,
532 ) {
533 const helpers = helpersByRendererID.get(id);
534 if (helpers !== undefined) {
535 helpersByRoot.set(root, helpers);
536
537 const current = root.current;
538 const alternate = current.alternate;
539
540 // We need to determine whether this root has just (un)mounted.
541 // This logic is copy-pasted from similar logic in the DevTools backend.
542 // If this breaks with some refactoring, you'll want to update DevTools too.
543
544 if (alternate !== null) {
545 const wasMounted =
546 alternate.memoizedState != null &&
547 alternate.memoizedState.element != null &&
548 mountedRoots.has(root);
549
550 const isMounted =
551 current.memoizedState != null &&
552 current.memoizedState.element != null;
553
554 if (!wasMounted && isMounted) {
555 // Mount a new root.
556 mountedRoots.add(root);
557 failedRoots.delete(root);
558 } else if (wasMounted && isMounted) {
559 // Update an existing root.
560 // This doesn't affect our mounted root Set.
561 } else if (wasMounted && !isMounted) {
562 // Unmount an existing root.
563 mountedRoots.delete(root);
564 if (didError) {
565 // We'll remount it on future edits.
566 failedRoots.add(root);
567 } else {
568 helpersByRoot.delete(root);
569 }
570 } else if (!wasMounted && !isMounted) {
571 if (didError) {
572 // We'll remount it on future edits.
573 failedRoots.add(root);
574 }
575 }
576 } else {
577 // Mount a new root.
578 mountedRoots.add(root);
579 }
580 }
581
582 // Always call the decorated DevTools hook.
583 return oldOnCommitFiberRoot.apply(this, arguments);
584 };
585 } else {
586 throw new Error(
587 'Unexpected call to React Refresh in a production environment.',
588 );
589 }
590 }
591
592 export function hasUnrecoverableErrors(): boolean {
593 // TODO: delete this after removing dependency in RN.
594 return false;
595 }
596
597 // Exposed for testing.
598 export function _getMountedRootCount(): number {
599 if (__DEV__) {
600 return mountedRoots.size;
601 } else {
602 throw new Error(
603 'Unexpected call to React Refresh in a production environment.',
604 );
605 }
606 }
607
608 // This is a wrapper over more primitive functions for setting signature.
609 // Signatures let us decide whether the Hook order has changed on refresh.
610 //
611 // This function is intended to be used as a transform target, e.g.:
612 // var _s = createSignatureFunctionForTransform()
613 //
614 // function Hello() {
615 // const [foo, setFoo] = useState(0);
616 // const value = useCustomHook();
617 // _s(); /* Call without arguments triggers collecting the custom Hook list.
618 // * This doesn't happen during the module evaluation because we
619 // * don't want to change the module order with inline requires.
620 // * Next calls are noops. */
621 // return <h1>Hi</h1>;
622 // }
623 //
624 // /* Call with arguments attaches the signature to the type: */
625 // _s(
626 // Hello,
627 // 'useState{[foo, setFoo]}(0)',
628 // () => [useCustomHook], /* Lazy to avoid triggering inline requires */
629 // );
630 export function createSignatureFunctionForTransform(): <T>(
631 type: T,
632 key: string,
633 forceReset?: boolean,
634 getCustomHooks?: () => Array<Function>,
635 ) => T | void {
636 if (__DEV__) {
637 let savedType: mixed;
638 let hasCustomHooks: boolean;
639 let didCollectHooks = false;
640 return function <T>(
641 type: T,
642 key: string,
643 forceReset?: boolean,
644 getCustomHooks?: () => Array<Function>,
645 ): T | void {
646 if (typeof key === 'string') {
647 // We're in the initial phase that associates signatures
648 // with the functions. Note this may be called multiple times
649 // in HOC chains like _s(hoc1(_s(hoc2(_s(actualFunction))))).
650 if (!savedType) {
651 // We're in the innermost call, so this is the actual type.
652 savedType = type;
653 hasCustomHooks = typeof getCustomHooks === 'function';
654 }
655 // Set the signature for all types (even wrappers!) in case
656 // they have no signatures of their own. This is to prevent
657 // problems like https://github.com/facebook/react/issues/20417.
658 if (
659 type != null &&
660 (typeof type === 'function' || typeof type === 'object')
661 ) {
662 setSignature(type, key, forceReset, getCustomHooks);
663 }
664 return type;
665 } else {
666 // We're in the _s() call without arguments, which means
667 // this is the time to collect custom Hook signatures.
668 // Only do this once. This path is hot and runs *inside* every render!
669 if (!didCollectHooks && hasCustomHooks) {
670 didCollectHooks = true;
671 collectCustomHooksForSignature(savedType);
672 }
673 }
674 };
675 } else {
676 throw new Error(
677 'Unexpected call to React Refresh in a production environment.',
678 );
679 }
680 }
681
682 export function isLikelyComponentType(type: any): boolean {
683 if (__DEV__) {
684 switch (typeof type) {
685 case 'function': {
686 // First, deal with classes.
687 if (type.prototype != null) {
688 if (type.prototype.isReactComponent) {
689 // React class.
690 return true;
691 }
692 const ownNames = Object.getOwnPropertyNames(type.prototype);
693 if (ownNames.length > 1 || ownNames[0] !== 'constructor') {
694 // This looks like a class.
695 return false;
696 }
697 if (
698 // $FlowFixMe[prop-missing]
699 type.prototype.__proto__ !== Object.prototype // eslint-disable-line no-proto
700 ) {
701 // It has a superclass.
702 return false;
703 }
704 // Pass through.
705 // This looks like a regular function with empty prototype.
706 }
707 // For plain functions and arrows, use name as a heuristic.
708 const name = type.name || type.displayName;
709 return typeof name === 'string' && /^[A-Z]/.test(name);
710 }
711 case 'object': {
712 if (type != null) {
713 switch (getProperty(type, '$$typeof')) {
714 case REACT_FORWARD_REF_TYPE:
715 case REACT_MEMO_TYPE:
716 // Definitely React components.
717 return true;
718 default:
719 return false;
720 }
721 }
722 return false;
723 }
724 default: {
725 return false;
726 }
727 }
728 } else {
729 throw new Error(
730 'Unexpected call to React Refresh in a production environment.',
731 );
732 }
733 }