main
js 655 lines 17 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 {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
11 import type {
12 Container,
13 PublicInstance,
14 Instance,
15 TextInstance,
16 } from './ReactFiberConfigTestHost';
17
18 import * as React from 'react';
19 import * as Scheduler from 'scheduler/unstable_mock';
20 import {
21 getPublicRootInstance,
22 createContainer,
23 updateContainer,
24 flushSyncFromReconciler,
25 injectIntoDevTools,
26 batchedUpdates,
27 defaultOnUncaughtError,
28 defaultOnCaughtError,
29 defaultOnRecoverableError,
30 } from 'react-reconciler/src/ReactFiberReconciler';
31 import {findCurrentFiberUsingSlowPath} from 'react-reconciler/src/ReactFiberTreeReflection';
32 import {
33 Fragment,
34 FunctionComponent,
35 ClassComponent,
36 HostComponent,
37 HostHoistable,
38 HostSingleton,
39 HostPortal,
40 HostText,
41 HostRoot,
42 ContextConsumer,
43 ContextProvider,
44 Mode,
45 ForwardRef,
46 Profiler,
47 MemoComponent,
48 SimpleMemoComponent,
49 IncompleteClassComponent,
50 ScopeComponent,
51 } from 'react-reconciler/src/ReactWorkTags';
52 import isArray from 'shared/isArray';
53 import getComponentNameFromType from 'shared/getComponentNameFromType';
54 import ReactVersion from 'shared/ReactVersion';
55 import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
56
57 import {getPublicInstance} from './ReactFiberConfigTestHost';
58 import {ConcurrentRoot, LegacyRoot} from 'react-reconciler/src/ReactRootTags';
59 import {
60 enableReactTestRendererWarning,
61 disableLegacyMode,
62 } from 'shared/ReactFeatureFlags';
63
64 import noop from 'shared/noop';
65 import type {WorkTag} from 'react-reconciler/src/ReactWorkTags';
66
67 const defaultOnDefaultTransitionIndicator: () => void | (() => void) = noop;
68
69 // $FlowFixMe[prop-missing]: This is only in the development export.
70 // $FlowFixMe[missing-export]
71 const act = React.act;
72
73 // TODO: Remove from public bundle
74
75 type TestRendererOptions = {
76 createNodeMock: (element: React$Element<any>) => any,
77 unstable_isConcurrent: boolean,
78 unstable_strictMode: boolean,
79 ...
80 };
81
82 type ReactTestRendererJSON = {
83 type: string,
84 props: {[propName: string]: any, ...},
85 children: null | Array<ReactTestRendererNode>,
86 $$typeof?: symbol, // Optional because we add it with defineProperty().
87 };
88 type ReactTestRendererNode = ReactTestRendererJSON | string;
89
90 type FindOptions = {
91 // performs a "greedy" search: if a matching node is found, will continue
92 // to search within the matching node's children. (default: true)
93 deep?: boolean,
94 };
95
96 export type Predicate = (node: ReactTestInstance) => ?boolean;
97
98 const defaultTestOptions = {
99 createNodeMock: function () {
100 return null;
101 },
102 };
103
104 function toJSON(inst: Instance | TextInstance): ReactTestRendererNode | null {
105 if (inst.isHidden) {
106 // Omit timed out children from output entirely. This seems like the least
107 // surprising behavior. We could perhaps add a separate API that includes
108 // them, if it turns out people need it.
109 return null;
110 }
111 switch (inst.tag) {
112 case 'TEXT':
113 return inst.text;
114 case 'INSTANCE': {
115 // We don't include the `children` prop in JSON.
116 // Instead, we will include the actual rendered children.
117 const {children, ...props} = inst.props;
118 let renderedChildren = null;
119 if (inst.children && inst.children.length) {
120 for (let i = 0; i < inst.children.length; i++) {
121 const renderedChild = toJSON(inst.children[i]);
122 if (renderedChild !== null) {
123 if (renderedChildren === null) {
124 renderedChildren = [renderedChild];
125 } else {
126 renderedChildren.push(renderedChild);
127 }
128 }
129 }
130 }
131 const json: ReactTestRendererJSON = {
132 type: inst.type,
133 props: props,
134 children: renderedChildren,
135 };
136 Object.defineProperty(json, '$$typeof', {
137 value: Symbol.for('react.test.json'),
138 });
139 return json;
140 }
141 default:
142 throw new Error(`Unexpected node type in toJSON: ${inst.tag}`);
143 }
144 }
145
146 function childrenToTree(node: null | Fiber) {
147 if (!node) {
148 return null;
149 }
150 const children = nodeAndSiblingsArray(node);
151 if (children.length === 0) {
152 return null;
153 } else if (children.length === 1) {
154 return toTree(children[0]);
155 }
156 return flatten(children.map(toTree));
157 }
158
159 // $FlowFixMe[missing-local-annot]
160 function nodeAndSiblingsArray(nodeWithSibling) {
161 const array = [];
162 let node = nodeWithSibling;
163 while (node != null) {
164 array.push(node);
165 node = node.sibling;
166 }
167 return array;
168 }
169
170 // $FlowFixMe[missing-local-annot]
171 function flatten(arr) {
172 const result = [];
173 const stack = [{i: 0, array: arr}];
174 while (stack.length) {
175 const n = stack.pop();
176 // $FlowFixMe[incompatible-use]
177 while (n.i < n.array.length) {
178 // $FlowFixMe[incompatible-use]
179 const el = n.array[n.i];
180 // $FlowFixMe[incompatible-use]
181 n.i += 1;
182 if (isArray(el)) {
183 // $FlowFixMe[incompatible-type]
184 stack.push(n);
185 stack.push({i: 0, array: el});
186 break;
187 }
188 result.push(el);
189 }
190 }
191 return result;
192 }
193
194 function toTree(node: null | Fiber): $FlowFixMe {
195 if (node == null) {
196 return null;
197 }
198 switch (node.tag) {
199 case HostRoot:
200 return childrenToTree(node.child);
201 case HostPortal:
202 return childrenToTree(node.child);
203 case ClassComponent:
204 return {
205 nodeType: 'component',
206 type: node.type,
207 props: {...node.memoizedProps},
208 instance: node.stateNode,
209 rendered: childrenToTree(node.child),
210 };
211 case FunctionComponent:
212 case SimpleMemoComponent:
213 return {
214 nodeType: 'component',
215 type: node.type,
216 props: {...node.memoizedProps},
217 instance: null,
218 rendered: childrenToTree(node.child),
219 };
220 case HostHoistable:
221 case HostSingleton:
222 case HostComponent: {
223 return {
224 nodeType: 'host',
225 type: node.type,
226 props: {...node.memoizedProps},
227 instance: null, // TODO: use createNodeMock here somehow?
228 rendered: flatten(nodeAndSiblingsArray(node.child).map(toTree)),
229 };
230 }
231 case HostText:
232 return node.stateNode.text;
233 case Fragment:
234 case ContextProvider:
235 case ContextConsumer:
236 case Mode:
237 case Profiler:
238 case ForwardRef:
239 case MemoComponent:
240 case IncompleteClassComponent:
241 case ScopeComponent:
242 return childrenToTree(node.child);
243 default:
244 throw new Error(
245 `toTree() does not yet know how to handle nodes with tag=${node.tag}`,
246 );
247 }
248 }
249
250 const validWrapperTypes: Set<WorkTag> = new Set([
251 FunctionComponent,
252 ClassComponent,
253 HostComponent,
254 ForwardRef,
255 MemoComponent,
256 SimpleMemoComponent,
257 // Normally skipped, but used when there's more than one root child.
258 HostRoot,
259 ]);
260
261 function getChildren(parent: Fiber) {
262 const children = [];
263 const startingNode = parent;
264 let node: Fiber = startingNode;
265 if (node.child === null) {
266 return children;
267 }
268 node.child.return = node;
269 node = node.child;
270 outer: while (true) {
271 let descend = false;
272 if (validWrapperTypes.has(node.tag)) {
273 children.push(wrapFiber(node));
274 } else if (node.tag === HostText) {
275 if (__DEV__) {
276 checkPropStringCoercion(node.memoizedProps, 'memoizedProps');
277 }
278 children.push('' + node.memoizedProps);
279 } else {
280 descend = true;
281 }
282 if (descend && node.child !== null) {
283 node.child.return = node;
284 node = node.child;
285 continue;
286 }
287 while (node.sibling === null) {
288 if (node.return === startingNode) {
289 break outer;
290 }
291 node = node.return as any;
292 }
293 (node.sibling as any).return = node.return;
294 node = node.sibling as any;
295 }
296 return children;
297 }
298
299 class ReactTestInstance {
300 _fiber: Fiber;
301
302 _currentFiber(): Fiber {
303 // Throws if this component has been unmounted.
304 const fiber = findCurrentFiberUsingSlowPath(this._fiber);
305
306 if (fiber === null) {
307 throw new Error(
308 "Can't read from currently-mounting component. This error is likely " +
309 'caused by a bug in React. Please file an issue.',
310 );
311 }
312
313 return fiber;
314 }
315
316 constructor(fiber: Fiber) {
317 if (!validWrapperTypes.has(fiber.tag)) {
318 throw new Error(
319 `Unexpected object passed to ReactTestInstance constructor (tag: ${fiber.tag}). ` +
320 'This is probably a bug in React.',
321 );
322 }
323
324 this._fiber = fiber;
325 }
326
327 get instance(): $FlowFixMe {
328 const tag = this._fiber.tag;
329 if (
330 tag === HostComponent ||
331 tag === HostHoistable ||
332 tag === HostSingleton
333 ) {
334 return getPublicInstance(this._fiber.stateNode);
335 } else {
336 return this._fiber.stateNode;
337 }
338 }
339
340 get type(): any {
341 return this._fiber.type;
342 }
343
344 get props(): Object {
345 return this._currentFiber().memoizedProps;
346 }
347
348 get parent(): ?ReactTestInstance {
349 let parent = this._fiber.return;
350 while (parent !== null) {
351 if (validWrapperTypes.has(parent.tag)) {
352 if (parent.tag === HostRoot) {
353 // Special case: we only "materialize" instances for roots
354 // if they have more than a single child. So we'll check that now.
355 if (getChildren(parent).length < 2) {
356 return null;
357 }
358 }
359 return wrapFiber(parent);
360 }
361 parent = parent.return;
362 }
363 return null;
364 }
365
366 get children(): Array<ReactTestInstance | string> {
367 return getChildren(this._currentFiber());
368 }
369
370 // Custom search functions
371 find(predicate: Predicate): ReactTestInstance {
372 return expectOne(
373 this.findAll(predicate, {deep: false}),
374 `matching custom predicate: ${predicate.toString()}`,
375 );
376 }
377
378 findByType(type: any): ReactTestInstance {
379 return expectOne(
380 this.findAllByType(type, {deep: false}),
381 `with node type: "${getComponentNameFromType(type) || 'Unknown'}"`,
382 );
383 }
384
385 findByProps(props: Object): ReactTestInstance {
386 return expectOne(
387 this.findAllByProps(props, {deep: false}),
388 `with props: ${JSON.stringify(props)}`,
389 );
390 }
391
392 findAll(
393 predicate: Predicate,
394 options: ?FindOptions = null,
395 ): Array<ReactTestInstance> {
396 return findAll(this, predicate, options);
397 }
398
399 findAllByType(
400 type: any,
401 options: ?FindOptions = null,
402 ): Array<ReactTestInstance> {
403 return findAll(this, node => node.type === type, options);
404 }
405
406 findAllByProps(
407 props: Object,
408 options: ?FindOptions = null,
409 ): Array<ReactTestInstance> {
410 return findAll(
411 this,
412 node => node.props && propsMatch(node.props, props),
413 options,
414 );
415 }
416 }
417
418 function findAll(
419 root: ReactTestInstance,
420 predicate: Predicate,
421 options: ?FindOptions,
422 ): Array<ReactTestInstance> {
423 const deep = options ? options.deep : true;
424 const results = [];
425
426 if (predicate(root)) {
427 results.push(root);
428 if (!deep) {
429 return results;
430 }
431 }
432
433 root.children.forEach(child => {
434 if (typeof child === 'string') {
435 return;
436 }
437 results.push(...findAll(child, predicate, options));
438 });
439
440 return results;
441 }
442
443 function expectOne(
444 all: Array<ReactTestInstance>,
445 message: string,
446 ): ReactTestInstance {
447 if (all.length === 1) {
448 return all[0];
449 }
450
451 const prefix =
452 all.length === 0
453 ? 'No instances found '
454 : `Expected 1 but found ${all.length} instances `;
455
456 throw new Error(prefix + message);
457 }
458
459 function propsMatch(props: Object, filter: Object): boolean {
460 for (const key in filter) {
461 if (props[key] !== filter[key]) {
462 return false;
463 }
464 }
465 return true;
466 }
467
468 function create(
469 element: React$Element<any>,
470 options: TestRendererOptions,
471 ): {
472 _Scheduler: typeof Scheduler,
473 root: void,
474 toJSON(): Array<ReactTestRendererNode> | ReactTestRendererNode | null,
475 toTree(): mixed,
476 update(newElement: React$Element<any>): any,
477 unmount(): void,
478 getInstance(): component(...props: any) | PublicInstance | null,
479 unstable_flushSync: typeof flushSyncFromReconciler,
480 } {
481 if (__DEV__) {
482 if (
483 enableReactTestRendererWarning === true &&
484 global.IS_REACT_NATIVE_TEST_ENVIRONMENT !== true
485 ) {
486 console.error(
487 'react-test-renderer is deprecated. See https://react.dev/warnings/react-test-renderer',
488 );
489 }
490 }
491
492 let createNodeMock = defaultTestOptions.createNodeMock;
493 const isConcurrentOnly =
494 disableLegacyMode === true &&
495 global.IS_REACT_NATIVE_TEST_ENVIRONMENT !== true;
496 let isConcurrent = isConcurrentOnly;
497 let isStrictMode = false;
498 // $FlowFixMe[invalid-compare]
499 if (typeof options === 'object' && options !== null) {
500 if (typeof options.createNodeMock === 'function') {
501 // $FlowFixMe[incompatible-type] found when upgrading Flow
502 createNodeMock = options.createNodeMock;
503 }
504 if (isConcurrentOnly === false) {
505 isConcurrent = options.unstable_isConcurrent;
506 }
507 if (options.unstable_strictMode === true) {
508 isStrictMode = true;
509 }
510 }
511 let container: Container = {
512 children: [] as Array<Instance | TextInstance>,
513 createNodeMock,
514 tag: 'CONTAINER',
515 };
516 let root: FiberRoot | null = createContainer(
517 container,
518 isConcurrent ? ConcurrentRoot : LegacyRoot,
519 null,
520 isStrictMode,
521 false,
522 '',
523 defaultOnUncaughtError,
524 defaultOnCaughtError,
525 defaultOnRecoverableError,
526 defaultOnDefaultTransitionIndicator,
527 null,
528 );
529
530 if (root == null) {
531 throw new Error('something went wrong');
532 }
533
534 // $FlowFixMe[incompatible-type]
535 updateContainer(element, root, null, null);
536
537 const entry = {
538 _Scheduler: Scheduler,
539
540 root: undefined, // makes flow happy
541 // we define a 'getter' for 'root' below using 'Object.defineProperty'
542 toJSON(): Array<ReactTestRendererNode> | ReactTestRendererNode | null {
543 if (root == null || root.current == null || container == null) {
544 return null;
545 }
546 if (container.children.length === 0) {
547 return null;
548 }
549 if (container.children.length === 1) {
550 return toJSON(container.children[0]);
551 }
552 if (
553 container.children.length === 2 &&
554 container.children[0].isHidden === true &&
555 container.children[1].isHidden === false
556 ) {
557 // Omit timed out children from output entirely, including the fact that we
558 // temporarily wrap fallback and timed out children in an array.
559 return toJSON(container.children[1]);
560 }
561 let renderedChildren = null;
562 if (container.children && container.children.length) {
563 for (let i = 0; i < container.children.length; i++) {
564 const renderedChild = toJSON(container.children[i]);
565 if (renderedChild !== null) {
566 if (renderedChildren === null) {
567 renderedChildren = [renderedChild];
568 } else {
569 renderedChildren.push(renderedChild);
570 }
571 }
572 }
573 }
574 return renderedChildren;
575 },
576 toTree() {
577 if (root == null || root.current == null) {
578 return null;
579 }
580 return toTree(root.current);
581 },
582 update(newElement: React$Element<any>): number | void {
583 if (root == null || root.current == null) {
584 return;
585 }
586 // $FlowFixMe[incompatible-type]
587 updateContainer(newElement, root, null, null);
588 },
589 unmount() {
590 if (root == null || root.current == null) {
591 return;
592 }
593 updateContainer(null, root, null, null);
594 // $FlowFixMe[incompatible-type] found when upgrading Flow
595 container = null;
596 root = null;
597 },
598 getInstance() {
599 if (root == null || root.current == null) {
600 return null;
601 }
602 return getPublicRootInstance(root);
603 },
604
605 unstable_flushSync: flushSyncFromReconciler,
606 };
607
608 Object.defineProperty(entry, 'root', {
609 configurable: true,
610 enumerable: true,
611 get: function () {
612 if (root === null) {
613 throw new Error("Can't access .root on unmounted test renderer");
614 }
615 const children = getChildren(root.current);
616 if (children.length === 0) {
617 throw new Error("Can't access .root on unmounted test renderer");
618 } else if (children.length === 1) {
619 // Normally, we skip the root and just give you the child.
620 return children[0];
621 } else {
622 // However, we give you the root if there's more than one root child.
623 // We could make this the behavior for all cases but it would be a breaking change.
624 // $FlowFixMe[incompatible-use] found when upgrading Flow
625 return wrapFiber(root.current);
626 }
627 },
628 } as Object);
629
630 return entry;
631 }
632
633 const fiberToWrapper = new WeakMap<Fiber, ReactTestInstance>();
634 function wrapFiber(fiber: Fiber): ReactTestInstance {
635 let wrapper = fiberToWrapper.get(fiber);
636 if (wrapper === undefined && fiber.alternate !== null) {
637 wrapper = fiberToWrapper.get(fiber.alternate);
638 }
639 if (wrapper === undefined) {
640 wrapper = new ReactTestInstance(fiber);
641 fiberToWrapper.set(fiber, wrapper);
642 }
643 return wrapper;
644 }
645
646 // Enable ReactTestRenderer to be used to test DevTools integration.
647 injectIntoDevTools();
648
649 export {
650 Scheduler as _Scheduler,
651 create,
652 batchedUpdates as unstable_batchedUpdates,
653 act,
654 ReactVersion as version,
655 };