| 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 | } |