@keepsnappy/react-native 0.1.0-alpha.15 → 0.1.0-alpha.17

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.
package/js/sdk/fiber.ts CHANGED
@@ -2,15 +2,20 @@
2
2
  // (to name touched views) and route key -> screen fiber (to name screens).
3
3
  // Only reads fiber fields; never mutates the tree.
4
4
 
5
+ import { buildPlaceId, placeSegment, logPlace } from './place';
6
+ export { buildPlaceId, placeSegment } from './place';
7
+
5
8
  export interface Fiber {
6
9
  tag: number;
7
10
  flags: number;
8
11
  type: unknown;
12
+ elementType?: unknown;
9
13
  alternate: Fiber | null;
10
14
  child: Fiber | null;
11
15
  sibling: Fiber | null;
12
16
  return: Fiber | null;
13
17
  memoizedProps: unknown;
18
+ memoizedState?: unknown;
14
19
  stateNode: unknown;
15
20
  }
16
21
 
@@ -33,6 +38,25 @@ export const isComponentTag = (tag: number) =>
33
38
  tag === FORWARD_REF ||
34
39
  tag === SIMPLE_MEMO_COMPONENT;
35
40
 
41
+ export const OUTSIDE_APP = '(outside app components)';
42
+ const hasOwn = Object.prototype.hasOwnProperty;
43
+ const stampOf = (type: unknown): string | null => {
44
+ if (type === null || (typeof type !== 'function' && typeof type !== 'object')) return null;
45
+ const key = (type as { __ks?: unknown }).__ks;
46
+ return typeof key === 'string' && hasOwn.call(type, '__ks') ? key : null;
47
+ };
48
+
49
+ export const clientKeyOf = (fiber: Fiber): string | null => {
50
+ const parent = fiber.tag === SIMPLE_MEMO_COMPONENT ? null : fiber.return;
51
+ return (fiber.tag === SIMPLE_MEMO_COMPONENT
52
+ ? stampOf(fiber.elementType)
53
+ : parent?.tag === MEMO_COMPONENT ? stampOf(parent.type) : null) ??
54
+ stampOf(fiber.type) ??
55
+ (fiber.tag === FORWARD_REF ? stampOf((fiber.type as { render?: unknown } | null)?.render) : null);
56
+ };
57
+
58
+ export const clientFiber = (f: Fiber) => isComponentTag(f.tag) && clientKeyOf(f) !== null;
59
+
36
60
  type Props = Record<string, unknown>;
37
61
 
38
62
  export const propsOf = (fiber: Fiber): Props | null => {
@@ -158,6 +182,7 @@ export interface RouteEntry {
158
182
  }
159
183
 
160
184
  export const routes = new Map<string, RouteEntry>();
185
+ const scenes = new Map<string, Fiber>();
161
186
 
162
187
  // React Navigation passes `route` and `navigation` to every screen component;
163
188
  // Expo Router routes are React Navigation routes. Reading the props is enough.
@@ -205,6 +230,14 @@ export const noteMounted = (fiber: Fiber) => {
205
230
  const props = propsOf(fiber);
206
231
  if (props && props.route !== undefined) {
207
232
  noteRoute(fiber, props);
233
+ // React Navigation core's SceneView: the route's `screen` config and `clearOptions`. Expo Router 58's fork no
234
+ // longer passes `getState`, so that prop does not mark it. Headers and layouts get `route` without these.
235
+ const route = props.route as { key?: unknown } | null;
236
+ if (typeof route?.key === 'string' &&
237
+ props.screen !== null && typeof props.screen === 'object' &&
238
+ typeof props.clearOptions === 'function') {
239
+ scenes.set(route.key, fiber);
240
+ }
208
241
  }
209
242
  };
210
243
 
@@ -229,6 +262,8 @@ export const noteUnmounted = (fiber: Fiber) => {
229
262
  }
230
263
  const route = propsOf(fiber)?.route as { key?: unknown } | undefined;
231
264
  if (route && typeof route.key === 'string') {
265
+ const scene = scenes.get(route.key);
266
+ if (scene === fiber || scene === fiber.alternate) scenes.delete(route.key);
232
267
  const entry = routes.get(route.key);
233
268
  if (entry && (entry.fiber === fiber || entry.fiber === fiber.alternate)) {
234
269
  routes.delete(route.key);
@@ -239,6 +274,7 @@ export const noteUnmounted = (fiber: Fiber) => {
239
274
  export const registrySizes = () => ({
240
275
  hostTags: hostByTag.size,
241
276
  routes: routes.size,
277
+ scenes: scenes.size,
242
278
  containers: containerFibers.size,
243
279
  });
244
280
 
@@ -248,8 +284,103 @@ export interface ScreenRoute {
248
284
  routeName: string | null;
249
285
  }
250
286
 
287
+ // React's slow path follows both branches: a shared child can keep a stale return pointer.
288
+ // Registries only supply mounted fibers; unresolved/deleted alternate pairs are not scanned.
289
+ export const currentFiber = (fiber: Fiber): Fiber | null => {
290
+ const alternate = fiber.alternate;
291
+ if (!alternate) return fiber;
292
+ let a = fiber;
293
+ let b = alternate;
294
+ let remaining = 1000;
295
+ while (remaining-- > 0) {
296
+ const parentA = a.return;
297
+ if (!parentA) {
298
+ const root = a.stateNode as { current?: Fiber } | null;
299
+ if (a.tag !== 3) return null;
300
+ if (root?.current === a) return fiber;
301
+ return root?.current === b ? alternate : null;
302
+ }
303
+ const parentB = parentA.alternate;
304
+ if (!parentB) {
305
+ const grandparent = parentA.return;
306
+ if (!grandparent) return null;
307
+ a = b = grandparent;
308
+ continue;
309
+ }
310
+ if (parentA.child === parentB.child) {
311
+ for (let child = parentA.child; child && remaining-- > 0; child = child.sibling) {
312
+ if (child === a) return fiber;
313
+ if (child === b) return alternate;
314
+ }
315
+ return null;
316
+ }
317
+ if (a.return !== b.return) {
318
+ a = parentA;
319
+ b = parentB;
320
+ } else {
321
+ let found = false;
322
+ for (const parent of [parentA, parentB]) {
323
+ for (let child = parent.child; child && remaining-- > 0; child = child.sibling) {
324
+ if (child === a || child === b) {
325
+ const other = parent === parentA ? parentB : parentA;
326
+ if (child === a) { a = parent; b = other; }
327
+ else { a = other; b = parent; }
328
+ found = true;
329
+ break;
330
+ }
331
+ }
332
+ if (found) break;
333
+ }
334
+ if (!found) return null;
335
+ }
336
+ if (a.alternate !== b) return null;
337
+ }
338
+ return null;
339
+ };
340
+
251
341
  const MAX_ROUTE_SEARCH = 500;
252
342
 
343
+ export interface ScreenKeyResolution {
344
+ screen: string;
345
+ screenPending?: boolean;
346
+ }
347
+
348
+ export interface ScreenRouteResolution extends ScreenKeyResolution {
349
+ suspended?: Fiber;
350
+ }
351
+
352
+ let screenKeyProvider: () => ScreenKeyResolution = () => ({ screen: '(no screen)' });
353
+ export const setScreenKeyProvider = (provider: () => ScreenKeyResolution) => {
354
+ screenKeyProvider = provider;
355
+ };
356
+ export const getScreenKey = () => screenKeyProvider();
357
+
358
+ export const screenKeyForRoute = (routeKey: string | null, routeName: string): ScreenRouteResolution => {
359
+ const scene = routeKey === null ? null : scenes.get(routeKey);
360
+ const root = scene ? currentFiber(scene) : null;
361
+ let suspended: Fiber | undefined;
362
+ if (root) {
363
+ const queue: Fiber[] = [];
364
+ let next: Fiber | null = root.child;
365
+ let visited = 0;
366
+ let queued = 0;
367
+ while ((next || queued < queue.length) && visited < MAX_ROUTE_SEARCH) {
368
+ const f: Fiber = next ?? queue[queued++]!;
369
+ visited++;
370
+ next = f.sibling;
371
+ if (clientFiber(f)) return { screen: clientKeyOf(f)! };
372
+ // A suspended boundary's child is hidden primary content; its sibling is the fallback Fragment.
373
+ if (f.tag === 13 && f.memoizedState != null) { suspended ??= f; continue; }
374
+ if (f.child) queue.push(f.child);
375
+ }
376
+ }
377
+ return {
378
+ screen: placeSegment(OUTSIDE_APP, routeName),
379
+ screenPending: !root || suspended !== undefined,
380
+ ...(suspended ? { suspended } : {}),
381
+ };
382
+ };
383
+
253
384
  const routeLike = (value: unknown): { key: string; name: string } | null => {
254
385
  if (value === null || typeof value !== 'object') {
255
386
  return null;
@@ -355,7 +486,9 @@ export type NameSource =
355
486
  | 'accessibilityLabel'
356
487
  | 'text'
357
488
  | 'component'
358
- | 'tag';
489
+ | 'tag'
490
+ | 'client'
491
+ | 'outside';
359
492
 
360
493
  export interface TargetDescription {
361
494
  name: string;
@@ -365,14 +498,17 @@ export interface TargetDescription {
365
498
  accessibilityLabel: string | null;
366
499
  component: string | null;
367
500
  actionable: boolean;
501
+ legacyName: string;
502
+ legacySource: NameSource;
503
+ place: TargetPlace;
368
504
  }
369
505
 
370
506
  // testID -> accessibilityLabel -> text -> nearest named component -> tag.
371
507
  // testID/label are searched from the touched view up to the first named
372
508
  // component above the actionable (onPress/responder) fiber.
373
- export const describeTarget = (tag: number): TargetDescription => {
509
+ const legacyTarget = (tag: number): Omit<TargetDescription, 'legacyName' | 'legacySource' | 'place'> => {
374
510
  const host = hostByTag.get(tag);
375
- const result: TargetDescription = {
511
+ const result: Omit<TargetDescription, 'legacyName' | 'legacySource' | 'place'> = {
376
512
  name: `tag:${tag}`,
377
513
  source: 'tag',
378
514
  tag,
@@ -438,6 +574,180 @@ export const describeTarget = (tag: number): TargetDescription => {
438
574
  return result;
439
575
  };
440
576
 
577
+ export interface TargetPlace {
578
+ screen: string;
579
+ parent: string | null;
580
+ target: string;
581
+ k: number | null;
582
+ hostType: string;
583
+ screenPending?: boolean;
584
+ manySiblings?: number;
585
+ }
586
+
587
+ // A scroll view's own responder handlers (RN sets them on RCTScrollView) serve the scroll gesture, so a scroll view is
588
+ // never the tap's region: the pressables inside it are.
589
+ const actionableFiber = (f: Fiber) => {
590
+ const props = propsOf(f);
591
+ return props !== null && isActionable(props) && !isScrollHost(f);
592
+ };
593
+ const isListCell = (f: Fiber) => {
594
+ if (!isComponentTag(f.tag) || clientKeyOf(f) !== null) return false;
595
+ const p = propsOf(f);
596
+ return p !== null && ((typeof p.cellKey === 'string' && typeof p.index === 'number') ||
597
+ (typeof p.index === 'number' && 'item' in p && typeof p.renderItem === 'function') ||
598
+ (typeof p.getRenderedItem === 'function' && 'itemKey' in p));
599
+ };
600
+
601
+ // React elements a component received in its props (children or any other prop), arrays included, by their props
602
+ // object: the fiber React creates from an element keeps that object as its props.
603
+ // Props objects do not change after their render, so the result is kept per object.
604
+ const MAX_PASSED_ELEMENTS = 64;
605
+ const NO_ELEMENTS: ReadonlySet<unknown> = new Set();
606
+ const passedByProps = new WeakMap<object, ReadonlySet<unknown>>();
607
+ const passedElementProps = (props: Props): ReadonlySet<unknown> => {
608
+ const known = passedByProps.get(props);
609
+ if (known) return known;
610
+ const found = new Set<unknown>();
611
+ try {
612
+ const stack: unknown[] = Object.values(props);
613
+ let seen = 0;
614
+ while (stack.length > 0 && seen++ < MAX_PASSED_ELEMENTS) {
615
+ const value = stack.pop();
616
+ if (Array.isArray(value)) {
617
+ stack.push(...value.slice(0, MAX_PASSED_ELEMENTS));
618
+ } else if (value !== null && typeof value === 'object' && '$$typeof' in value && 'props' in value) {
619
+ found.add((value as { props: unknown }).props);
620
+ }
621
+ }
622
+ } catch {
623
+ // A prop that throws on read (a revoked Proxy) carries no element we can match.
624
+ }
625
+ const result = found.size === 0 ? NO_ELEMENTS : found;
626
+ passedByProps.set(props, result);
627
+ return result;
628
+ };
629
+
630
+ // A client component between the target and the component that placed it passes the press through: it received a
631
+ // press handler from its owner (a button wrapper forwarding `onPress`), or it renders an element it received in its
632
+ // props around the target (a layout or animation wrapper given `children`). Neither tells two buttons apart.
633
+ const passesThrough = (f: Fiber, path: readonly Fiber[]) => {
634
+ const props = propsOf(f);
635
+ if (!props) return false;
636
+ if (isActionable(props)) return true;
637
+ const passed = passedElementProps(props);
638
+ if (passed.size === 0) return false;
639
+ return path.some(p => passed.has(p.memoizedProps) || (p.alternate !== null && passed.has(p.alternate.memoizedProps)));
640
+ };
641
+
642
+ // The parent of a place: the first client component above the target that does not pass the press through, else the
643
+ // nearest client component above it.
644
+ const placeParentOf = (target: Fiber): Fiber | null => {
645
+ let nearest: Fiber | null = null;
646
+ const path: Fiber[] = [target];
647
+ for (let f = target.return, n = 0; f && n < MAX_UP * 2; f = f.return, n++) {
648
+ if (clientFiber(f)) {
649
+ nearest ??= f;
650
+ if (!passesThrough(f, path)) return f;
651
+ }
652
+ path.push(f);
653
+ }
654
+ return nearest;
655
+ };
656
+
657
+ // A client component that can pass a press through for something below it; any other client component is the
658
+ // parent of every target under it.
659
+ const mayPassThrough = (f: Fiber) => {
660
+ const props = propsOf(f);
661
+ return props !== null && (isActionable(props) || passedElementProps(props).size > 0);
662
+ };
663
+
664
+ const occurrenceOf = (
665
+ parent: Fiber,
666
+ target: Fiber,
667
+ region: Fiber,
668
+ scroll: boolean,
669
+ ): { k: number | null; manySiblings?: number } => {
670
+ const none = { k: null };
671
+ for (let f: Fiber | null = region, n = 0; f && f !== parent && n < MAX_UP * 2; f = f.return, n++) {
672
+ if (isListCell(f)) return none;
673
+ }
674
+ const root = currentFiber(parent);
675
+ if (!root) { logPlace('place current parent unavailable'); return none; }
676
+ let visited = 0;
677
+ const key = clientKeyOf(target);
678
+ const stack: { f: Fiber; inside: boolean }[] = root.child ? [{ f: root.child, inside: false }] : [];
679
+ let count = 0;
680
+ let index = 0;
681
+ const isParent = (f: Fiber | null) => f !== null && (f === root || f === root.alternate);
682
+ while (stack.length && visited < 1000) {
683
+ const { f, inside } = stack.pop()!;
684
+ visited++;
685
+ if (f.sibling) stack.push({ f: f.sibling, inside });
686
+ if (isListCell(f)) continue;
687
+ const client = clientFiber(f);
688
+ if (client && inside) continue;
689
+ if (client && clientKeyOf(f) !== key) {
690
+ if (mayPassThrough(f) && f.child) stack.push({ f: f.child, inside: false });
691
+ continue;
692
+ }
693
+ if (client && !isParent(placeParentOf(f))) continue;
694
+ const inTarget = inside || client;
695
+ if (inTarget && ((scroll && client) || (!scroll && actionableFiber(f)))) {
696
+ count++;
697
+ if (f === region || f === region.alternate) index = count;
698
+ continue;
699
+ }
700
+ if (f.child) stack.push({ f: f.child, inside: inTarget });
701
+ }
702
+ if (stack.length) { logPlace('place occurrence scan cap'); return none; }
703
+ if (index === 0) { logPlace('place occurrence region not found'); return none; }
704
+ if (count > 20) {
705
+ logPlace(`place many siblings count=${count}`);
706
+ return { k: index, manySiblings: count };
707
+ }
708
+ return { k: count > 1 ? index : null };
709
+ };
710
+
711
+ const placeOf = (host: Fiber | null, scroll: boolean, screen: ScreenKeyResolution): TargetPlace => {
712
+ const hostType = typeof host?.type === 'string' ? host.type : 'unknown';
713
+ let start = host;
714
+ if (!scroll) {
715
+ for (let f = host, n = 0; f && n < MAX_UP; f = f.return, n++) {
716
+ if (actionableFiber(f)) { start = f; break; }
717
+ }
718
+ }
719
+ let target: Fiber | null = null;
720
+ let region = start;
721
+ for (let f = start, n = 0; f && n < MAX_UP * 2; f = f.return, n++) {
722
+ if (!scroll && actionableFiber(f)) region = f;
723
+ if (clientFiber(f)) { target = f; break; }
724
+ }
725
+ const parent = target ? placeParentOf(target) : null;
726
+ return {
727
+ screen: screen.screen,
728
+ parent: parent ? clientKeyOf(parent) : null,
729
+ target: target ? clientKeyOf(target)! : placeSegment(OUTSIDE_APP, hostType),
730
+ hostType,
731
+ ...(screen.screenPending ? { screenPending: true } : {}),
732
+ ...(parent && target && region ? occurrenceOf(parent, target, scroll ? target : region, scroll) : { k: null }),
733
+ };
734
+ };
735
+
736
+ const placeName = (place: TargetPlace) => buildPlaceId({ kind: 'press', ...place }).slice('press:'.length);
737
+
738
+ export const describeTarget = (tag: number, screen = getScreenKey()): TargetDescription => {
739
+ const legacy = legacyTarget(tag);
740
+ const place = placeOf(hostFiber(tag), false, screen);
741
+ return {
742
+ ...legacy,
743
+ name: placeName(place),
744
+ source: place.target.startsWith(OUTSIDE_APP + '@') ? 'outside' : 'client',
745
+ legacyName: legacy.name,
746
+ legacySource: legacy.source,
747
+ place,
748
+ };
749
+ };
750
+
441
751
  export const findInTree = (
442
752
  root: Fiber,
443
753
  match: (fiber: Fiber) => unknown,
@@ -474,7 +784,9 @@ export type ContainerNameSource =
474
784
  | 'nativeID'
475
785
  | 'component'
476
786
  | 'route'
477
- | 'host';
787
+ | 'host'
788
+ | 'client'
789
+ | 'outside';
478
790
 
479
791
  export interface ScrollContainer {
480
792
  name: string;
@@ -482,6 +794,9 @@ export interface ScrollContainer {
482
794
  hostType: string;
483
795
  axis: ScrollAxis;
484
796
  depth: number;
797
+ legacyName: string;
798
+ legacySource: ContainerNameSource;
799
+ place: TargetPlace;
485
800
  }
486
801
 
487
802
  const scrollAxisOf = (fiber: Fiber): ScrollAxis =>
@@ -511,10 +826,10 @@ const routeNameAbove = (fiber: Fiber): string | null => {
511
826
  // -> ScrollView -> RCTScrollView), so testID/label/nativeID are read there first,
512
827
  // then on the components above it up to the first one with a specific name,
513
828
  // which is the fallback name.
514
- const nameScrollContainer = (
829
+ const legacyScrollContainer = (
515
830
  host: Fiber,
516
831
  depth: number,
517
- ): ScrollContainer => {
832
+ ): Omit<ScrollContainer, 'legacyName' | 'legacySource' | 'place'> => {
518
833
  const hostType = host.type as string;
519
834
  let testID: string | null = null;
520
835
  let label: string | null = null;
@@ -560,32 +875,41 @@ const nameScrollContainer = (
560
875
  return { ...base, name: hostType, source: 'host' };
561
876
  };
562
877
 
878
+ export const nameScrollContainer = (candidate: ScrollHost, screen: ScreenKeyResolution): ScrollContainer => {
879
+ const host = currentFiber(candidate.host) ?? candidate.host;
880
+ const depth = candidate.depth;
881
+ const legacy = legacyScrollContainer(host, depth);
882
+ const place = placeOf(host, true, screen);
883
+ return {
884
+ ...legacy,
885
+ name: placeName(place),
886
+ source: place.target.startsWith(OUTSIDE_APP + '@') ? 'outside' : 'client',
887
+ legacyName: legacy.name,
888
+ legacySource: legacy.source,
889
+ place,
890
+ };
891
+ };
892
+
563
893
  // The scroll view a drag moved: the nearest scrollable ancestor of the touched
564
894
  // view whose axis matches the gesture's main axis, else the nearest one. A
565
895
  // horizontal swipe on a carousel inside a vertical feed names the carousel; a
566
896
  // vertical swipe on the same spot names the feed. Scroll events carry no target
567
897
  // 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++;
898
+ export interface ScrollHost {
899
+ host: Fiber;
900
+ axis: ScrollAxis;
901
+ depth: number;
902
+ }
903
+
904
+ export const scrollHostsAbove = (host: Fiber | null): ScrollHost[] => {
905
+ const hosts: ScrollHost[] = [];
906
+ for (let f = host, n = 0; f && n < MAX_UP * 4; f = f.return, n++) {
907
+ if (isScrollHost(f)) hosts.push({ host: f, axis: scrollAxisOf(f), depth: hosts.length });
589
908
  }
590
- return nearest ? nameScrollContainer(nearest, nearestDepth) : null;
909
+ return hosts;
591
910
  };
911
+
912
+ export const selectScrollHost = (
913
+ hosts: readonly ScrollHost[],
914
+ axis: ScrollAxis | null,
915
+ ): ScrollHost | null => hosts.find(h => axis === null || h.axis === axis) ?? hosts[0] ?? null;
@@ -25,7 +25,12 @@ import {
25
25
  writeRunEvent,
26
26
  } from './core';
27
27
  import {
28
- describeScrollContainer,
28
+ getScreenKey,
29
+ nameScrollContainer,
30
+ scrollHostsAbove,
31
+ selectScrollHost,
32
+ type ScreenKeyResolution,
33
+ type ScrollHost,
29
34
  describeTarget,
30
35
  type Fiber,
31
36
  findInTree,
@@ -51,8 +56,9 @@ interface ActivePress {
51
56
  key: string;
52
57
  seq: number;
53
58
  target: TargetDescription;
54
- host: Fiber | null;
59
+ screen: ScreenKeyResolution;
55
60
  container: ScrollContainer | null;
61
+ scrollHosts: ScrollHost[];
56
62
  identifier: unknown;
57
63
  touchStartT: number;
58
64
  touchEndT: number | null;
@@ -216,7 +222,7 @@ const details = (press: ActivePress) => () => {
216
222
  target: press.target,
217
223
  ...(press.kind === 'drag'
218
224
  ? {
219
- touched: press.target.name,
225
+ touched: press.target.legacyName,
220
226
  container: press.container,
221
227
  nameSource: press.container
222
228
  ? `container.${press.container.source}`
@@ -264,6 +270,19 @@ const finishPress = (press: ActivePress, reason: string, at?: number) => {
264
270
  }
265
271
  };
266
272
 
273
+ // A drag is named after its scroll view (REQ-MEAS-10). Returns whether the gesture is a drag.
274
+ const classify = (press: ActivePress) => {
275
+ const drag = press.maxMovePt >= DRAG_SLOP_PT || press.scrolled || press.takenByScroll;
276
+ press.kind = drag ? 'drag' : 'press';
277
+ if (drag) {
278
+ const axis = dragAxis(press);
279
+ const candidate = selectScrollHost(press.scrollHosts, axis);
280
+ press.container = candidate ? nameScrollContainer(candidate, press.screen) : null;
281
+ markers.rename(press.key, dragName(press), `drag:${press.container?.legacyName ?? press.target.legacyName}`);
282
+ }
283
+ return drag;
284
+ };
285
+
267
286
  const onTouchStart = (ne: Record<string, unknown>) => {
268
287
  const touches = ne.touches;
269
288
  if (Array.isArray(touches) && touches.length > 1) {
@@ -281,25 +300,19 @@ const onTouchStart = (ne: Record<string, unknown>) => {
281
300
  touchStartT,
282
301
  );
283
302
  }
284
- const target: TargetDescription =
285
- tag === null
286
- ? {
287
- name: 'unknown',
288
- source: 'tag',
289
- tag: -1,
290
- testID: null,
291
- accessibilityLabel: null,
292
- component: null,
293
- actionable: false,
294
- }
295
- : describeTarget(tag);
303
+ const screen = getScreenKey();
304
+ const target = describeTarget(tag ?? -1, screen);
305
+ if (tag === null) target.legacyName = 'unknown';
306
+ const host = tag === null ? null : hostFiber(tag);
307
+ const scrollHosts = scrollHostsAbove(host);
296
308
  seq++;
297
309
  const press: ActivePress = {
298
310
  key: `press#${seq}`,
299
311
  seq,
300
312
  target,
301
- host: tag === null ? null : hostFiber(tag),
313
+ screen,
302
314
  container: null,
315
+ scrollHosts,
303
316
  identifier: ne.identifier,
304
317
  touchStartT,
305
318
  touchEndT: null,
@@ -323,8 +336,14 @@ const onTouchStart = (ne: Record<string, unknown>) => {
323
336
  markers.start(name, {
324
337
  key: press.key,
325
338
  kind: 'press',
339
+ legacyName: `press:${target.legacyName}`,
326
340
  at: touchStartT,
327
341
  details: details(press),
342
+ // A touch whose touchEnd never reached JS (its view unmounted, the app left the foreground) is classified by how
343
+ // far it moved, so a swipe stays a drag on its list.
344
+ beforeEnd: () => {
345
+ if (press.kind === null) classify(press);
346
+ },
328
347
  });
329
348
  };
330
349
 
@@ -418,20 +437,15 @@ const onTouchEnd = (ne: Record<string, unknown>, cancelled: boolean) => {
418
437
  press.touchEndT = num(ne.timestamp) ?? now();
419
438
  // A scroll view cancels the touch when it takes the gesture. On a list still moving from a fling it does so at once,
420
439
  // before the finger passes the slop, so the move alone would call a fling a press on the row under the finger.
421
- press.takenByScroll = cancelled && describeScrollContainer(press.host, null) !== null;
422
- const drag = press.maxMovePt >= DRAG_SLOP_PT || press.scrolled || press.takenByScroll;
423
- press.kind = drag ? 'drag' : 'press';
424
- if (drag) {
425
- press.container = describeScrollContainer(press.host, dragAxis(press));
426
- const name = dragName(press);
427
- markers.rename(press.key, name);
440
+ press.takenByScroll = cancelled && press.scrollHosts.length > 0;
441
+ if (classify(press)) {
428
442
  return;
429
443
  }
430
444
  if (cancelled) {
431
445
  markers.point('touch.cancel');
432
446
  }
433
447
  const name = pressName(press);
434
- if (!cancelled && isFaultTarget(name)) {
448
+ if (!cancelled && isFaultTarget({ name, legacyName: `press:${press.target.legacyName}`, target: press.target })) {
435
449
  press.faultsRun = true;
436
450
  runOpenFaults(name);
437
451
  if (settings.faults.dupnet > 0) {
@@ -0,0 +1,25 @@
1
+ let placeLog: (message: string) => void = () => undefined;
2
+ export const setPlaceLog = (log: (message: string) => void) => { placeLog = log; };
3
+ export const logPlace = (message: string) => placeLog(message);
4
+
5
+ export interface PlaceKeyInput {
6
+ kind: 'screen' | 'launch' | 'press' | 'drag';
7
+ screen: string;
8
+ parent?: string | null;
9
+ target?: string | null;
10
+ k?: number | null;
11
+ visit?: number | 'repeat' | null;
12
+ }
13
+
14
+ // Pinned to facts/components.cjs by the shared place-keys.json vectors.
15
+ export const placeSegment = (name: string, file: string) =>
16
+ `${name}@${encodeURIComponent(file).replace(/%2F/g, '/').replace(/%40/g, '@').replace(/~/g, '%7E')}`;
17
+
18
+ export const buildPlaceId = (parts: PlaceKeyInput): string => {
19
+ const id = parts.kind === 'screen' ? `screen:${parts.screen}#${parts.visit}`
20
+ : parts.kind === 'launch' ? `launch:${parts.screen}`
21
+ : `${parts.kind}:${parts.screen}›${parts.parent ? parts.parent + '›' : ''}${parts.target}${parts.k ? '~' + parts.k : ''}`;
22
+ if ((parts.kind === 'press' || parts.kind === 'drag') && id.includes('#')) logPlace('place unexpected # in input id');
23
+ if (id.length > 900) logPlace(`place long id length=${id.length}`);
24
+ return id;
25
+ };
@@ -52,6 +52,7 @@ const elementType = (tag: number) =>
52
52
  export interface ExportFiber {
53
53
  id: number;
54
54
  name: string;
55
+ owner: string;
55
56
  type: number;
56
57
  // Component ancestors, nearest first.
57
58
  ancestors: number[];
@@ -147,10 +148,12 @@ export const exportFiber = (
147
148
  actualMs: number | null,
148
149
  selfMs: number | null,
149
150
  ancestors: number[] = ancestorsOf(fiber),
151
+ owner: string,
150
152
  ): ExportFiber => {
151
153
  return {
152
154
  id: fiberId(fiber),
153
155
  name,
156
+ owner,
154
157
  type: elementType(fiber.tag),
155
158
  ancestors,
156
159
  actualMs: actualMs ?? 0,
@@ -215,6 +218,17 @@ export const buildReactExport = (t0: number, commits: ExportCommit[]): ReactExpo
215
218
  added.add(fiber.id);
216
219
  }
217
220
  const table = strings.flatMap(encodeString);
221
+ const ownerNames: string[] = [];
222
+ const ownerIndices = new Map<string, number>();
223
+ const byFiber = commit.fibers.map(fiber => {
224
+ let index = ownerIndices.get(fiber.owner);
225
+ if (index === undefined) {
226
+ index = ownerNames.length;
227
+ ownerNames.push(fiber.owner);
228
+ ownerIndices.set(fiber.owner, index);
229
+ }
230
+ return [fiber.id, index];
231
+ });
218
232
  operations.push([RENDERER_ID, ROOT_ID, table.length, ...table, ...ops]);
219
233
  commitData.push({
220
234
  changeDescriptions: commit.fibers.map(f => [f.id, changeDescription(f.cause)]),
@@ -226,6 +240,7 @@ export const buildReactExport = (t0: number, commits: ExportCommit[]): ReactExpo
226
240
  priorityLevel: null,
227
241
  timestamp: Math.max(0, commit.t - t0),
228
242
  keepsnappyCauses: commit.causes,
243
+ keepsnappyOwners: { names: ownerNames, byFiber },
229
244
  keepsnappyCarrier: commit.carrier,
230
245
  keepsnappyStore: commit.store,
231
246
  keepsnappyTask: commit.task,