/** * Introspector — Walks the React component tree to discover interactive elements. * * This module traverses React's internal fiber tree to find components * that are interactive (buttons, inputs, scrollviews, etc.) and extracts * their metadata (testID, accessibility props, handlers, layout). * * The introspector runs periodically and updates the ElementRegistry. */ import type { ElementInfo, ElementType, ActionType, ElementHandlers, ElementPosition, } from './types'; import { logDebug, logWarn } from './logger'; // React fiber node types (internal React types) interface FiberNode { tag: number; type: any; stateNode: any; memoizedProps: Record; child: FiberNode | null; sibling: FiberNode | null; return: FiberNode | null; key: string | null; ref: any; _debugOwner?: FiberNode; _debugSource?: { fileName: string; lineNumber: number }; } const HOST_COMPONENT = 5; // Native view (View, Text, etc.) const HOST_TEXT = 6; /** * Map of React Native component names / accessibility roles to our ElementType. */ const ROLE_TO_TYPE: Record = { button: 'button', link: 'link', search: 'input', text: 'text', header: 'header', image: 'image', imagebutton: 'button', adjustable: 'input', switch: 'switch', tab: 'tab', tablist: 'list', scrollbar: 'scroll', none: 'view', }; const COMPONENT_TO_TYPE: Record = { RCTTextInput: 'input', TextInput: 'input', RCTScrollView: 'scroll', ScrollView: 'scroll', RCTSwitch: 'switch', Switch: 'switch', RCTText: 'text', Text: 'text', Image: 'image', RCTImageView: 'image', Pressable: 'button', TouchableOpacity: 'button', TouchableHighlight: 'button', TouchableWithoutFeedback: 'button', TouchableNativeFeedback: 'button', RCTView: 'view', View: 'view', FlatList: 'list', SectionList: 'list', Modal: 'modal', }; export interface IntrospectedElement { id: string; info: Omit; ref: React.RefObject; handlers: ElementHandlers; } /** * Get the React fiber root from a React Native root view ref. * * When a ref is attached to a , React Native gives us the native * host instance. The fiber is stored on that instance under different * property names depending on React / React Native version: * * - __internalInstanceHandle (React Native renderer, most common) * - _internalFiberInstanceHandleDEV (older RN dev builds) * - _reactInternals (class component instances only) * * We also look for a `_nativeTag` as a sanity-check that we really have * a native host instance (which is the expected case for a ref). */ function getFiberRoot(root: any): FiberNode | null { if (!root) return null; // Primary path — React Native attaches the fiber here on host instances. if (root.__internalInstanceHandle) { return root.__internalInstanceHandle; } // Older / DEV-only name if (root._internalFiberInstanceHandleDEV) { return root._internalFiberInstanceHandleDEV; } // Class component instance (unlikely for a ref, but just in case) if (root._reactInternals) { return root._reactInternals; } // Walk own properties looking for a fiber-shaped object as last resort for (const key of Object.keys(root)) { if ( key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$') ) { return root[key]; } } return null; } /** * Determine the ElementType from a fiber node's props and component type. */ function resolveElementType(fiber: FiberNode): ElementType { const props = fiber.memoizedProps || {}; // Check accessibility role first if (props.accessibilityRole && ROLE_TO_TYPE[props.accessibilityRole]) { return ROLE_TO_TYPE[props.accessibilityRole]!; } if (props.role && ROLE_TO_TYPE[props.role]) { return ROLE_TO_TYPE[props.role]!; } // Check component type name const typeName = typeof fiber.type === 'string' ? fiber.type : fiber.type?.displayName || fiber.type?.name || ''; if (typeName && COMPONENT_TO_TYPE[typeName]) { return COMPONENT_TO_TYPE[typeName]!; } // Check if it has interactive handlers if (props.onPress || props.onPressIn || props.onPressOut) { return 'button'; } if (props.onChangeText) { return 'input'; } if (props.onValueChange && props.value !== undefined) { return typeof props.value === 'number' ? 'slider' : 'switch'; } return 'unknown'; } /** * Determine available actions for an element type. */ function resolveActions( type: ElementType, props: Record ): ActionType[] { const actions: ActionType[] = []; if (props.onPress) actions.push('tap'); if (props.onLongPress) actions.push('longPress'); if (props.onChangeText) { actions.push('type'); actions.push('clear'); actions.push('focus'); actions.push('blur'); } if (props.onValueChange) { if (typeof props.value === 'number') { actions.push('setValue'); } else { actions.push('toggle'); } } if (type === 'scroll') actions.push('scroll'); return actions; } /** * Extract handlers from props for the ActionExecutor. */ function extractHandlers( props: Record, fiber: FiberNode, type: ElementType ): ElementHandlers { const handlers: ElementHandlers = { onPress: props.onPress, onLongPress: props.onLongPress, onChangeText: props.onChangeText, onValueChange: props.onValueChange, }; // Attach scrollTo for scroll-type elements. // The host fiber (RCTScrollView) stateNode is a raw native view that // doesn't have scrollTo. The scrollTo lives on the composite ScrollView // component instance, accessible via the ref on an ancestor fiber. if (type === 'scroll') { const scrollRef = findScrollRef(fiber); if (scrollRef && typeof scrollRef.scrollTo === 'function') { handlers.scrollTo = (opts) => scrollRef.scrollTo(opts); } } return handlers; } /** * Walk up ancestor fibers from a host ScrollView node to find a ref * that exposes `scrollTo`. In React Native, the composite `ScrollView` * component is typically 1-4 levels above the host `RCTScrollView` fiber. */ function findScrollRef(fiber: FiberNode): any { let current: FiberNode | null = fiber; let depth = 0; while (current && depth < 8) { // Check refs on composite components (the ScrollView JS component) if (current.ref) { const ref = typeof current.ref === 'function' ? null // callback refs — can't read synchronously : current.ref?.current ?? current.ref; if (ref) { // Direct scrollTo on the ref (most common path) if (typeof ref.scrollTo === 'function') { return ref; } // Some wrappers use getNativeScrollRef if (typeof ref.getNativeScrollRef === 'function') { const inner = ref.getNativeScrollRef(); if (inner && typeof inner.scrollTo === 'function') { return inner; } } // Or getScrollResponder if (typeof ref.getScrollResponder === 'function') { const responder = ref.getScrollResponder(); if (responder && typeof responder.scrollTo === 'function') { return responder; } } } } // Also check stateNode for class component instances if (current.stateNode && current.tag !== HOST_COMPONENT) { const inst = current.stateNode; if (typeof inst.scrollTo === 'function') { return inst; } if (typeof inst.getNativeScrollRef === 'function') { const inner = inst.getNativeScrollRef(); if (inner && typeof inner.scrollTo === 'function') { return inner; } } if (typeof inst.getScrollResponder === 'function') { const responder = inst.getScrollResponder(); if (responder && typeof responder.scrollTo === 'function') { return responder; } } } current = current.return; depth++; } // Last resort: check the original fiber's stateNode directly if (fiber.stateNode && typeof fiber.stateNode.scrollTo === 'function') { return fiber.stateNode; } return null; } /** * Walk up the fiber tree from a host component to collect interactive * props from ancestor composite components. * * React Native composite components like Pressable, TouchableOpacity, etc. * hold the onPress/onLongPress handlers on their own fiber, but pass * testID and accessibility props down to the rendered native View. * We need to merge these ancestor props so that we capture the handlers. */ const INTERACTIVE_PROPS = [ 'onPress', 'onLongPress', 'onChangeText', 'onValueChange', 'accessibilityRole', 'accessibilityLabel', 'accessibilityHint', 'accessibilityState', 'role', 'disabled', ] as const; function collectAncestorProps(fiber: FiberNode): Record { const collected: Record = {}; let current = fiber.return; // Walk up at most 6 levels — composite wrappers are typically 1-3 levels up let depth = 0; while (current && depth < 6) { // Only look at composite (function/class) components, not other host nodes if (current.tag !== HOST_COMPONENT && current.tag !== HOST_TEXT) { const parentProps = current.memoizedProps || {}; for (const prop of INTERACTIVE_PROPS) { if (parentProps[prop] !== undefined && collected[prop] === undefined) { collected[prop] = parentProps[prop]; } } } else { // Stop at the first ancestor host component — we've left our subtree break; } current = current.return; depth++; } return collected; } /** * Extract element label from props. */ function resolveLabel(props: Record, type: ElementType): string { // Priority: accessibilityLabel > title > text children > testID > placeholder if (props.accessibilityLabel) return String(props.accessibilityLabel); if (props.title) return String(props.title); if (typeof props.children === 'string') return props.children; if (props.testID) return String(props.testID); if (props.placeholder) return String(props.placeholder); if (props['aria-label']) return String(props['aria-label']); return `${type}`; } /** * Extract element ID from props. */ function resolveId(props: Record, index: number): string { if (props.testID) return String(props.testID); if (props.nativeID) return String(props.nativeID); if (props.accessibilityLabel) { return String(props.accessibilityLabel).replace(/\s+/g, '-').toLowerCase(); } return `element-${index}`; } /** * Check if a fiber node represents an interactive or interesting element. */ function isInteractiveElement( fiber: FiberNode, includeNonInteractive: boolean ): boolean { if (fiber.tag !== HOST_COMPONENT) return false; const props = fiber.memoizedProps || {}; // Always include elements with testID (developer explicitly marked them) if (props.testID) return true; // Include elements with accessibility labels if (props.accessibilityLabel || props['aria-label']) return true; // Include interactive elements if ( props.onPress || props.onLongPress || props.onChangeText || props.onValueChange ) { return true; } // Include elements with accessibility roles if (props.accessibilityRole || props.role) return true; // Check immediate composite parent for interactive handlers. // Handles cases like where onPress is on the // composite fiber (Text) rather than the host fiber (RCTText). const parent = fiber.return; if (parent && parent.tag !== HOST_COMPONENT && parent.tag !== HOST_TEXT) { const parentProps = parent.memoizedProps || {}; if ( parentProps.onPress || parentProps.onLongPress || parentProps.onChangeText || parentProps.onValueChange ) { return true; } } // If includeNonInteractive, include all host components with meaningful content if (includeNonInteractive) { if (typeof props.children === 'string' && props.children.trim()) return true; } return false; } /** * Check if a fiber represents a hidden subtree that should be skipped. * * Detects: * - react-native-screens inactive screens (activityState === 0) * - @react-navigation/native-stack unfocused screens (aria-hidden) * - Views with display: 'none' style */ function isHiddenSubtree(fiber: FiberNode): boolean { const props = fiber.memoizedProps || {}; // react-native-screens uses activityState: 0 = inactive, 2 = active if (props.activityState === 0) return true; // @react-navigation/native-stack sets aria-hidden={!focused} on ScreenStackItem if (props['aria-hidden'] === true) return true; // display: 'none' (used by react-native-screens for non-native impls) if (props.style) { const style = Array.isArray(props.style) ? Object.assign({}, ...props.style.filter(Boolean)) : props.style; if (style?.display === 'none') return true; } return false; } /** * Recursively traverse the fiber tree and collect interactive elements. */ function traverseFiber( fiber: FiberNode | null, elements: IntrospectedElement[], includeNonInteractive: boolean, parentId?: string ): void { if (!fiber) return; // Skip entire subtrees that are hidden / off-screen if (isHiddenSubtree(fiber)) { // Still traverse siblings — only this subtree is hidden traverseFiber(fiber.sibling, elements, includeNonInteractive, parentId); return; } if (isInteractiveElement(fiber, includeNonInteractive)) { const ownProps = fiber.memoizedProps || {}; // Merge with ancestor composite component props (Pressable, etc.) // Ancestor props fill in missing handlers/accessibility attrs. const ancestorProps = collectAncestorProps(fiber); const props = { ...ancestorProps, ...ownProps }; const type = resolveElementType({ ...fiber, memoizedProps: props, } as FiberNode); const id = resolveId(props, elements.length); // Avoid duplicate IDs by appending index if needed const uniqueId = elements.some((e) => e.id === id) ? `${id}-${elements.length}` : id; const position: ElementPosition = { x: 0, y: 0, width: 0, height: 0, }; // Try to get layout info from the native node if (fiber.stateNode?.measure) { try { fiber.stateNode.measure( ( _x: number, _y: number, w: number, h: number, px: number, py: number ) => { position.x = px || 0; position.y = py || 0; position.width = w || 0; position.height = h || 0; } ); } catch { // Measurement can fail for unmounted elements } } const element: IntrospectedElement = { id: uniqueId, info: { type, label: resolveLabel(props, type), value: props.value !== undefined ? String(props.value) : undefined, placeholder: props.placeholder, state: { disabled: props.disabled || props.accessibilityState?.disabled || false, selected: props.accessibilityState?.selected || false, checked: props.accessibilityState?.checked ?? (type === 'switch' ? Boolean(props.value) : undefined), expanded: props.accessibilityState?.expanded, busy: props.accessibilityState?.busy, }, position, children: [], parent: parentId, actions: resolveActions(type, props), accessibilityRole: props.accessibilityRole || props.role, accessibilityHint: props.accessibilityHint || props['aria-hint'], }, ref: { current: fiber.stateNode }, handlers: extractHandlers(props, fiber, type), }; elements.push(element); // Traverse children with this element as parent traverseFiber(fiber.child, elements, includeNonInteractive, uniqueId); } else { // Skip this node but continue traversing children traverseFiber(fiber.child, elements, includeNonInteractive, parentId); } // Always traverse siblings traverseFiber(fiber.sibling, elements, includeNonInteractive, parentId); } /** * Main introspection function. Accepts a React root ref and returns * all discovered interactive elements. */ export function introspect( rootRef: React.RefObject, includeNonInteractive: boolean = false ): IntrospectedElement[] { const elements: IntrospectedElement[] = []; if (!rootRef.current) { logWarn('Root ref is null, cannot introspect'); return elements; } // Try to get the fiber root from the ref const fiberRoot = getFiberRoot(rootRef.current); if (!fiberRoot) { logDebug( 'Could not find fiber root — falling back to accessibility-based scan' ); return elements; } try { traverseFiber(fiberRoot, elements, includeNonInteractive); logDebug(`Introspection found ${elements.length} elements`); } catch (e: any) { logWarn(`Introspection error: ${e.message}`); } return elements; } /** * Extract text content from a fiber node's children. */ export function extractTextContent(fiber: FiberNode): string { const texts: string[] = []; function walk(node: FiberNode | null) { if (!node) return; if (node.tag === HOST_TEXT && node.memoizedProps) { if (typeof node.memoizedProps === 'string') { texts.push(node.memoizedProps); } } if (typeof node.memoizedProps?.children === 'string') { texts.push(node.memoizedProps.children); } walk(node.child); walk(node.sibling); } walk(fiber); return texts.join(' ').trim(); }