@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,531 @@
1
+ // Generic interaction windows. React Native's Fabric renderer emits every
2
+ // native event to RawEventEmitter before any handler runs; touch payloads carry
3
+ // the touched view's native tag and the hardware touch time. A press window
4
+ // opens at that time, is named from the touched fiber, and ends at the next
5
+ // touch, when the app goes to the background, or when the run is flushed; never
6
+ // after a quiet period. A touch that turns into a drag is renamed after
7
+ // the scroll view it moved, not the row under the finger, so the name is the
8
+ // same in every run.
9
+
10
+ import { AppState, Keyboard } from 'react-native';
11
+
12
+ import { type CauseRoot, currentCause } from './causes';
13
+ import {
14
+ entries,
15
+ fmt,
16
+ isFaultTarget,
17
+ isWindowOpen,
18
+ lastRoot,
19
+ markers,
20
+ now,
21
+ perfLog,
22
+ runDeferredFault,
23
+ runOpenFaults,
24
+ settings,
25
+ writeRunEvent,
26
+ } from './core';
27
+ import {
28
+ describeScrollContainer,
29
+ describeTarget,
30
+ type Fiber,
31
+ findInTree,
32
+ hostFiber,
33
+ propsOf,
34
+ type ScrollContainer,
35
+ type TargetDescription,
36
+ } from './fiber';
37
+
38
+ const DRAG_SLOP_PT = 10;
39
+ const LONG_PRESS_MS = 500;
40
+
41
+ interface RawEvent {
42
+ eventName: string;
43
+ nativeEvent: Record<string, unknown>;
44
+ }
45
+
46
+ interface Emitter {
47
+ addListener(type: string, listener: (event: RawEvent) => void): unknown;
48
+ }
49
+
50
+ interface ActivePress {
51
+ key: string;
52
+ seq: number;
53
+ target: TargetDescription;
54
+ host: Fiber | null;
55
+ container: ScrollContainer | null;
56
+ identifier: unknown;
57
+ touchStartT: number;
58
+ touchEndT: number | null;
59
+ startX: number;
60
+ startY: number;
61
+ maxMovePt: number;
62
+ maxDxPt: number;
63
+ maxDyPt: number;
64
+ scrolled: boolean;
65
+ /** The touch was cancelled by the scroll view above it (it took the gesture, e.g. on a list still moving from a fling). */
66
+ takenByScroll: boolean;
67
+ focused: boolean;
68
+ kind: 'press' | 'drag' | null;
69
+ faultsRun: boolean;
70
+ }
71
+
72
+ const rawStats = {
73
+ events: {} as Record<string, number>,
74
+ listenerN: 0,
75
+ listenerSumMs: 0,
76
+ listenerMaxMs: 0,
77
+ errors: 0,
78
+ };
79
+
80
+ export const interactionStats = () => ({
81
+ ...rawStats,
82
+ events: { ...rawStats.events },
83
+ });
84
+
85
+ let seq = 0;
86
+ let active: ActivePress | null = null;
87
+ // `cause`: the touch's own cause root (the cause tracker's listener runs before this one).
88
+ let lastPress: { t0: number; name: string; cause: CauseRoot | null } | null = null;
89
+
90
+ export const lastPressStart = () => lastPress;
91
+
92
+ export const resetLastPressForTest = () => {
93
+ lastPress = null;
94
+ };
95
+
96
+ const pressStartListeners = new Set<(t0: number) => void>();
97
+
98
+ export const onPressStart = (listener: (t0: number) => void) => {
99
+ pressStartListeners.add(listener);
100
+ };
101
+
102
+ // react-native-screens lifecycle events (and RN Modal `show`, kept for S4).
103
+ // The listener only buffers them; names are resolved later, off this path.
104
+ const NATIVE_SCREEN_EVENTS = new Set([
105
+ 'willAppear',
106
+ 'appear',
107
+ 'willDisappear',
108
+ 'disappear',
109
+ 'dismissed',
110
+ 'sheetDetentChanged',
111
+ 'finishTransitioning',
112
+ 'gestureCancel',
113
+ 'nativeDismissCancelled',
114
+ 'show',
115
+ ]);
116
+
117
+ export interface NativeScreenEvent {
118
+ type: string;
119
+ tag: number | null;
120
+ // Native creation time (Fabric `timeStamp`), performance.now() timebase.
121
+ t: number;
122
+ deliveredT: number;
123
+ host: Fiber | null;
124
+ hostType: string | null;
125
+ resolution: unknown;
126
+ }
127
+
128
+ const MAX_NATIVE_EVENTS = 200;
129
+ export const nativeScreenEvents: NativeScreenEvent[] = [];
130
+ const nativeLag = { n: 0, sumMs: 0, maxMs: 0, missingTimeStamp: 0 };
131
+ const nativeEventListeners = new Set<(event: NativeScreenEvent) => void>();
132
+
133
+ export const onNativeScreenEvent = (
134
+ listener: (event: NativeScreenEvent) => void,
135
+ ) => {
136
+ nativeEventListeners.add(listener);
137
+ };
138
+
139
+ export const nativeLagStats = () => ({
140
+ n: nativeLag.n,
141
+ meanMs: nativeLag.n === 0 ? null : nativeLag.sumMs / nativeLag.n,
142
+ maxMs: nativeLag.maxMs,
143
+ missingTimeStamp: nativeLag.missingTimeStamp,
144
+ });
145
+
146
+ const onNativeScreen = (type: string, ne: Record<string, unknown>) => {
147
+ const deliveredT = now();
148
+ const stamp = num(ne.timeStamp);
149
+ const tag = num(ne.target);
150
+ const host = tag === null ? null : hostFiber(tag);
151
+ if (stamp === null) {
152
+ nativeLag.missingTimeStamp++;
153
+ } else {
154
+ const lag = deliveredT - stamp;
155
+ nativeLag.n++;
156
+ nativeLag.sumMs += lag;
157
+ nativeLag.maxMs = Math.max(nativeLag.maxMs, lag);
158
+ }
159
+ const event: NativeScreenEvent = {
160
+ type,
161
+ tag,
162
+ t: stamp ?? deliveredT,
163
+ deliveredT,
164
+ host,
165
+ hostType: host && typeof host.type === 'string' ? host.type : null,
166
+ resolution: null,
167
+ };
168
+ nativeScreenEvents.push(event);
169
+ if (nativeScreenEvents.length > MAX_NATIVE_EVENTS) {
170
+ nativeScreenEvents.splice(0, nativeScreenEvents.length - MAX_NATIVE_EVENTS);
171
+ }
172
+ for (const listener of nativeEventListeners) {
173
+ listener(event);
174
+ }
175
+ };
176
+
177
+ const num = (value: unknown): number | null =>
178
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
179
+
180
+ const shortType = (eventName: string) =>
181
+ eventName.startsWith('top')
182
+ ? eventName.charAt(3).toLowerCase() + eventName.slice(4)
183
+ : eventName;
184
+
185
+ const pressName = (press: ActivePress) => `press:${press.target.name}`;
186
+
187
+ const dragName = (press: ActivePress) =>
188
+ `drag:${press.container?.name ?? press.target.name}`;
189
+
190
+ const dragAxis = (press: ActivePress) => {
191
+ if (press.maxDxPt === press.maxDyPt) {
192
+ return null;
193
+ }
194
+ return press.maxDxPt > press.maxDyPt ? 'x' : 'y';
195
+ };
196
+
197
+ const eventEntriesFor = (press: ActivePress) => {
198
+ const from = press.touchStartT - 1;
199
+ const to = (press.touchEndT ?? press.touchStartT) + 1;
200
+ return entries.filter(
201
+ e => e.entryType === 'event' && e.startTime >= from && e.startTime <= to,
202
+ );
203
+ };
204
+
205
+ const details = (press: ActivePress) => () => {
206
+ const events = eventEntriesFor(press);
207
+ const eventEnd = events.reduce<number | null>(
208
+ (max, e) =>
209
+ max === null
210
+ ? e.startTime + e.duration
211
+ : Math.max(max, e.startTime + e.duration),
212
+ null,
213
+ );
214
+ return {
215
+ gesture: press.kind ?? 'press',
216
+ target: press.target,
217
+ ...(press.kind === 'drag'
218
+ ? {
219
+ touched: press.target.name,
220
+ container: press.container,
221
+ nameSource: press.container
222
+ ? `container.${press.container.source}`
223
+ : 'fallback.touched',
224
+ }
225
+ : {}),
226
+ touchStartT: press.touchStartT,
227
+ touchEndT: press.touchEndT,
228
+ holdMs:
229
+ press.touchEndT === null ? null : press.touchEndT - press.touchStartT,
230
+ longPress:
231
+ press.touchEndT !== null &&
232
+ press.touchEndT - press.touchStartT >= LONG_PRESS_MS,
233
+ maxMovePt: press.maxMovePt,
234
+ scrolled: press.scrolled,
235
+ takenByScroll: press.takenByScroll,
236
+ focused: press.focused,
237
+ eventEntries: events.map(e => ({
238
+ name: e.name,
239
+ startTime: e.startTime,
240
+ duration: e.duration,
241
+ processingStart: e.processingStart ?? null,
242
+ processingEnd: e.processingEnd ?? null,
243
+ })),
244
+ // First visual response: native mount of the first commit the touch caused.
245
+ eventEndT: eventEnd,
246
+ eventEndMs: eventEnd === null ? null : eventEnd - press.touchStartT,
247
+ rawListener: {
248
+ n: rawStats.listenerN,
249
+ sumMs: rawStats.listenerSumMs,
250
+ maxMs: rawStats.listenerMaxMs,
251
+ },
252
+ };
253
+ };
254
+
255
+ const finishPress = (press: ActivePress, reason: string, at?: number) => {
256
+ if (isWindowOpen(press.key)) {
257
+ markers.end(press.key, reason, at);
258
+ if (press.faultsRun) {
259
+ runDeferredFault(press.target.name);
260
+ }
261
+ }
262
+ if (active === press) {
263
+ active = null;
264
+ }
265
+ };
266
+
267
+ const onTouchStart = (ne: Record<string, unknown>) => {
268
+ const touches = ne.touches;
269
+ if (Array.isArray(touches) && touches.length > 1) {
270
+ // Extra fingers belong to the gesture already being tracked.
271
+ return;
272
+ }
273
+ const tag = num(ne.target);
274
+ const touchStartT = num(ne.timestamp) ?? now();
275
+ if (active) {
276
+ // A press whose touchEnd never reached JS (view unmounted mid-touch) is still active here, with the only finger down
277
+ // being this new one: the new touch ends it.
278
+ finishPress(
279
+ active,
280
+ active.touchEndT === null ? 'touch-lost' : 'next-press',
281
+ touchStartT,
282
+ );
283
+ }
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);
296
+ seq++;
297
+ const press: ActivePress = {
298
+ key: `press#${seq}`,
299
+ seq,
300
+ target,
301
+ host: tag === null ? null : hostFiber(tag),
302
+ container: null,
303
+ identifier: ne.identifier,
304
+ touchStartT,
305
+ touchEndT: null,
306
+ startX: num(ne.pageX) ?? 0,
307
+ startY: num(ne.pageY) ?? 0,
308
+ maxMovePt: 0,
309
+ maxDxPt: 0,
310
+ maxDyPt: 0,
311
+ scrolled: false,
312
+ takenByScroll: false,
313
+ focused: false,
314
+ kind: null,
315
+ faultsRun: false,
316
+ };
317
+ active = press;
318
+ const name = pressName(press);
319
+ lastPress = { t0: touchStartT, name, cause: currentCause() };
320
+ for (const listener of pressStartListeners) {
321
+ listener(touchStartT);
322
+ }
323
+ markers.start(name, {
324
+ key: press.key,
325
+ kind: 'press',
326
+ at: touchStartT,
327
+ details: details(press),
328
+ });
329
+ };
330
+
331
+ const onTouchMove = (ne: Record<string, unknown>) => {
332
+ const press = active;
333
+ if (
334
+ !press ||
335
+ press.touchEndT !== null ||
336
+ ne.identifier !== press.identifier
337
+ ) {
338
+ return;
339
+ }
340
+ const x = num(ne.pageX);
341
+ const y = num(ne.pageY);
342
+ if (x === null || y === null) {
343
+ return;
344
+ }
345
+ press.maxMovePt = Math.max(
346
+ press.maxMovePt,
347
+ Math.hypot(x - press.startX, y - press.startY),
348
+ );
349
+ press.maxDxPt = Math.max(press.maxDxPt, Math.abs(x - press.startX));
350
+ press.maxDyPt = Math.max(press.maxDyPt, Math.abs(y - press.startY));
351
+ };
352
+
353
+ let queryClient: { refetchQueries?: unknown; getQueryCache?: unknown } | null =
354
+ null;
355
+
356
+ // Finds the app's TanStack QueryClient through QueryClientProvider's `client`
357
+ // prop, so the dupnet fault needs no import from the app.
358
+ const findQueryClient = () => {
359
+ if (queryClient) {
360
+ return queryClient;
361
+ }
362
+ const root = lastRoot(1);
363
+ if (!root) {
364
+ return null;
365
+ }
366
+ queryClient = findInTree(root.current, (fiber: Fiber) => {
367
+ const client = propsOf(fiber)?.client as
368
+ | { getQueryCache?: unknown; refetchQueries?: unknown }
369
+ | undefined;
370
+ return client &&
371
+ typeof client.getQueryCache === 'function' &&
372
+ typeof client.refetchQueries === 'function'
373
+ ? client
374
+ : null;
375
+ }) as typeof queryClient;
376
+ return queryClient;
377
+ };
378
+
379
+ interface QueryLike {
380
+ isActive(): boolean;
381
+ queryKey: unknown;
382
+ state: { status: string; dataUpdatedAt: number };
383
+ }
384
+
385
+ const duplicateLatestQuery = () => {
386
+ const client = findQueryClient() as {
387
+ getQueryCache(): { getAll(): QueryLike[] };
388
+ refetchQueries(filters: Record<string, unknown>): Promise<unknown>;
389
+ } | null;
390
+ if (!client) {
391
+ perfLog('fault dupnet no QueryClient in tree');
392
+ return;
393
+ }
394
+ const latest = client
395
+ .getQueryCache()
396
+ .getAll()
397
+ .filter(query => query.isActive() && query.state.status === 'success')
398
+ .sort((a, b) => b.state.dataUpdatedAt - a.state.dataUpdatedAt)[0];
399
+ if (!latest) {
400
+ perfLog('fault dupnet no active query');
401
+ return;
402
+ }
403
+ markers.point('fault.dupnet');
404
+ perfLog(`fault dupnet key=${JSON.stringify(latest.queryKey)}`);
405
+ client
406
+ .refetchQueries({ queryKey: latest.queryKey, exact: true })
407
+ .catch(() => undefined);
408
+ };
409
+
410
+ const onTouchEnd = (ne: Record<string, unknown>, cancelled: boolean) => {
411
+ const press = active;
412
+ if (!press || press.touchEndT !== null) {
413
+ return;
414
+ }
415
+ if (Array.isArray(ne.touches) && ne.touches.length > 0) {
416
+ return;
417
+ }
418
+ press.touchEndT = num(ne.timestamp) ?? now();
419
+ // 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
+ // 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);
428
+ return;
429
+ }
430
+ if (cancelled) {
431
+ markers.point('touch.cancel');
432
+ }
433
+ const name = pressName(press);
434
+ if (!cancelled && isFaultTarget(name)) {
435
+ press.faultsRun = true;
436
+ runOpenFaults(name);
437
+ if (settings.faults.dupnet > 0) {
438
+ duplicateLatestQuery();
439
+ }
440
+ }
441
+ };
442
+
443
+ const onRawEvent = (event: RawEvent) => {
444
+ const t0 = now();
445
+ try {
446
+ const type = shortType(event.eventName);
447
+ rawStats.events[type] = (rawStats.events[type] ?? 0) + 1;
448
+ const ne = event.nativeEvent ?? {};
449
+ switch (type) {
450
+ case 'touchStart':
451
+ onTouchStart(ne);
452
+ break;
453
+ case 'touchMove':
454
+ onTouchMove(ne);
455
+ break;
456
+ case 'touchEnd':
457
+ onTouchEnd(ne, false);
458
+ break;
459
+ case 'touchCancel':
460
+ onTouchEnd(ne, true);
461
+ break;
462
+ case 'scroll':
463
+ case 'scrollBeginDrag':
464
+ if (active && active.touchEndT === null) {
465
+ active.scrolled = true;
466
+ }
467
+ break;
468
+ case 'focus':
469
+ if (active) {
470
+ active.focused = true;
471
+ markers.point('focus');
472
+ }
473
+ break;
474
+ default:
475
+ if (NATIVE_SCREEN_EVENTS.has(type)) {
476
+ onNativeScreen(type, ne);
477
+ }
478
+ break;
479
+ }
480
+ } catch (error) {
481
+ rawStats.errors++;
482
+ if (rawStats.errors <= 3) {
483
+ perfLog(`raw event error ${String(error)}`);
484
+ }
485
+ }
486
+ const cost = now() - t0;
487
+ rawStats.listenerN++;
488
+ rawStats.listenerSumMs += cost;
489
+ if (cost > rawStats.listenerMaxMs) {
490
+ rawStats.listenerMaxMs = cost;
491
+ }
492
+ };
493
+
494
+ const loadRawEventEmitter = (): Emitter | null => {
495
+ try {
496
+ // Private RN module (exposed to the renderer via ReactNativePrivateInterface).
497
+ const mod = require('react-native/Libraries/Core/RawEventEmitter') as {
498
+ default?: Emitter;
499
+ } & Emitter;
500
+ return mod.default ?? mod;
501
+ } catch (error) {
502
+ perfLog(`RawEventEmitter unavailable ${String(error)}`);
503
+ return null;
504
+ }
505
+ };
506
+
507
+ let installed = false;
508
+
509
+ export const installInteractions = () => {
510
+ if (installed) {
511
+ return;
512
+ }
513
+ installed = true;
514
+ const emitter = loadRawEventEmitter();
515
+ if (!emitter) {
516
+ return;
517
+ }
518
+ emitter.addListener('*', onRawEvent);
519
+ Keyboard.addListener('keyboardDidShow', () => {
520
+ markers.point('keyboard.didShow');
521
+ });
522
+ AppState.addEventListener('change', state => {
523
+ // Derivation reads these to tell an iOS GC command (a link that may pass through inactive) from a clean one.
524
+ writeRunEvent('appstate', { state });
525
+ if (state === 'background') {
526
+ markers.endAll('background');
527
+ active = null;
528
+ }
529
+ });
530
+ perfLog(`interactions installed t=${fmt(now())}`);
531
+ };