main
js 617 lines 15.8 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} from 'react-reconciler/src/ReactInternalTypes';
11 import type {Instance} from './ReactFiberConfig';
12
13 import {
14 HostComponent,
15 HostHoistable,
16 HostSingleton,
17 HostText,
18 } from 'react-reconciler/src/ReactWorkTags';
19 import getComponentNameFromType from 'shared/getComponentNameFromType';
20 import {
21 findFiberRoot,
22 getBoundingRect,
23 getInstanceFromNode,
24 getTextContent,
25 isHiddenSubtree,
26 matchAccessibilityRole,
27 setFocusIfFocusable,
28 setupIntersectionObserver,
29 supportsTestSelectors,
30 } from './ReactFiberConfig';
31
32 let COMPONENT_TYPE: symbol | number = 0b000;
33 let HAS_PSEUDO_CLASS_TYPE: symbol | number = 0b001;
34 let ROLE_TYPE: symbol | number = 0b010;
35 let TEST_NAME_TYPE: symbol | number = 0b011;
36 let TEXT_TYPE: symbol | number = 0b100;
37
38 if (typeof Symbol === 'function' && Symbol.for) {
39 const symbolFor = Symbol.for;
40 COMPONENT_TYPE = symbolFor('selector.component');
41 HAS_PSEUDO_CLASS_TYPE = symbolFor('selector.has_pseudo_class');
42 ROLE_TYPE = symbolFor('selector.role');
43 TEST_NAME_TYPE = symbolFor('selector.test_id');
44 TEXT_TYPE = symbolFor('selector.text');
45 }
46
47 type Type = symbol | number;
48
49 type ComponentSelector = {
50 $$typeof: Type,
51 value: component(),
52 };
53
54 type HasPseudoClassSelector = {
55 $$typeof: Type,
56 value: Array<Selector>,
57 };
58
59 type RoleSelector = {
60 $$typeof: Type,
61 value: string,
62 };
63
64 type TextSelector = {
65 $$typeof: Type,
66 value: string,
67 };
68
69 type TestNameSelector = {
70 $$typeof: Type,
71 value: string,
72 };
73
74 type Selector =
75 | ComponentSelector
76 | HasPseudoClassSelector
77 | RoleSelector
78 | TextSelector
79 | TestNameSelector;
80
81 export function createComponentSelector(
82 component: component(),
83 ): ComponentSelector {
84 return {
85 $$typeof: COMPONENT_TYPE,
86 value: component,
87 };
88 }
89
90 export function createHasPseudoClassSelector(
91 selectors: Array<Selector>,
92 ): HasPseudoClassSelector {
93 return {
94 $$typeof: HAS_PSEUDO_CLASS_TYPE,
95 value: selectors,
96 };
97 }
98
99 export function createRoleSelector(role: string): RoleSelector {
100 return {
101 $$typeof: ROLE_TYPE,
102 value: role,
103 };
104 }
105
106 export function createTextSelector(text: string): TextSelector {
107 return {
108 $$typeof: TEXT_TYPE,
109 value: text,
110 };
111 }
112
113 export function createTestNameSelector(id: string): TestNameSelector {
114 return {
115 $$typeof: TEST_NAME_TYPE,
116 value: id,
117 };
118 }
119
120 function findFiberRootForHostRoot(hostRoot: Instance): Fiber {
121 const maybeFiber = getInstanceFromNode(hostRoot as any);
122 if (maybeFiber != null) {
123 if (typeof maybeFiber.memoizedProps['data-testname'] !== 'string') {
124 throw new Error(
125 'Invalid host root specified. Should be either a React container or a node with a testname attribute.',
126 );
127 }
128
129 return maybeFiber as any as Fiber;
130 } else {
131 const fiberRoot = findFiberRoot(hostRoot);
132
133 // $FlowFixMe[invalid-compare]
134 if (fiberRoot === null) {
135 throw new Error(
136 'Could not find React container within specified host subtree.',
137 );
138 }
139
140 // The Flow type for FiberRoot is a little funky.
141 // createFiberRoot() cheats this by treating the root as :any and adding stateNode lazily.
142 return (fiberRoot as any).stateNode.current as Fiber;
143 }
144 }
145
146 function matchSelector(fiber: Fiber, selector: Selector): boolean {
147 const tag = fiber.tag;
148 switch (selector.$$typeof) {
149 case COMPONENT_TYPE:
150 if (fiber.type === selector.value) {
151 return true;
152 }
153 break;
154 case HAS_PSEUDO_CLASS_TYPE:
155 return hasMatchingPaths(
156 fiber,
157 (selector as any as HasPseudoClassSelector).value,
158 );
159 case ROLE_TYPE:
160 if (
161 tag === HostComponent ||
162 tag === HostHoistable ||
163 tag === HostSingleton
164 ) {
165 const node = fiber.stateNode;
166 if (
167 matchAccessibilityRole(node, (selector as any as RoleSelector).value)
168 ) {
169 return true;
170 }
171 }
172 break;
173 case TEXT_TYPE:
174 if (
175 tag === HostComponent ||
176 tag === HostText ||
177 tag === HostHoistable ||
178 tag === HostSingleton
179 ) {
180 const textContent = getTextContent(fiber);
181 // $FlowFixMe[invalid-compare]
182 if (
183 // $FlowFixMe[invalid-compare]
184 textContent !== null &&
185 textContent.indexOf((selector as any as TextSelector).value) >= 0
186 ) {
187 return true;
188 }
189 }
190 break;
191 case TEST_NAME_TYPE:
192 if (
193 tag === HostComponent ||
194 tag === HostHoistable ||
195 tag === HostSingleton
196 ) {
197 const dataTestID = fiber.memoizedProps['data-testname'];
198 if (
199 typeof dataTestID === 'string' &&
200 dataTestID.toLowerCase() ===
201 (selector as any as TestNameSelector).value.toLowerCase()
202 ) {
203 return true;
204 }
205 }
206 break;
207 default:
208 throw new Error('Invalid selector type specified.');
209 }
210
211 return false;
212 }
213
214 function selectorToString(selector: Selector): string | null {
215 switch (selector.$$typeof) {
216 case COMPONENT_TYPE:
217 const displayName = getComponentNameFromType(selector.value) || 'Unknown';
218 return `<${displayName}>`;
219 case HAS_PSEUDO_CLASS_TYPE:
220 return `:has(${selectorToString(selector) || ''})`;
221 case ROLE_TYPE:
222 return `[role="${(selector as any as RoleSelector).value}"]`;
223 case TEXT_TYPE:
224 return `"${(selector as any as TextSelector).value}"`;
225 case TEST_NAME_TYPE:
226 return `[data-testname="${(selector as any as TestNameSelector).value}"]`;
227 default:
228 throw new Error('Invalid selector type specified.');
229 }
230 }
231
232 function findPaths(root: Fiber, selectors: Array<Selector>): Array<Fiber> {
233 const matchingFibers: Array<Fiber> = [];
234
235 const stack = [root, 0];
236 let index = 0;
237 while (index < stack.length) {
238 const fiber = stack[index++] as any as Fiber;
239 const tag = fiber.tag;
240 let selectorIndex = stack[index++] as any as number;
241 let selector = selectors[selectorIndex];
242
243 if (
244 (tag === HostComponent ||
245 tag === HostHoistable ||
246 tag === HostSingleton) &&
247 isHiddenSubtree(fiber)
248 ) {
249 continue;
250 } else {
251 while (selector != null && matchSelector(fiber, selector)) {
252 selectorIndex++;
253 selector = selectors[selectorIndex];
254 }
255 }
256
257 if (selectorIndex === selectors.length) {
258 matchingFibers.push(fiber);
259 } else {
260 let child = fiber.child;
261 while (child !== null) {
262 stack.push(child, selectorIndex);
263 child = child.sibling;
264 }
265 }
266 }
267
268 return matchingFibers;
269 }
270
271 // Same as findPaths but with eager bailout on first match
272 function hasMatchingPaths(root: Fiber, selectors: Array<Selector>): boolean {
273 const stack = [root, 0];
274 let index = 0;
275 while (index < stack.length) {
276 const fiber = stack[index++] as any as Fiber;
277 const tag = fiber.tag;
278 let selectorIndex = stack[index++] as any as number;
279 let selector = selectors[selectorIndex];
280
281 if (
282 (tag === HostComponent ||
283 tag === HostHoistable ||
284 tag === HostSingleton) &&
285 isHiddenSubtree(fiber)
286 ) {
287 continue;
288 } else {
289 while (selector != null && matchSelector(fiber, selector)) {
290 selectorIndex++;
291 selector = selectors[selectorIndex];
292 }
293 }
294
295 if (selectorIndex === selectors.length) {
296 return true;
297 } else {
298 let child = fiber.child;
299 while (child !== null) {
300 stack.push(child, selectorIndex);
301 child = child.sibling;
302 }
303 }
304 }
305
306 return false;
307 }
308
309 export function findAllNodes(
310 hostRoot: Instance,
311 selectors: Array<Selector>,
312 ): Array<Instance> {
313 // $FlowFixMe[constant-condition]
314 if (!supportsTestSelectors) {
315 throw new Error('Test selector API is not supported by this renderer.');
316 }
317
318 const root = findFiberRootForHostRoot(hostRoot);
319 const matchingFibers = findPaths(root, selectors);
320
321 const instanceRoots: Array<Instance> = [];
322
323 const stack = Array.from(matchingFibers);
324 let index = 0;
325 while (index < stack.length) {
326 const node = stack[index++] as any as Fiber;
327 const tag = node.tag;
328 if (
329 tag === HostComponent ||
330 tag === HostHoistable ||
331 tag === HostSingleton
332 ) {
333 if (isHiddenSubtree(node)) {
334 continue;
335 }
336 instanceRoots.push(node.stateNode);
337 } else {
338 let child = node.child;
339 while (child !== null) {
340 stack.push(child);
341 child = child.sibling;
342 }
343 }
344 }
345
346 return instanceRoots;
347 }
348
349 export function getFindAllNodesFailureDescription(
350 hostRoot: Instance,
351 selectors: Array<Selector>,
352 ): string | null {
353 // $FlowFixMe[constant-condition]
354 if (!supportsTestSelectors) {
355 throw new Error('Test selector API is not supported by this renderer.');
356 }
357
358 const root = findFiberRootForHostRoot(hostRoot);
359
360 let maxSelectorIndex: number = 0;
361 const matchedNames = [];
362
363 // The logic of this loop should be kept in sync with findPaths()
364 const stack = [root, 0];
365 let index = 0;
366 while (index < stack.length) {
367 const fiber = stack[index++] as any as Fiber;
368 const tag = fiber.tag;
369 let selectorIndex = stack[index++] as any as number;
370 const selector = selectors[selectorIndex];
371
372 if (
373 (tag === HostComponent ||
374 tag === HostHoistable ||
375 tag === HostSingleton) &&
376 isHiddenSubtree(fiber)
377 ) {
378 continue;
379 } else if (matchSelector(fiber, selector)) {
380 matchedNames.push(selectorToString(selector));
381 selectorIndex++;
382
383 if (selectorIndex > maxSelectorIndex) {
384 maxSelectorIndex = selectorIndex;
385 }
386 }
387
388 if (selectorIndex < selectors.length) {
389 let child = fiber.child;
390 while (child !== null) {
391 stack.push(child, selectorIndex);
392 child = child.sibling;
393 }
394 }
395 }
396
397 if (maxSelectorIndex < selectors.length) {
398 const unmatchedNames = [];
399 for (let i = maxSelectorIndex; i < selectors.length; i++) {
400 unmatchedNames.push(selectorToString(selectors[i]));
401 }
402
403 return (
404 'findAllNodes was able to match part of the selector:\n' +
405 ` ${matchedNames.join(' > ')}\n\n` +
406 'No matching component was found for:\n' +
407 ` ${unmatchedNames.join(' > ')}`
408 );
409 }
410
411 return null;
412 }
413
414 export type BoundingRect = {
415 x: number,
416 y: number,
417 width: number,
418 height: number,
419 };
420
421 export function findBoundingRects(
422 hostRoot: Instance,
423 selectors: Array<Selector>,
424 ): Array<BoundingRect> {
425 // $FlowFixMe[constant-condition]
426 if (!supportsTestSelectors) {
427 throw new Error('Test selector API is not supported by this renderer.');
428 }
429
430 const instanceRoots = findAllNodes(hostRoot, selectors);
431
432 const boundingRects: Array<BoundingRect> = [];
433 for (let i = 0; i < instanceRoots.length; i++) {
434 boundingRects.push(getBoundingRect(instanceRoots[i]));
435 }
436
437 for (let i = boundingRects.length - 1; i > 0; i--) {
438 const targetRect = boundingRects[i];
439 const targetLeft = targetRect.x;
440 const targetRight = targetLeft + targetRect.width;
441 const targetTop = targetRect.y;
442 const targetBottom = targetTop + targetRect.height;
443
444 for (let j = i - 1; j >= 0; j--) {
445 if (i !== j) {
446 const otherRect = boundingRects[j];
447 const otherLeft = otherRect.x;
448 const otherRight = otherLeft + otherRect.width;
449 const otherTop = otherRect.y;
450 const otherBottom = otherTop + otherRect.height;
451
452 // Merging all rects to the minimums set would be complicated,
453 // but we can handle the most common cases:
454 // 1. completely overlapping rects
455 // 2. adjacent rects that are the same width or height (e.g. items in a list)
456 //
457 // Even given the above constraints,
458 // we still won't end up with the fewest possible rects without doing multiple passes,
459 // but it's good enough for this purpose.
460
461 if (
462 targetLeft >= otherLeft &&
463 targetTop >= otherTop &&
464 targetRight <= otherRight &&
465 targetBottom <= otherBottom
466 ) {
467 // Complete overlapping rects; remove the inner one.
468 boundingRects.splice(i, 1);
469 break;
470 } else if (
471 targetLeft === otherLeft &&
472 targetRect.width === otherRect.width &&
473 !(otherBottom < targetTop) &&
474 !(otherTop > targetBottom)
475 ) {
476 // Adjacent vertical rects; merge them.
477 if (otherTop > targetTop) {
478 otherRect.height += otherTop - targetTop;
479 otherRect.y = targetTop;
480 }
481 if (otherBottom < targetBottom) {
482 otherRect.height = targetBottom - otherTop;
483 }
484
485 boundingRects.splice(i, 1);
486 break;
487 } else if (
488 targetTop === otherTop &&
489 targetRect.height === otherRect.height &&
490 !(otherRight < targetLeft) &&
491 !(otherLeft > targetRight)
492 ) {
493 // Adjacent horizontal rects; merge them.
494 if (otherLeft > targetLeft) {
495 otherRect.width += otherLeft - targetLeft;
496 otherRect.x = targetLeft;
497 }
498 if (otherRight < targetRight) {
499 otherRect.width = targetRight - otherLeft;
500 }
501
502 boundingRects.splice(i, 1);
503 break;
504 }
505 }
506 }
507 }
508
509 return boundingRects;
510 }
511
512 export function focusWithin(
513 hostRoot: Instance,
514 selectors: Array<Selector>,
515 ): boolean {
516 // $FlowFixMe[constant-condition]
517 if (!supportsTestSelectors) {
518 throw new Error('Test selector API is not supported by this renderer.');
519 }
520
521 const root = findFiberRootForHostRoot(hostRoot);
522 const matchingFibers = findPaths(root, selectors);
523
524 const stack = Array.from(matchingFibers);
525 let index = 0;
526 while (index < stack.length) {
527 const fiber = stack[index++] as any as Fiber;
528 const tag = fiber.tag;
529 if (isHiddenSubtree(fiber)) {
530 continue;
531 }
532 if (
533 tag === HostComponent ||
534 tag === HostHoistable ||
535 tag === HostSingleton
536 ) {
537 const node = fiber.stateNode;
538 if (setFocusIfFocusable(node)) {
539 return true;
540 }
541 }
542 let child = fiber.child;
543 while (child !== null) {
544 stack.push(child);
545 child = child.sibling;
546 }
547 }
548
549 return false;
550 }
551
552 const commitHooks: Array<Function> = [];
553
554 export function onCommitRoot(): void {
555 // $FlowFixMe[constant-condition]
556 if (supportsTestSelectors) {
557 commitHooks.forEach(commitHook => commitHook());
558 }
559 }
560
561 export type IntersectionObserverOptions = Object;
562
563 export type ObserveVisibleRectsCallback = (
564 intersections: Array<{ratio: number, rect: BoundingRect}>,
565 ) => void;
566
567 export function observeVisibleRects(
568 hostRoot: Instance,
569 selectors: Array<Selector>,
570 callback: (intersections: Array<{ratio: number, rect: BoundingRect}>) => void,
571 options?: IntersectionObserverOptions,
572 ): {disconnect: () => void} {
573 // $FlowFixMe[constant-condition]
574 if (!supportsTestSelectors) {
575 throw new Error('Test selector API is not supported by this renderer.');
576 }
577
578 const instanceRoots = findAllNodes(hostRoot, selectors);
579
580 const {disconnect, observe, unobserve} = setupIntersectionObserver(
581 instanceRoots,
582 callback,
583 options,
584 );
585
586 // When React mutates the host environment, we may need to change what we're listening to.
587 const commitHook = () => {
588 const nextInstanceRoots = findAllNodes(hostRoot, selectors);
589
590 instanceRoots.forEach(target => {
591 if (nextInstanceRoots.indexOf(target) < 0) {
592 unobserve(target);
593 }
594 });
595
596 nextInstanceRoots.forEach(target => {
597 if (instanceRoots.indexOf(target) < 0) {
598 observe(target);
599 }
600 });
601 };
602
603 commitHooks.push(commitHook);
604
605 return {
606 disconnect: () => {
607 // Stop listening for React mutations:
608 const index = commitHooks.indexOf(commitHook);
609 if (index >= 0) {
610 commitHooks.splice(index, 1);
611 }
612
613 // Disconnect the host observer:
614 disconnect();
615 },
616 };
617 }