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

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,586 @@
1
+ // Screen visits. The focused route chain comes from the navigation container's
2
+ // state (nav.ts); without a container, from the screen fibers' `route` props.
3
+ // Every chain change is a visit `screen:<chain>#<n>`, where n counts visits of
4
+ // that chain since launch. A visit starts at the touch, link, dispatch or state
5
+ // time that caused it and ends at the next action (touch, link or dispatch), the
6
+ // next visit, when the app goes to the background, or when the run is flushed;
7
+ // never after a time limit. The navigator reports a visit only after the commit that mounted the
8
+ // screen, so a visit caused by a link or a dispatch is back-dated to it and the
9
+ // window takes the commits since then from the ring buffer (core.ts backfill).
10
+ // react-native-screens' native will/did appear times are attached as
11
+ // information; they never decide the window's end.
12
+ // The first visit of the process is the launch screen (REQ-MEAS-22): it starts
13
+ // when the app started its first surface, whatever opened it (a plain launch,
14
+ // the launch URL or a touch), so it holds the whole launch on every delivery path.
15
+
16
+ import {
17
+ fmt,
18
+ isWindowOpen,
19
+ markers,
20
+ now,
21
+ onAfterCommit,
22
+ onWindowsEnd,
23
+ perfLog,
24
+ setRetainFloor,
25
+ settings,
26
+ } from './core';
27
+ import { retainFloorOf } from './retain-floor';
28
+ import { type CauseRoot, currentCause, latestActionAt, launchedAt, launchRootId } from './causes';
29
+ import { routeOfScreenHost, routes } from './fiber';
30
+ import {
31
+ lastPressStart,
32
+ type NativeScreenEvent,
33
+ nativeLagStats,
34
+ nativeScreenEvents,
35
+ onNativeScreenEvent,
36
+ onPressStart,
37
+ } from './interactions';
38
+ import {
39
+ consumeAction,
40
+ containerCount,
41
+ type NavAction,
42
+ type NavSnapshot,
43
+ navStats,
44
+ onNavAction,
45
+ onNavSnapshot,
46
+ type RoutePart,
47
+ recordNavCost,
48
+ resolveRouteKey,
49
+ } from './nav';
50
+
51
+ // Information only (the native appear times attached to a visit), never a window edge.
52
+ const NATIVE_MATCH_SLACK_MS = 100;
53
+ // Information only: native screen events are named after the state that holds their key has been read.
54
+ const RESOLVE_DELAY_MS = 250;
55
+ const MAX_LISTED = 100;
56
+
57
+ type StartSource = 'launch' | 'touch' | 'link' | 'action' | 'cause' | 'state';
58
+ type NameSource = 'state' | 'routeProps';
59
+
60
+ interface Visit {
61
+ key: string;
62
+ name: string;
63
+ chain: RoutePart[];
64
+ chainKey: string;
65
+ keyChain: string;
66
+ visit: number;
67
+ t0: number;
68
+ openedAt: number;
69
+ endedAt: number | null;
70
+ startSource: StartSource;
71
+ actionType: string | null;
72
+ stateT: number;
73
+ touchName: string | null;
74
+ nameSource: NameSource;
75
+ extendedFrom: string | null;
76
+ launch: boolean;
77
+ cause: CauseRoot | null;
78
+ }
79
+
80
+ interface Resolution {
81
+ screenId: string | null;
82
+ routeKey: string | null;
83
+ name: string | null;
84
+ path: string | null;
85
+ resolved: boolean;
86
+ via: 'screenId' | 'route' | null;
87
+ }
88
+
89
+ let current: Visit | null = null;
90
+ let seq = 0;
91
+ const visitsByChain = new Map<string, number>();
92
+ let lastActionAt = -Infinity;
93
+ interface AppLink {
94
+ t: number;
95
+ via: string;
96
+ url: string;
97
+ cause: CauseRoot | null;
98
+ }
99
+ let pendingLink: AppLink | null = null;
100
+ let lastLinkAt = -Infinity;
101
+ let initialLinkSeen = false;
102
+ let checkScheduled = false;
103
+ let launchVisitOpened = false;
104
+ let windowsEndedSinceStart = false;
105
+ let windowsEndedAt = -Infinity;
106
+ type Press = NonNullable<ReturnType<typeof lastPressStart>>;
107
+ // Touches since the current visit opened: a later visit may have been caused by any of them (two quick taps).
108
+ let pressesSinceVisit: Press[] = [];
109
+ // One URL event reaches every app listener in the same task; only the first delivery counts.
110
+ let linkTaskOpen = false;
111
+
112
+ const stats = {
113
+ visits: 0,
114
+ extended: 0,
115
+ sameNamesNewKeys: 0,
116
+ links: 0,
117
+ startSources: { launch: 0, touch: 0, link: 0, action: 0, cause: 0, state: 0 } as Record<
118
+ StartSource,
119
+ number
120
+ >,
121
+ nameSources: { state: 0, routeProps: 0 } as Record<NameSource, number>,
122
+ ends: {} as Record<string, number>,
123
+ firstVisitAt: null as number | null,
124
+ native: {
125
+ byType: {} as Record<string, number>,
126
+ resolved: 0,
127
+ unresolved: 0,
128
+ screenIds: new Map<string, { resolved: boolean; name: string | null }>(),
129
+ },
130
+ };
131
+
132
+ const keyChainOf = (chain: RoutePart[]) =>
133
+ chain.map(part => part.key ?? part.name).join('\u0001');
134
+
135
+ const resolveEvent = (event: NativeScreenEvent): Resolution => {
136
+ if (event.resolution) {
137
+ return event.resolution as Resolution;
138
+ }
139
+ const host = event.host;
140
+ const route = host
141
+ ? routeOfScreenHost(host)
142
+ : { screenId: null, routeKey: null, routeName: null };
143
+ const indexed = route.routeKey ? resolveRouteKey(route.routeKey) : null;
144
+ const resolution: Resolution = {
145
+ screenId: route.screenId,
146
+ routeKey: route.routeKey,
147
+ name: indexed?.name ?? route.routeName,
148
+ path: indexed?.path ?? null,
149
+ resolved: indexed !== null,
150
+ via: route.screenId ? 'screenId' : route.routeKey ? 'route' : null,
151
+ };
152
+ // An unresolved result is retried later: the state that holds the key may
153
+ // not have been read yet.
154
+ if (resolution.resolved) {
155
+ event.resolution = resolution;
156
+ }
157
+ return resolution;
158
+ };
159
+
160
+ const logNativeEvent = (event: NativeScreenEvent) => {
161
+ const r = resolveEvent(event);
162
+ stats.native.byType[event.type] = (stats.native.byType[event.type] ?? 0) + 1;
163
+ if (r.resolved) {
164
+ stats.native.resolved++;
165
+ } else {
166
+ stats.native.unresolved++;
167
+ }
168
+ if (r.screenId && stats.native.screenIds.size < MAX_LISTED) {
169
+ stats.native.screenIds.set(r.screenId, {
170
+ resolved: r.resolved,
171
+ name: r.path ?? r.name,
172
+ });
173
+ }
174
+ perfLog(
175
+ `screen.native type=${event.type} tag=${event.tag ?? '-'} host=${event.hostType ?? '-'} t=${fmt(event.t)} lagMs=${fmt(event.deliveredT - event.t)} screenId=${r.screenId ?? '-'} key=${r.routeKey ?? '-'} name=${r.path ?? r.name ?? '-'} resolved=${r.resolved} via=${r.via ?? '-'}`,
176
+ );
177
+ };
178
+
179
+ const nativeFor = (visit: Visit) => {
180
+ const from = visit.t0 - NATIVE_MATCH_SLACK_MS;
181
+ const to = visit.endedAt ?? now();
182
+ const keys = new Set(
183
+ visit.chain.map(part => part.key).filter((key): key is string => !!key),
184
+ );
185
+ const inWindow = nativeScreenEvents.filter(e => e.t >= from && e.t <= to);
186
+ const matched = inWindow.filter(e => {
187
+ const key = resolveEvent(e).routeKey;
188
+ return key !== null && keys.has(key);
189
+ });
190
+ const appearCount = inWindow.filter(e => e.type === 'appear').length;
191
+ if (matched.length === 0) {
192
+ return inWindow.length === 0
193
+ ? null
194
+ : { matched: 0, inWindow: inWindow.length, appearCount };
195
+ }
196
+ const first = (type: string) => matched.find(e => e.type === type)?.t ?? null;
197
+ const willAppearT = first('willAppear');
198
+ const didAppearT = first('appear');
199
+ const screen = resolveEvent(
200
+ matched.find(e => e.type === 'appear' || e.type === 'willAppear') ??
201
+ (matched[0] as NativeScreenEvent),
202
+ );
203
+ return {
204
+ matched: matched.length,
205
+ inWindow: inWindow.length,
206
+ appearCount,
207
+ screenId: screen.screenId,
208
+ routeKey: screen.routeKey,
209
+ resolved: screen.resolved,
210
+ willAppearT,
211
+ didAppearT,
212
+ // Informational only: mostly the configured animation length.
213
+ transitionMs:
214
+ willAppearT !== null && didAppearT !== null
215
+ ? didAppearT - willAppearT
216
+ : null,
217
+ events: matched.slice(0, 20).map(e => {
218
+ const r = resolveEvent(e);
219
+ return {
220
+ type: e.type,
221
+ t: e.t,
222
+ lagMs: e.deliveredT - e.t,
223
+ key: r.routeKey,
224
+ name: r.path ?? r.name,
225
+ };
226
+ }),
227
+ };
228
+ };
229
+
230
+ const details = (visit: Visit) => () => ({
231
+ chain: visit.chain,
232
+ chainKey: visit.chainKey,
233
+ visit: visit.visit,
234
+ nameSource: visit.nameSource,
235
+ startSource: visit.startSource,
236
+ actionType: visit.actionType,
237
+ stateT: visit.stateT,
238
+ detectedAt: visit.openedAt,
239
+ touchName: visit.touchName,
240
+ extendedFrom: visit.extendedFrom,
241
+ launch: visit.launch,
242
+ cause: visit.cause ? `${visit.cause.source}#${visit.cause.id}` : null,
243
+ native: nativeFor(visit),
244
+ });
245
+
246
+ const endVisit = (reason: string, at?: number) => {
247
+ const visit = current;
248
+ if (!visit || visit.endedAt !== null) {
249
+ return;
250
+ }
251
+ const t = Math.max(visit.t0, at ?? now());
252
+ visit.endedAt = t;
253
+ stats.ends[reason] = (stats.ends[reason] ?? 0) + 1;
254
+ if (isWindowOpen(visit.key)) {
255
+ markers.end(visit.key, reason, t);
256
+ }
257
+ };
258
+
259
+ // A visit starts at the action that caused it (REQ-MEAS-28: events, not time limits). The cause tracker names the action
260
+ // behind the state change; the visit starts at that touch, link or dispatch. With no known cause it starts at the first
261
+ // touch, link or dispatch since the previous visit opened (the one that ended it, so the mount is inside), else at the
262
+ // state event.
263
+ const chooseStart = (snapshot: NavSnapshot, floor: number) => {
264
+ const inSpan = (t: number) => t >= floor && t <= snapshot.stateT;
265
+ const presses = pressesSinceVisit.filter(p => inSpan(p.t0));
266
+ const link = pendingLink && inSpan(pendingLink.t) ? pendingLink : null;
267
+ const action = snapshot.action && inSpan(snapshot.action.t) ? snapshot.action : null;
268
+ const touchStart = (p: Press) => ({
269
+ t0: p.t0,
270
+ source: 'touch' as const,
271
+ touchName: p.name,
272
+ action,
273
+ link: null,
274
+ });
275
+ const linkStart = (l: AppLink) => ({
276
+ t0: l.t,
277
+ source: 'link' as const,
278
+ touchName: null,
279
+ action,
280
+ link: l,
281
+ });
282
+ const actionStart = (a: NavAction) => ({
283
+ t0: a.t,
284
+ source: 'action' as const,
285
+ touchName: null,
286
+ action: a,
287
+ link: null,
288
+ });
289
+ const stateStart = () => ({
290
+ t0: snapshot.stateT,
291
+ source: 'state' as const,
292
+ touchName: null,
293
+ action: null,
294
+ link: null,
295
+ });
296
+ const cause = snapshot.cause;
297
+ if (cause) {
298
+ const press = presses.find(p => p.cause?.id === cause.id);
299
+ if (press) {
300
+ return touchStart(press);
301
+ }
302
+ if (link && link.cause?.id === cause.id) {
303
+ return linkStart(link);
304
+ }
305
+ if (action && action.cause?.id === cause.id) {
306
+ return actionStart(action);
307
+ }
308
+ // A cause with no event of its own here (data pushed to the app, typing, the launch's own code) starts the visit
309
+ // at its own time, when that is after the previous visit opened.
310
+ return inSpan(cause.t)
311
+ ? { t0: cause.t, source: 'cause' as const, touchName: null, action: null, link: null }
312
+ : stateStart();
313
+ }
314
+ const first = [
315
+ ...presses.map(p => ({ t: p.t0, start: () => touchStart(p) })),
316
+ link ? { t: link.t, start: () => linkStart(link) } : null,
317
+ action ? { t: action.t, start: () => actionStart(action) } : null,
318
+ ]
319
+ .filter((c): c is { t: number; start: () => ReturnType<typeof touchStart> } => c !== null)
320
+ .sort((x, y) => x.t - y.t)[0];
321
+ return first ? first.start() : stateStart();
322
+ };
323
+
324
+ const nameOf = (chainKey: string, visit: number) =>
325
+ `screen:${chainKey}#${visit}`;
326
+
327
+ const isStrictExtension = (prev: RoutePart[], next: RoutePart[]) =>
328
+ next.length > prev.length &&
329
+ prev.every(
330
+ (part, i) =>
331
+ part.name === next[i]?.name &&
332
+ (part.key ?? null) === (next[i]?.key ?? null),
333
+ );
334
+
335
+ const extend = (visit: Visit, chain: RoutePart[], chainKey: string) => {
336
+ const previous = visitsByChain.get(visit.chainKey) ?? 1;
337
+ visitsByChain.set(visit.chainKey, previous - 1);
338
+ const n = (visitsByChain.get(chainKey) ?? 0) + 1;
339
+ visitsByChain.set(chainKey, n);
340
+ visit.extendedFrom ??= visit.chainKey;
341
+ visit.chain = chain;
342
+ visit.chainKey = chainKey;
343
+ visit.keyChain = keyChainOf(chain);
344
+ visit.visit = n;
345
+ visit.name = nameOf(chainKey, n);
346
+ markers.rename(visit.key, visit.name);
347
+ stats.extended++;
348
+ };
349
+
350
+ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
351
+ if (!settings.hook || snapshot.chain.length === 0) {
352
+ return;
353
+ }
354
+ const chain = snapshot.chain;
355
+ const chainKey = chain.map(part => part.name).join('/');
356
+ const keyChain = keyChainOf(chain);
357
+ const prev = current;
358
+ if (prev && prev.keyChain === keyChain) {
359
+ return;
360
+ }
361
+ if (prev && prev.chainKey === chainKey) {
362
+ stats.sameNamesNewKeys++;
363
+ }
364
+ const detectedAt = now();
365
+ // A chain that only grows with no action since the visit opened, under the same cause, is the same navigation
366
+ // finishing (a nested navigator reporting its initial route), not a new visit.
367
+ if (
368
+ prev &&
369
+ prev.endedAt === null &&
370
+ (prev.cause === null ||
371
+ snapshot.cause === null ||
372
+ prev.cause.id === snapshot.cause.id) &&
373
+ lastActionAt <= prev.openedAt &&
374
+ lastLinkAt <= prev.openedAt &&
375
+ (lastPressStart()?.t0 ?? -Infinity) <= prev.openedAt &&
376
+ isStrictExtension(prev.chain, chain)
377
+ ) {
378
+ perfLog(`screen ${prev.name} extended -> ${chainKey} t=${fmt(detectedAt)}`);
379
+ extend(prev, chain, chainKey);
380
+ return;
381
+ }
382
+ endVisit('next-screen', detectedAt);
383
+ const launch = !launchVisitOpened;
384
+ launchVisitOpened = true;
385
+ const launchT = launch ? launchedAt() : null;
386
+ const start =
387
+ launchT !== null
388
+ ? {
389
+ t0: launchT,
390
+ source: 'launch' as const,
391
+ touchName: null,
392
+ action: null,
393
+ link: pendingLink,
394
+ }
395
+ : chooseStart(snapshot, Math.max(prev?.openedAt ?? -Infinity, windowsEndedAt));
396
+ consumeAction();
397
+ pendingLink = null;
398
+ const n = (visitsByChain.get(chainKey) ?? 0) + 1;
399
+ visitsByChain.set(chainKey, n);
400
+ seq++;
401
+ const visit: Visit = {
402
+ key: `screen#${seq}`,
403
+ name: nameOf(chainKey, n),
404
+ chain,
405
+ chainKey,
406
+ keyChain,
407
+ visit: n,
408
+ t0: start.t0,
409
+ openedAt: detectedAt,
410
+ endedAt: null,
411
+ startSource: start.source,
412
+ actionType: start.action?.type ?? null,
413
+ stateT: snapshot.stateT,
414
+ touchName: start.touchName,
415
+ nameSource,
416
+ extendedFrom: null,
417
+ launch,
418
+ cause: snapshot.cause,
419
+ };
420
+ current = visit;
421
+ pressesSinceVisit = [];
422
+ stats.visits++;
423
+ stats.startSources[start.source]++;
424
+ stats.nameSources[nameSource]++;
425
+ stats.firstVisitAt ??= detectedAt;
426
+ perfLog(
427
+ `screen ${prev?.name ?? '-'} -> ${visit.name} source=${start.source}${start.link ? ` link=${start.link.via}` : ''}${visit.actionType ? ` action=${visit.actionType}` : ''} names=${nameSource}${launch ? ' launch' : ''} cause=${snapshot.cause ? `${snapshot.cause.source}#${snapshot.cause.id}` : '-'} t0=${fmt(visit.t0)} stateT=${fmt(snapshot.stateT)} detected=${fmt(detectedAt)}`,
428
+ );
429
+ markers.start(visit.name, {
430
+ key: visit.key,
431
+ kind: 'screen',
432
+ at: visit.t0,
433
+ details: details(visit),
434
+ // The launch window belongs to the launch, whatever action came before the first state (REQ-MEAS-22).
435
+ ...(start.source === 'launch' ? { anchor: launchRootId() ?? undefined } : {}),
436
+ });
437
+ };
438
+
439
+ const onAction = (action: NavAction) => {
440
+ lastActionAt = action.t;
441
+ if (current && current.endedAt === null && action.t > current.openedAt) {
442
+ endVisit('next-action', action.t);
443
+ }
444
+ };
445
+
446
+ // Called by the link wrapper (links.ts) before the app sees an app URL. The
447
+ // launch URL is read more than once at startup; only the first read counts.
448
+ export const noteAppLink = (url: string, via: string) => {
449
+ const initial = via !== 'event';
450
+ if (initial && initialLinkSeen) {
451
+ return;
452
+ }
453
+ const t = now();
454
+ if (!initial && pendingLink && pendingLink.url === url && linkTaskOpen) {
455
+ return;
456
+ }
457
+ if (!initial) {
458
+ linkTaskOpen = true;
459
+ Promise.resolve().then(() => {
460
+ linkTaskOpen = false;
461
+ });
462
+ }
463
+ initialLinkSeen ||= initial;
464
+ pendingLink = { t, via, url, cause: currentCause() };
465
+ lastLinkAt = t;
466
+ stats.links++;
467
+ perfLog(`screen link via=${via} t=${fmt(t)}`);
468
+ if (current && current.endedAt === null && t > current.openedAt) {
469
+ endVisit('next-link', t);
470
+ }
471
+ };
472
+
473
+ const onPress = (t0: number) => {
474
+ const press = lastPressStart();
475
+ if (press) {
476
+ pressesSinceVisit.push(press);
477
+ }
478
+ if (current && current.endedAt === null && t0 > current.openedAt) {
479
+ endVisit('next-action', t0);
480
+ }
481
+ };
482
+
483
+ // Fallback when no navigation container was found: the focused chain is
484
+ // rebuilt from the screen fibers' `route` props after each commit.
485
+ const focusedChainFromRoutes = (): RoutePart[] => {
486
+ const chain: { name: string; key: string; depth: number }[] = [];
487
+ for (const entry of routes.values()) {
488
+ let focused = false;
489
+ try {
490
+ focused = entry.navigation.isFocused();
491
+ } catch {
492
+ focused = false;
493
+ }
494
+ if (focused) {
495
+ chain.push({ name: entry.name, key: entry.key, depth: entry.depth });
496
+ }
497
+ }
498
+ chain.sort((a, b) => a.depth - b.depth);
499
+ return chain.map(({ name, key }) => ({ name, key }));
500
+ };
501
+
502
+ const checkRouteProps = () => {
503
+ checkScheduled = false;
504
+ if (!settings.hook || containerCount() > 0) {
505
+ return;
506
+ }
507
+ const t0 = now();
508
+ const chain = focusedChainFromRoutes();
509
+ onSnapshot({ chain, stateT: t0, action: null, cause: currentCause() }, 'routeProps');
510
+ recordNavCost(now() - t0);
511
+ };
512
+
513
+ export const screenStats = () => ({
514
+ mode:
515
+ containerCount() > 0
516
+ ? 'container'
517
+ : routes.size > 0
518
+ ? 'routeProps'
519
+ : 'none',
520
+ current: current?.name ?? null,
521
+ visits: stats.visits,
522
+ firstVisitAt: stats.firstVisitAt,
523
+ extended: stats.extended,
524
+ links: stats.links,
525
+ pressesWaiting: pressesSinceVisit.length,
526
+ sameNamesNewKeys: stats.sameNamesNewKeys,
527
+ startSources: { ...stats.startSources },
528
+ nameSources: { ...stats.nameSources },
529
+ ends: { ...stats.ends },
530
+ visitsByChain: Object.fromEntries(
531
+ [...visitsByChain].filter(([, n]) => n > 0).slice(0, MAX_LISTED),
532
+ ),
533
+ trackedRoutes: routes.size,
534
+ nav: navStats(),
535
+ native: {
536
+ byType: { ...stats.native.byType },
537
+ resolved: stats.native.resolved,
538
+ unresolved: stats.native.unresolved,
539
+ lag: nativeLagStats(),
540
+ screenIds: Object.fromEntries(stats.native.screenIds),
541
+ },
542
+ });
543
+
544
+ const screenRetainFloor = (): number =>
545
+ retainFloorOf(current?.openedAt ?? null, !windowsEndedSinceStart, launchedAt(), latestActionAt());
546
+
547
+ // The flush or the app leaving the foreground ended every window: no later visit starts before it, so the touches and
548
+ // the link waiting for a visit are dropped, and the launch window no longer holds the ring at the app's start.
549
+ const onWindowsEnded = (reason: string) => {
550
+ if (reason !== 'flush' && reason !== 'background') {
551
+ return;
552
+ }
553
+ windowsEndedSinceStart = true;
554
+ windowsEndedAt = now();
555
+ pressesSinceVisit = [];
556
+ pendingLink = null;
557
+ };
558
+
559
+ export const resetLaunchForTest = () => {
560
+ launchVisitOpened = false;
561
+ windowsEndedSinceStart = false;
562
+ pressesSinceVisit = [];
563
+ };
564
+
565
+ let installed = false;
566
+
567
+ export const installScreens = () => {
568
+ if (installed) {
569
+ return;
570
+ }
571
+ installed = true;
572
+ setRetainFloor(screenRetainFloor);
573
+ onWindowsEnd(onWindowsEnded);
574
+ onNavSnapshot(snapshot => onSnapshot(snapshot, 'state'));
575
+ onNavAction(onAction);
576
+ onPressStart(onPress);
577
+ onNativeScreenEvent(event => {
578
+ setTimeout(() => logNativeEvent(event), RESOLVE_DELAY_MS);
579
+ });
580
+ onAfterCommit(() => {
581
+ if (!checkScheduled && containerCount() === 0) {
582
+ checkScheduled = true;
583
+ setTimeout(checkRouteProps, 0);
584
+ }
585
+ });
586
+ };
@@ -0,0 +1,125 @@
1
+ // The commit stream (phase 1 of "the SDK records, the backend decides", REQ-MEAS-32): every React commit of the process
2
+ // once, judged by a window or not, with the facts the backend needs to split work by cause itself: its updates as parts
3
+ // (the roots behind them, the timer run and the store write they came from) and its render time (null when the renderer
4
+ // does not time it). Every root the tracker created and every store write go in tables next to the commits, with the
5
+ // timer runs a part or a write names. Cut into
6
+ // segment files at every flush and when the app leaves the foreground, so the phone never holds a whole run.
7
+
8
+ import type { CauseRoot, CommitCauses, StoreWrite, TimerRun } from './causes';
9
+ import { factIds, factsOf } from './causes';
10
+ import { buildReactExport, type ExportCommit, type ExportFiber } from './profile-export';
11
+
12
+ export interface StreamCommit {
13
+ /** The process's commit count at this commit; windows list the seqs they were given. */
14
+ seq: number;
15
+ t: number;
16
+ /** React's own render time of the commit; null on a renderer that does not time it. */
17
+ renderMs: number | null;
18
+ causes: CommitCauses;
19
+ /** The exported fibers (React export on), else none. */
20
+ fibers: ExportFiber[] | null;
21
+ updaters: string[] | null;
22
+ }
23
+
24
+ /** Fibers a segment keeps; past it, commits keep their facts and lose their fibers. */
25
+ export const MAX_SEGMENT_FIBERS = 200000;
26
+
27
+ export const streamFileName = (processId: string, seq: number) => `stream-${processId}-${seq}.json`;
28
+
29
+ export const createStream = (processId: string, maxFibers = MAX_SEGMENT_FIBERS) => {
30
+ let seq = 0;
31
+ let t0: number | null = null;
32
+ let roots: CauseRoot[] = [];
33
+ let writes: StoreWrite[] = [];
34
+ let commits: StreamCommit[] = [];
35
+ let fibers = 0;
36
+ let fibersDropped = 0;
37
+
38
+ return {
39
+ /** The segment the next commit goes into: windows name it so the backend can tell a stream is complete. */
40
+ seq: () => seq,
41
+ size: () => commits.length,
42
+ noteRoot(root: CauseRoot) {
43
+ roots.push(root);
44
+ },
45
+ noteWrite(write: StoreWrite) {
46
+ writes.push(write);
47
+ },
48
+ noteCommit(commit: StreamCommit) {
49
+ t0 ??= commit.t;
50
+ if (commit.fibers && fibers + commit.fibers.length > maxFibers) {
51
+ fibersDropped += commit.fibers.length;
52
+ commits.push({ ...commit, fibers: null });
53
+ return;
54
+ }
55
+ fibers += commit.fibers?.length ?? 0;
56
+ commits.push(commit);
57
+ },
58
+ /** Builds the segment and starts the next one; null when it holds nothing. */
59
+ cut(t1: number): { name: string; json: string; commits: number; fibersDropped: number } | null {
60
+ if (commits.length === 0 && roots.length === 0 && writes.length === 0) {
61
+ return null;
62
+ }
63
+ const runs = new Map<number, TimerRun>();
64
+ for (const w of writes) if (w.run) runs.set(w.run.id, w.run);
65
+ const exportCommits: ExportCommit[] = commits.map(c => {
66
+ const facts = factsOf(c.causes.set);
67
+ for (const part of facts.parts) if (part.run) runs.set(part.run.id, part.run);
68
+ return {
69
+ t: c.t,
70
+ durationMs: c.renderMs ?? 0,
71
+ updaters: c.updaters,
72
+ causes: [],
73
+ carrier: null,
74
+ store: null,
75
+ task: null,
76
+ fibers: c.fibers ?? [],
77
+ facts: {
78
+ seq: c.seq,
79
+ renderMs: c.renderMs,
80
+ unknown: c.causes.unknown,
81
+ parts: facts.parts.map(p => [[...p.ids], p.run?.id ?? null, p.write?.id ?? null] as [number[], number | null, number | null]),
82
+ more: facts.more,
83
+ fibers: c.fibers !== null,
84
+ },
85
+ };
86
+ });
87
+ const start = t0 ?? roots[0]?.t ?? t1;
88
+ const segment = {
89
+ v: 1,
90
+ kind: 'commit-stream',
91
+ process: processId,
92
+ seq,
93
+ t0: start,
94
+ t1,
95
+ fibersDropped,
96
+ roots: roots.map(r => ({ id: r.id, kind: r.kind, source: r.source, t: r.t })),
97
+ runs: [...runs.values()].map(r => ({
98
+ id: r.id,
99
+ timer: r.timer,
100
+ t: r.t,
101
+ registeredT: r.registeredT,
102
+ registered: factIds(factsOf(r.registered)),
103
+ net: r.net,
104
+ })),
105
+ writes: writes.map(w => ({
106
+ id: w.id,
107
+ store: w.store,
108
+ t: w.t,
109
+ writer: factIds(factsOf(w.writer)),
110
+ run: w.run?.id ?? null,
111
+ })),
112
+ react: buildReactExport(start, exportCommits),
113
+ };
114
+ const out = { name: streamFileName(processId, seq), json: JSON.stringify(segment), commits: commits.length, fibersDropped };
115
+ seq++;
116
+ t0 = null;
117
+ roots = [];
118
+ writes = [];
119
+ commits = [];
120
+ fibers = 0;
121
+ fibersDropped = 0;
122
+ return out;
123
+ },
124
+ };
125
+ };