@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,591 @@
1
+ // Fiber-side registries filled from the commit walk: native tag -> host fiber
2
+ // (to name touched views) and route key -> screen fiber (to name screens).
3
+ // Only reads fiber fields; never mutates the tree.
4
+
5
+ export interface Fiber {
6
+ tag: number;
7
+ flags: number;
8
+ type: unknown;
9
+ alternate: Fiber | null;
10
+ child: Fiber | null;
11
+ sibling: Fiber | null;
12
+ return: Fiber | null;
13
+ memoizedProps: unknown;
14
+ stateNode: unknown;
15
+ }
16
+
17
+ export interface FiberRoot {
18
+ current: Fiber;
19
+ }
20
+
21
+ export const FUNCTION_COMPONENT = 0;
22
+ export const CLASS_COMPONENT = 1;
23
+ export const HOST_COMPONENT = 5;
24
+ export const HOST_TEXT = 6;
25
+ export const CONTEXT_PROVIDER = 10;
26
+ export const FORWARD_REF = 11;
27
+ export const MEMO_COMPONENT = 14;
28
+ export const SIMPLE_MEMO_COMPONENT = 15;
29
+
30
+ export const isComponentTag = (tag: number) =>
31
+ tag === FUNCTION_COMPONENT ||
32
+ tag === CLASS_COMPONENT ||
33
+ tag === FORWARD_REF ||
34
+ tag === SIMPLE_MEMO_COMPONENT;
35
+
36
+ type Props = Record<string, unknown>;
37
+
38
+ export const propsOf = (fiber: Fiber): Props | null => {
39
+ const props = fiber.memoizedProps;
40
+ return props !== null && typeof props === 'object' ? (props as Props) : null;
41
+ };
42
+
43
+ const functionName = (value: unknown): string | null => {
44
+ if (typeof value !== 'function') {
45
+ return null;
46
+ }
47
+ const fn = value as { displayName?: string; name?: string };
48
+ return fn.displayName || fn.name || null;
49
+ };
50
+
51
+ // Release bundles are minified, so a component's own name can be a single
52
+ // letter. The Sentry annotate babel plugin puts the source name on the
53
+ // component's root element as a prop; use it when the name is short.
54
+ const annotatedName = (fiber: Fiber): string | null => {
55
+ const props = fiber.child ? propsOf(fiber.child) : null;
56
+ if (!props) {
57
+ return null;
58
+ }
59
+ const value =
60
+ props.dataSentryComponent ?? props['data-sentry-component'] ?? null;
61
+ return typeof value === 'string' ? value : null;
62
+ };
63
+
64
+ export const nameStats = { short: 0, total: 0 };
65
+
66
+ export const componentName = (fiber: Fiber): string => {
67
+ const type = fiber.type as Record<string, unknown> | null;
68
+ let name =
69
+ functionName(type) ??
70
+ (type && typeof type === 'object'
71
+ ? (functionName(type.render) ?? functionName(type.type))
72
+ : null);
73
+ if (!name || name.length <= 2) {
74
+ nameStats.short++;
75
+ name = annotatedName(fiber) ?? `${name ?? 'Anonymous'}#${fiber.tag}`;
76
+ }
77
+ nameStats.total++;
78
+ return name;
79
+ };
80
+
81
+ const nativeTagOf = (fiber: Fiber): number | null => {
82
+ const node = fiber.stateNode as {
83
+ canonical?: { nativeTag?: unknown };
84
+ } | null;
85
+ const tag = node?.canonical?.nativeTag;
86
+ return typeof tag === 'number' ? tag : null;
87
+ };
88
+
89
+ const depthOf = (fiber: Fiber) => {
90
+ let depth = 0;
91
+ for (let f = fiber.return; f !== null; f = f.return) {
92
+ depth++;
93
+ }
94
+ return depth;
95
+ };
96
+
97
+ const hostByTag = new Map<number, Fiber>();
98
+
99
+ export const hostFiber = (tag: number) => hostByTag.get(tag) ?? null;
100
+
101
+ // The value of React Navigation's NavigationContainerRefContext is the
102
+ // container's imperative handle. Upstream @react-navigation and Expo Router's
103
+ // bundled fork are two copies of the class, so it is recognised by shape.
104
+ export interface NavigationRef {
105
+ getRootState: () => unknown;
106
+ getCurrentRoute: () => unknown;
107
+ addListener: (type: string, callback: (event: unknown) => void) => unknown;
108
+ isReady: () => boolean;
109
+ }
110
+
111
+ export const asNavigationRef = (value: unknown): NavigationRef | null => {
112
+ if (value === null || typeof value !== 'object') {
113
+ return null;
114
+ }
115
+ const ref = value as Record<string, unknown>;
116
+ return typeof ref.getRootState === 'function' &&
117
+ typeof ref.getCurrentRoute === 'function' &&
118
+ typeof ref.addListener === 'function' &&
119
+ typeof ref.isReady === 'function'
120
+ ? (value as NavigationRef)
121
+ : null;
122
+ };
123
+
124
+ interface ContainerHandlers {
125
+ added: (fiber: Fiber, ref: NavigationRef, depth: number) => void;
126
+ removed: (fiber: Fiber) => void;
127
+ }
128
+
129
+ let containerHandlers: ContainerHandlers | null = null;
130
+
131
+ export const onNavigationContainer = (handlers: ContainerHandlers) => {
132
+ containerHandlers = handlers;
133
+ };
134
+
135
+ const containerFibers = new Set<Fiber>();
136
+
137
+ export const noteContainer = (fiber: Fiber) => {
138
+ if (
139
+ containerFibers.has(fiber) ||
140
+ (fiber.alternate && containerFibers.has(fiber.alternate))
141
+ ) {
142
+ return;
143
+ }
144
+ const ref = asNavigationRef(propsOf(fiber)?.value);
145
+ if (!ref) {
146
+ return;
147
+ }
148
+ containerFibers.add(fiber);
149
+ containerHandlers?.added(fiber, ref, depthOf(fiber));
150
+ };
151
+
152
+ export interface RouteEntry {
153
+ key: string;
154
+ name: string;
155
+ depth: number;
156
+ fiber: Fiber;
157
+ navigation: { isFocused: () => boolean };
158
+ }
159
+
160
+ export const routes = new Map<string, RouteEntry>();
161
+
162
+ // React Navigation passes `route` and `navigation` to every screen component;
163
+ // Expo Router routes are React Navigation routes. Reading the props is enough.
164
+ const noteRoute = (fiber: Fiber, props: Props) => {
165
+ const route = props.route as { key?: unknown; name?: unknown } | null;
166
+ const navigation = props.navigation as { isFocused?: unknown } | null;
167
+ if (
168
+ !route ||
169
+ typeof route.key !== 'string' ||
170
+ typeof route.name !== 'string' ||
171
+ !navigation ||
172
+ typeof navigation.isFocused !== 'function'
173
+ ) {
174
+ return;
175
+ }
176
+ const depth = depthOf(fiber);
177
+ const existing = routes.get(route.key);
178
+ if (existing && existing.depth <= depth) {
179
+ return;
180
+ }
181
+ routes.set(route.key, {
182
+ key: route.key,
183
+ name: route.name,
184
+ depth,
185
+ fiber,
186
+ navigation: navigation as RouteEntry['navigation'],
187
+ });
188
+ };
189
+
190
+ export const noteMounted = (fiber: Fiber) => {
191
+ if (fiber.tag === HOST_COMPONENT) {
192
+ const tag = nativeTagOf(fiber);
193
+ if (tag !== null) {
194
+ hostByTag.set(tag, fiber);
195
+ }
196
+ return;
197
+ }
198
+ if (fiber.tag === CONTEXT_PROVIDER) {
199
+ noteContainer(fiber);
200
+ return;
201
+ }
202
+ if (!isComponentTag(fiber.tag) && fiber.tag !== MEMO_COMPONENT) {
203
+ return;
204
+ }
205
+ const props = propsOf(fiber);
206
+ if (props && props.route !== undefined) {
207
+ noteRoute(fiber, props);
208
+ }
209
+ };
210
+
211
+ export const noteUnmounted = (fiber: Fiber) => {
212
+ if (fiber.tag === HOST_COMPONENT) {
213
+ const tag = nativeTagOf(fiber);
214
+ if (tag !== null) {
215
+ const current = hostByTag.get(tag);
216
+ if (current === fiber || current === fiber.alternate) {
217
+ hostByTag.delete(tag);
218
+ }
219
+ }
220
+ return;
221
+ }
222
+ if (fiber.tag === CONTEXT_PROVIDER) {
223
+ for (const candidate of [fiber, fiber.alternate]) {
224
+ if (candidate && containerFibers.delete(candidate)) {
225
+ containerHandlers?.removed(candidate);
226
+ }
227
+ }
228
+ return;
229
+ }
230
+ const route = propsOf(fiber)?.route as { key?: unknown } | undefined;
231
+ if (route && typeof route.key === 'string') {
232
+ const entry = routes.get(route.key);
233
+ if (entry && (entry.fiber === fiber || entry.fiber === fiber.alternate)) {
234
+ routes.delete(route.key);
235
+ }
236
+ }
237
+ };
238
+
239
+ export const registrySizes = () => ({
240
+ hostTags: hostByTag.size,
241
+ routes: routes.size,
242
+ containers: containerFibers.size,
243
+ });
244
+
245
+ export interface ScreenRoute {
246
+ screenId: string | null;
247
+ routeKey: string | null;
248
+ routeName: string | null;
249
+ }
250
+
251
+ const MAX_ROUTE_SEARCH = 500;
252
+
253
+ const routeLike = (value: unknown): { key: string; name: string } | null => {
254
+ if (value === null || typeof value !== 'object') {
255
+ return null;
256
+ }
257
+ const route = value as { key?: unknown; name?: unknown };
258
+ return typeof route.key === 'string' && typeof route.name === 'string'
259
+ ? { key: route.key, name: route.name }
260
+ : null;
261
+ };
262
+
263
+ // A native screen host (RNSScreen) carries `screenId` (= route.key) only under
264
+ // native-stack. Otherwise the route is the shallowest descendant that has a
265
+ // `route` prop or provides a route as context value (NavigationRouteContext).
266
+ export const routeOfScreenHost = (host: Fiber): ScreenRoute => {
267
+ const props = propsOf(host);
268
+ const screenId = nonEmptyString(props?.screenId);
269
+ const queue: Fiber[] = [];
270
+ for (let child = host.child; child !== null; child = child.sibling) {
271
+ queue.push(child);
272
+ }
273
+ let visited = 0;
274
+ while (queue.length > visited && visited < MAX_ROUTE_SEARCH) {
275
+ const fiber = queue[visited] as Fiber;
276
+ visited++;
277
+ const fiberProps = propsOf(fiber);
278
+ const route =
279
+ fiber.tag === CONTEXT_PROVIDER
280
+ ? routeLike(fiberProps?.value)
281
+ : routeLike(fiberProps?.route);
282
+ if (route) {
283
+ return screenId === null || screenId === route.key
284
+ ? { screenId, routeKey: route.key, routeName: route.name }
285
+ : { screenId, routeKey: screenId, routeName: null };
286
+ }
287
+ for (let child = fiber.child; child !== null; child = child.sibling) {
288
+ queue.push(child);
289
+ }
290
+ }
291
+ return { screenId, routeKey: screenId, routeName: null };
292
+ };
293
+
294
+ const ACTION_PROPS = [
295
+ 'onPress',
296
+ 'onLongPress',
297
+ 'onPressIn',
298
+ 'onResponderRelease',
299
+ 'onClick',
300
+ ];
301
+
302
+ const GENERIC_COMPONENT =
303
+ /^(View|Text|Image|Pressable|Touchable\w*|Animated\w*|ScrollView|FlatList|VirtualizedList|CellRenderer\w*|Anonymous.*|Unknown.*|\w+#\d+)$/;
304
+
305
+ const MAX_UP = 80;
306
+ const MAX_TEXT_FIBERS = 300;
307
+ const MAX_TEXT_CHARS = 40;
308
+
309
+ const isActionable = (props: Props) =>
310
+ ACTION_PROPS.some(key => typeof props[key] === 'function');
311
+
312
+ const nonEmptyString = (value: unknown): string | null =>
313
+ typeof value === 'string' && value.trim() !== '' ? value : null;
314
+
315
+ // An id a library generated (a bare number, React's useId `:r1:` / `«r1»`) changes from run to run, so it never names a list.
316
+ const GENERATED_ID = /^(\d+|[:«].*[:»])$/;
317
+ const authoredId = (value: unknown): string | null => {
318
+ const s = nonEmptyString(value);
319
+ return s !== null && !GENERATED_ID.test(s) ? s : null;
320
+ };
321
+
322
+ const textUnder = (root: Fiber): string | null => {
323
+ const parts: string[] = [];
324
+ const stack: Fiber[] = [root];
325
+ let visited = 0;
326
+ let length = 0;
327
+ while (
328
+ stack.length > 0 &&
329
+ visited < MAX_TEXT_FIBERS &&
330
+ length < MAX_TEXT_CHARS
331
+ ) {
332
+ const fiber = stack.pop() as Fiber;
333
+ visited++;
334
+ if (fiber.tag === HOST_TEXT && typeof fiber.memoizedProps === 'string') {
335
+ const text = fiber.memoizedProps.trim();
336
+ if (text) {
337
+ parts.push(text);
338
+ length += text.length;
339
+ }
340
+ }
341
+ const children: Fiber[] = [];
342
+ for (let child = fiber.child; child !== null; child = child.sibling) {
343
+ children.push(child);
344
+ }
345
+ for (let i = children.length - 1; i >= 0; i--) {
346
+ stack.push(children[i] as Fiber);
347
+ }
348
+ }
349
+ const text = parts.join(' ').replace(/\s+/g, ' ').trim();
350
+ return text ? text.slice(0, MAX_TEXT_CHARS) : null;
351
+ };
352
+
353
+ export type NameSource =
354
+ | 'testID'
355
+ | 'accessibilityLabel'
356
+ | 'text'
357
+ | 'component'
358
+ | 'tag';
359
+
360
+ export interface TargetDescription {
361
+ name: string;
362
+ source: NameSource;
363
+ tag: number;
364
+ testID: string | null;
365
+ accessibilityLabel: string | null;
366
+ component: string | null;
367
+ actionable: boolean;
368
+ }
369
+
370
+ // testID -> accessibilityLabel -> text -> nearest named component -> tag.
371
+ // testID/label are searched from the touched view up to the first named
372
+ // component above the actionable (onPress/responder) fiber.
373
+ export const describeTarget = (tag: number): TargetDescription => {
374
+ const host = hostByTag.get(tag);
375
+ const result: TargetDescription = {
376
+ name: `tag:${tag}`,
377
+ source: 'tag',
378
+ tag,
379
+ testID: null,
380
+ accessibilityLabel: null,
381
+ component: null,
382
+ actionable: false,
383
+ };
384
+ if (!host) {
385
+ return result;
386
+ }
387
+ let actionable: Fiber | null = null;
388
+ let steps = 0;
389
+ for (let f: Fiber | null = host; f !== null && steps < MAX_UP; f = f.return) {
390
+ steps++;
391
+ const props = propsOf(f);
392
+ if (props && isActionable(props)) {
393
+ actionable = f;
394
+ break;
395
+ }
396
+ }
397
+ result.actionable = actionable !== null;
398
+ // Without an actionable ancestor the search stops at the nearest named
399
+ // component, so a screen-level testID far above never names a stray touch.
400
+ let passedActionable = actionable === null;
401
+ steps = 0;
402
+ for (let f: Fiber | null = host; f !== null && steps < MAX_UP; f = f.return) {
403
+ steps++;
404
+ const props = propsOf(f);
405
+ if (props) {
406
+ result.testID ??= nonEmptyString(props.testID);
407
+ result.accessibilityLabel ??=
408
+ nonEmptyString(props.accessibilityLabel) ??
409
+ nonEmptyString(props['aria-label']);
410
+ }
411
+ if (f === actionable) {
412
+ passedActionable = true;
413
+ }
414
+ if (passedActionable && isComponentTag(f.tag)) {
415
+ const name = componentName(f);
416
+ if (!GENERIC_COMPONENT.test(name)) {
417
+ result.component = name;
418
+ break;
419
+ }
420
+ }
421
+ }
422
+ if (result.testID) {
423
+ result.name = result.testID;
424
+ result.source = 'testID';
425
+ } else if (result.accessibilityLabel) {
426
+ result.name = result.accessibilityLabel;
427
+ result.source = 'accessibilityLabel';
428
+ } else {
429
+ const text = textUnder(actionable ?? host);
430
+ if (text) {
431
+ result.name = text;
432
+ result.source = 'text';
433
+ } else if (result.component) {
434
+ result.name = result.component;
435
+ result.source = 'component';
436
+ }
437
+ }
438
+ return result;
439
+ };
440
+
441
+ export const findInTree = (
442
+ root: Fiber,
443
+ match: (fiber: Fiber) => unknown,
444
+ limit = 20000,
445
+ ): unknown => {
446
+ const stack: Fiber[] = [root];
447
+ let visited = 0;
448
+ while (stack.length > 0 && visited < limit) {
449
+ const fiber = stack.pop() as Fiber;
450
+ visited++;
451
+ const found = match(fiber);
452
+ if (found) {
453
+ return found;
454
+ }
455
+ for (let child = fiber.child; child !== null; child = child.sibling) {
456
+ stack.push(child);
457
+ }
458
+ }
459
+ return null;
460
+ };
461
+
462
+ const SCROLL_HOSTS = new Set(['RCTScrollView', 'AndroidHorizontalScrollView']);
463
+
464
+ // Components the lists render between their props and the native scroll view;
465
+ // a name from these would not tell two lists apart.
466
+ const GENERIC_SCROLL_COMPONENT =
467
+ /^(SectionList|VirtualizedSectionList|VirtualizedListContextProvider|VirtualizedListCellContextProvider|FlashList|AnimatedFlashList|RecyclerListView|ScrollViewBase|ScrollViewStickyHeader|ProgressiveListView|AnimatedFlatList|AnimatedScrollView|AnimatedComponent.*)$/;
468
+
469
+ export type ScrollAxis = 'x' | 'y';
470
+
471
+ export type ContainerNameSource =
472
+ | 'testID'
473
+ | 'accessibilityLabel'
474
+ | 'nativeID'
475
+ | 'component'
476
+ | 'route'
477
+ | 'host';
478
+
479
+ export interface ScrollContainer {
480
+ name: string;
481
+ source: ContainerNameSource;
482
+ hostType: string;
483
+ axis: ScrollAxis;
484
+ depth: number;
485
+ }
486
+
487
+ const scrollAxisOf = (fiber: Fiber): ScrollAxis =>
488
+ fiber.type === 'AndroidHorizontalScrollView' ||
489
+ propsOf(fiber)?.horizontal === true
490
+ ? 'x'
491
+ : 'y';
492
+
493
+ const isScrollHost = (fiber: Fiber) =>
494
+ fiber.tag === HOST_COMPONENT &&
495
+ typeof fiber.type === 'string' &&
496
+ SCROLL_HOSTS.has(fiber.type);
497
+
498
+ const routeNameAbove = (fiber: Fiber): string | null => {
499
+ let steps = 0;
500
+ for (let f: Fiber | null = fiber; f !== null && steps < MAX_UP * 4; f = f.return) {
501
+ steps++;
502
+ const route = routeLike(propsOf(f)?.route);
503
+ if (route) {
504
+ return route.name;
505
+ }
506
+ }
507
+ return null;
508
+ };
509
+
510
+ // The list's own props reach the native scroll view (FlatList -> VirtualizedList
511
+ // -> ScrollView -> RCTScrollView), so testID/label/nativeID are read there first,
512
+ // then on the components above it up to the first one with a specific name,
513
+ // which is the fallback name.
514
+ const nameScrollContainer = (
515
+ host: Fiber,
516
+ depth: number,
517
+ ): ScrollContainer => {
518
+ const hostType = host.type as string;
519
+ let testID: string | null = null;
520
+ let label: string | null = null;
521
+ let nativeID: string | null = null;
522
+ let component: string | null = null;
523
+ let steps = 0;
524
+ for (let f: Fiber | null = host; f !== null && steps < MAX_UP; f = f.return) {
525
+ steps++;
526
+ // Host views around the list (a screen's root View) are not the list.
527
+ const props = f === host || isComponentTag(f.tag) ? propsOf(f) : null;
528
+ if (props) {
529
+ testID ??= nonEmptyString(props.testID);
530
+ label ??=
531
+ nonEmptyString(props.accessibilityLabel) ??
532
+ nonEmptyString(props['aria-label']);
533
+ nativeID ??= authoredId(props.nativeID) ?? authoredId(props.id);
534
+ }
535
+ if (f !== host && isComponentTag(f.tag)) {
536
+ const name = componentName(f);
537
+ if (!GENERIC_COMPONENT.test(name) && !GENERIC_SCROLL_COMPONENT.test(name)) {
538
+ component = name;
539
+ break;
540
+ }
541
+ }
542
+ }
543
+ const base = { hostType, axis: scrollAxisOf(host), depth };
544
+ if (testID) {
545
+ return { ...base, name: testID, source: 'testID' };
546
+ }
547
+ if (label) {
548
+ return { ...base, name: label, source: 'accessibilityLabel' };
549
+ }
550
+ if (nativeID) {
551
+ return { ...base, name: nativeID, source: 'nativeID' };
552
+ }
553
+ if (component) {
554
+ return { ...base, name: component, source: 'component' };
555
+ }
556
+ const route = routeNameAbove(host);
557
+ if (route) {
558
+ return { ...base, name: `${route}/${hostType}`, source: 'route' };
559
+ }
560
+ return { ...base, name: hostType, source: 'host' };
561
+ };
562
+
563
+ // The scroll view a drag moved: the nearest scrollable ancestor of the touched
564
+ // view whose axis matches the gesture's main axis, else the nearest one. A
565
+ // horizontal swipe on a carousel inside a vertical feed names the carousel; a
566
+ // vertical swipe on the same spot names the feed. Scroll events carry no target
567
+ // tag, so the ancestor chain is the only link from the touch to the container.
568
+ export const describeScrollContainer = (
569
+ host: Fiber | null,
570
+ axis: ScrollAxis | null,
571
+ ): ScrollContainer | null => {
572
+ let nearest: Fiber | null = null;
573
+ let nearestDepth = 0;
574
+ let depth = 0;
575
+ let steps = 0;
576
+ for (let f: Fiber | null = host; f !== null && steps < MAX_UP * 4; f = f.return) {
577
+ steps++;
578
+ if (!isScrollHost(f)) {
579
+ continue;
580
+ }
581
+ if (axis === null || scrollAxisOf(f) === axis) {
582
+ return nameScrollContainer(f, depth);
583
+ }
584
+ if (nearest === null) {
585
+ nearest = f;
586
+ nearestDepth = depth;
587
+ }
588
+ depth++;
589
+ }
590
+ return nearest ? nameScrollContainer(nearest, nearestDepth) : null;
591
+ };