@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/README.md +12 -0
- package/js/babel-transformer.js +31 -6
- package/js/component-contract.cjs +14 -0
- package/js/component-stamps.js +207 -0
- package/js/sdk/bytes.ts +20 -0
- package/js/sdk/core.ts +92 -22
- package/js/sdk/device-model.ts +10 -0
- package/js/sdk/fiber.ts +352 -28
- package/js/sdk/interactions.ts +38 -24
- package/js/sdk/place.ts +25 -0
- package/js/sdk/profile-export.ts +15 -0
- package/js/sdk/profile.ts +272 -18
- package/js/sdk/push-install.ts +2 -1
- package/js/sdk/push.ts +70 -46
- package/js/sdk/sampler.ts +1 -1
- package/js/sdk/screens.ts +91 -6
- package/js/sdk/stream.ts +6 -0
- package/metro.js +11 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
569
|
-
host: Fiber
|
|
570
|
-
axis: ScrollAxis
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
for (let f
|
|
577
|
-
|
|
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
|
|
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;
|
package/js/sdk/interactions.ts
CHANGED
|
@@ -25,7 +25,12 @@ import {
|
|
|
25
25
|
writeRunEvent,
|
|
26
26
|
} from './core';
|
|
27
27
|
import {
|
|
28
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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 &&
|
|
422
|
-
|
|
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) {
|
package/js/sdk/place.ts
ADDED
|
@@ -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
|
+
};
|
package/js/sdk/profile-export.ts
CHANGED
|
@@ -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,
|