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

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/profile.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // (actualDuration / selfBaseDuration) and updaters (root.memoizedUpdaters)
5
5
  // exist only when the app runs React's profiling renderer.
6
6
 
7
+ import { MAX_BATCH_BYTES, utf8Length } from './bytes';
7
8
  import {
8
9
  CLASS_COMPONENT,
9
10
  componentName,
@@ -11,6 +12,9 @@ import {
11
12
  FORWARD_REF,
12
13
  FUNCTION_COMPONENT,
13
14
  SIMPLE_MEMO_COMPONENT,
15
+ clientKeyOf,
16
+ isComponentTag,
17
+ OUTSIDE_APP,
14
18
  } from './fiber';
15
19
 
16
20
  export interface HookNode {
@@ -179,7 +183,55 @@ export const updaterNames = (root: ProfiledRoot): string[] | null => {
179
183
  return names.sort();
180
184
  };
181
185
 
182
- export interface ComponentProfile {
186
+ const MAX_OWNER_UP = 512;
187
+ const updaterOwners = (root: ProfiledRoot) => {
188
+ if (!(root.memoizedUpdaters instanceof Set)) return { owners: null, unresolved: 0 };
189
+ const known = new Map<Fiber, string>();
190
+ const unresolvedPaths = new Set<Fiber>();
191
+ const owners: string[] = [];
192
+ let unresolved = 0;
193
+ for (const updater of root.memoizedUpdaters) {
194
+ const path: Fiber[] = [];
195
+ let fiber: Fiber | null = updater, owner = OUTSIDE_APP, capped = false;
196
+ for (let hops = 0; fiber !== null && hops < MAX_OWNER_UP; hops++) {
197
+ const cached = known.get(fiber);
198
+ if (cached !== undefined) {
199
+ owner = cached;
200
+ capped = unresolvedPaths.has(fiber);
201
+ break;
202
+ }
203
+ path.push(fiber);
204
+ const own = isComponentTag(fiber.tag) ? clientKeyOf(fiber) : null;
205
+ if (own !== null) { owner = own; break; }
206
+ fiber = fiber.return;
207
+ if (fiber !== null && hops === MAX_OWNER_UP - 1) capped = true;
208
+ }
209
+ for (const f of path) {
210
+ known.set(f, owner);
211
+ // A capped path stays unresolved for later updaters joining that path.
212
+ if (capped) unresolvedPaths.add(f);
213
+ }
214
+ if (capped) unresolved++;
215
+ owners.push(owner);
216
+ }
217
+ return { owners, unresolved };
218
+ };
219
+
220
+ interface RolledTime { ms: number; renders: number }
221
+ interface RolledRest { ms: number; n: number; saturated?: true }
222
+ interface RestedLibraries { ms: number; names: Set<string>; saturated: boolean }
223
+ const ROLLUP_LIMITS = { trackedNames: 64, restNames: 64 } as const;
224
+ interface RollupDetail {
225
+ rolled: Map<string, RolledTime> | null;
226
+ rolledRest: RestedLibraries | null;
227
+ }
228
+ interface OutsideProfile extends RollupDetail {
229
+ selfMs: number;
230
+ mountSelfMs: number;
231
+ }
232
+ const emptyOutside = (): OutsideProfile => ({ selfMs: 0, mountSelfMs: 0, rolled: null, rolledRest: null });
233
+
234
+ export interface ComponentProfile extends RollupDetail {
183
235
  renders: number;
184
236
  mounts: number;
185
237
  // Distinct instances (fiber pairs) that rendered.
@@ -195,15 +247,25 @@ export interface ComponentProfile {
195
247
  context: number;
196
248
  parent: number;
197
249
  other: number;
250
+ rolledMs: number;
251
+ // These describe the first parent until a second distinct parent needs `parents`.
252
+ firstParent: string | null;
253
+ parentCount: number;
254
+ parents: Map<string | null, number> | null;
198
255
  }
199
256
 
200
257
  export interface CommitProfile {
201
258
  // Index of the commit in this process (1-based), for aligning windows.
202
259
  seq: number;
260
+ // When the commit landed: the hook ran after React rendered it (performance.now), null when the caller does not know it.
261
+ endMs: number | null;
203
262
  timed: boolean;
204
263
  renderMs: number | null;
205
264
  updaters: string[] | null;
265
+ updaterOwners: string[] | null;
266
+ updatersUnresolved: number;
206
267
  components: Map<string, ComponentProfile>;
268
+ outside: OutsideProfile | null;
207
269
  }
208
270
 
209
271
  const emptyComponent = (): ComponentProfile => ({
@@ -220,16 +282,27 @@ const emptyComponent = (): ComponentProfile => ({
220
282
  context: 0,
221
283
  parent: 0,
222
284
  other: 0,
285
+ rolledMs: 0,
286
+ rolled: null,
287
+ rolledRest: null,
288
+ firstParent: null,
289
+ parentCount: 0,
290
+ parents: null,
223
291
  });
224
292
 
225
- export const createCommitProfile = (root: ProfiledRoot, seq = 0): CommitProfile => {
293
+ export const createCommitProfile = (root: ProfiledRoot, seq = 0, endMs: number | null = null): CommitProfile => {
226
294
  const timed = isProfilingFiber(root.current);
295
+ const updaters = timed ? updaterOwners(root) : { owners: null, unresolved: 0 };
227
296
  return {
228
297
  seq,
298
+ endMs,
229
299
  timed,
230
300
  renderMs: timed ? totalMs(root.current) : null,
231
301
  updaters: timed ? updaterNames(root) : null,
302
+ updaterOwners: updaters.owners,
303
+ updatersUnresolved: updaters.unresolved,
232
304
  components: new Map(),
305
+ outside: timed ? emptyOutside() : null,
233
306
  };
234
307
  };
235
308
 
@@ -237,12 +310,84 @@ const bump = (map: Record<string, number>, key: string, by = 1) => {
237
310
  map[key] = (map[key] ?? 0) + by;
238
311
  };
239
312
 
313
+ const noteParent = (entry: ComponentProfile, parent: string | null, count = 1) => {
314
+ if (entry.parents !== null) {
315
+ entry.parents.set(parent, (entry.parents.get(parent) ?? 0) + count);
316
+ } else if (entry.parentCount === 0) {
317
+ entry.firstParent = parent;
318
+ entry.parentCount = count;
319
+ } else if (entry.firstParent === parent) {
320
+ entry.parentCount += count;
321
+ } else {
322
+ entry.parents = new Map([[entry.firstParent, entry.parentCount], [parent, count]]);
323
+ }
324
+ };
325
+
326
+ const restBucket = (into: RollupDetail) =>
327
+ into.rolledRest ??= { ms: 0, names: new Set<string>(), saturated: false };
328
+
329
+ const noteRestName = (rest: RestedLibraries, name: string) => {
330
+ if (!rest.names.has(name)) {
331
+ if (rest.names.size < ROLLUP_LIMITS.restNames) rest.names.add(name);
332
+ else rest.saturated = true;
333
+ }
334
+ };
335
+
336
+ // Keep the same lexicographically first names at both layers. A name omitted
337
+ // from a commit has 64 smaller names ahead of it, so it cannot enter the
338
+ // window's tracked set either. Tracked totals stay complete and can be ranked
339
+ // by time even after the bounded distinct-rest counter saturates.
340
+ const addRolled = (
341
+ into: RollupDetail,
342
+ name: string, ms: number, renders: number,
343
+ ) => {
344
+ const known = into.rolled?.get(name);
345
+ if (known) { known.ms += ms; known.renders += renders; return; }
346
+ const rolled = into.rolled ??= new Map();
347
+ if (rolled.size < ROLLUP_LIMITS.trackedNames) { rolled.set(name, { ms, renders }); return; }
348
+ let last: [string, RolledTime] | null = null;
349
+ for (const pair of rolled) if (!last || pair[0] > last[0]) last = pair;
350
+ const rest = restBucket(into);
351
+ if (last && name < last[0]) {
352
+ rolled.delete(last[0]);
353
+ rolled.set(name, { ms, renders });
354
+ rest.ms += last[1].ms;
355
+ noteRestName(rest, last[0]);
356
+ } else {
357
+ rest.ms += ms;
358
+ noteRestName(rest, name);
359
+ }
360
+ };
361
+
362
+ export const recordRolledUp = (
363
+ commit: CommitProfile, owner: string, parent: string | null, name: string, fiber: ProfiledFiber,
364
+ ) => {
365
+ if (!commit.timed) return;
366
+ const self = selfMs(fiber);
367
+ if (self === null) return;
368
+ if (owner === OUTSIDE_APP) {
369
+ const outside = commit.outside as OutsideProfile;
370
+ outside.selfMs += self;
371
+ if (fiber.alternate === null) outside.mountSelfMs += self;
372
+ addRolled(outside, name, self, 1);
373
+ } else {
374
+ let entry = commit.components.get(owner);
375
+ if (!entry) { entry = emptyComponent(); commit.components.set(owner, entry); }
376
+ entry.selfMs += self;
377
+ entry.rolledMs += self;
378
+ if (fiber.alternate === null) entry.mountSelfMs += self;
379
+ noteParent(entry, parent);
380
+ addRolled(entry, name, self, 1);
381
+ }
382
+ };
383
+
240
384
  export const recordFiber = (
241
385
  commit: CommitProfile,
242
386
  name: string,
243
387
  fiber: ProfiledFiber,
244
388
  cause: UpdateCause,
245
389
  instance?: number,
390
+ parent: string | null = null,
246
391
  ) => {
247
392
  let entry = commit.components.get(name);
248
393
  if (!entry) {
@@ -250,6 +395,7 @@ export const recordFiber = (
250
395
  commit.components.set(name, entry);
251
396
  }
252
397
  entry.renders++;
398
+ noteParent(entry, parent);
253
399
  if (instance !== undefined) {
254
400
  entry.instances.add(instance);
255
401
  }
@@ -299,8 +445,12 @@ export interface WindowProfile {
299
445
  lastSeq: number | null;
300
446
  timedCommits: number;
301
447
  renderMs: number[];
448
+ // The judged commit that set the largest render time (the earliest seq on a tie), kept as the max is updated.
449
+ slowest: { seq: number; endMs: number | null; ms: number } | null;
302
450
  updaters: Record<string, number>;
451
+ updatersUnresolved: number;
303
452
  components: Map<string, ComponentProfile>;
453
+ outside: OutsideProfile;
304
454
  }
305
455
 
306
456
  export const createWindowProfile = (): WindowProfile => ({
@@ -309,8 +459,11 @@ export const createWindowProfile = (): WindowProfile => ({
309
459
  lastSeq: null,
310
460
  timedCommits: 0,
311
461
  renderMs: [],
462
+ slowest: null,
312
463
  updaters: {},
464
+ updatersUnresolved: 0,
313
465
  components: new Map(),
466
+ outside: emptyOutside(),
314
467
  });
315
468
 
316
469
  const mergeCounts = (into: Record<string, number>, from: Record<string, number>) => {
@@ -319,6 +472,16 @@ const mergeCounts = (into: Record<string, number>, from: Record<string, number>)
319
472
  }
320
473
  };
321
474
 
475
+ const mergeRolled = (into: RollupDetail, from: RollupDetail) => {
476
+ for (const [name, time] of from.rolled ?? []) addRolled(into, name, time.ms, time.renders);
477
+ if (from.rolledRest) {
478
+ const rest = restBucket(into);
479
+ for (const name of from.rolledRest.names) noteRestName(rest, name);
480
+ rest.ms += from.rolledRest.ms;
481
+ rest.saturated ||= from.rolledRest.saturated;
482
+ }
483
+ };
484
+
322
485
  export const addCommitProfile = (window: WindowProfile, commit: CommitProfile) => {
323
486
  window.commits++;
324
487
  if (commit.seq > 0) {
@@ -330,10 +493,20 @@ export const addCommitProfile = (window: WindowProfile, commit: CommitProfile) =
330
493
  }
331
494
  if (commit.renderMs !== null) {
332
495
  window.renderMs.push(commit.renderMs);
496
+ const slowest = window.slowest;
497
+ if (commit.seq > 0 && (slowest === null || commit.renderMs > slowest.ms || (commit.renderMs === slowest.ms && commit.seq < slowest.seq))) {
498
+ window.slowest = { seq: commit.seq, endMs: commit.endMs, ms: commit.renderMs };
499
+ }
333
500
  }
334
- for (const name of commit.updaters ?? []) {
501
+ for (const name of new Set(commit.updaterOwners ?? [])) {
335
502
  bump(window.updaters, name);
336
503
  }
504
+ window.updatersUnresolved += commit.updatersUnresolved;
505
+ if (commit.outside) {
506
+ window.outside.selfMs += commit.outside.selfMs;
507
+ window.outside.mountSelfMs += commit.outside.mountSelfMs;
508
+ mergeRolled(window.outside, commit.outside);
509
+ }
337
510
  for (const [name, from] of commit.components) {
338
511
  let into = window.components.get(name);
339
512
  if (!into) {
@@ -353,6 +526,13 @@ export const addCommitProfile = (window: WindowProfile, commit: CommitProfile) =
353
526
  into.context += from.context;
354
527
  into.parent += from.parent;
355
528
  into.other += from.other;
529
+ into.rolledMs += from.rolledMs;
530
+ mergeRolled(into, from);
531
+ if (from.parents) {
532
+ for (const [parent, count] of from.parents) noteParent(into, parent, count);
533
+ } else if (from.parentCount > 0) {
534
+ noteParent(into, from.firstParent, from.parentCount);
535
+ }
356
536
  mergeCounts(into.props, from.props);
357
537
  mergeCounts(into.hooks, from.hooks);
358
538
  }
@@ -368,9 +548,18 @@ const sortedCounts = (map: Record<string, number>) => {
368
548
  return out;
369
549
  };
370
550
 
371
- // Sizes of the per-window summary (plan 40 §3.1): component rows, cause keys
372
- // per component, updaters.
373
- export const PROFILE_LIMITS = { components: 30, causeKeys: 3, updaters: 5 };
551
+ // Sizes of the per-window summary (plan 40 §3.1, plan 79 S9): every component gets a row until the valve (`rows` or `rowBytes`; the smallest are dropped
552
+ // and counted in `omitted`). The first `detailRows` rows, reported in the summary, also get causes, shares and roll-up: the top `detailTop` and every row at
553
+ // FULL_ROW_MS, as far as rows and detail together fit `profileBytes`. Half the batch cap, so a window line is never dropped.
554
+ export const PROFILE_LIMITS = {
555
+ rows: 512, rowBytes: 64 * 1024, detailTop: 30, profileBytes: MAX_BATCH_BYTES / 2,
556
+ causeKeys: 3, updaters: 5,
557
+ rollupTop: 5,
558
+ };
559
+
560
+ // A component with at least this much self time (as sent, rounded) keeps cause, share and roll-up detail in the window summary. Half the lowest ms floor of
561
+ // the component rules (verdict-core clause T-WARN-MS); a test pins it, because the SDK cannot import the clause table.
562
+ export const FULL_ROW_MS = 2.5;
374
563
 
375
564
  // The budgets every timed window counts commits against (120 Hz and 60 Hz),
376
565
  // plus the device's own when it is neither.
@@ -457,14 +646,44 @@ export const overBudgetCounts = (renderMs: number[], budgets: number[]) => {
457
646
  const DEFAULT_BUDGET: FrameBudget = { ms: 16.7, hz: null, source: 'default' };
458
647
 
459
648
  // Component rows: [name, renders, mounts, instances, selfMs, totalMs]; times
460
- // are null without the profiling renderer. Rows are the top components by self
461
- // time when timed, else by renders (then name), so a production-renderer run
462
- // with the same renders prints the same rows.
649
+ // are null without the profiling renderer. Rows are every component by self
650
+ // time when timed, else by renders (then name), up to the valve in PROFILE_LIMITS,
651
+ // so a production-renderer run with the same renders prints the same rows.
463
652
  export type ComponentRow = [string, number, number, number, number | null, number | null];
464
653
 
654
+ const rolledSummary = (entry: RollupDetail, limit: number) => {
655
+ const all = [...(entry.rolled ?? [])].sort((a, b) => b[1].ms - a[1].ms || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
656
+ return {
657
+ rolledUp: all.slice(0, limit).map(([name, time]): [string, number, number] => [name, round(time.ms), time.renders]),
658
+ rolledUpRest: {
659
+ ms: round(all.slice(limit).reduce((sum, [, time]) => sum + time.ms, entry.rolledRest?.ms ?? 0)),
660
+ n: (entry.rolledRest?.names.size ?? 0) + Math.max(0, all.length - limit),
661
+ ...(entry.rolledRest?.saturated ? { saturated: true as const } : {}),
662
+ },
663
+ };
664
+ };
665
+
666
+ const dominantParent = (entry: ComponentProfile): string | null => {
667
+ if (entry.parents === null) return entry.firstParent;
668
+ let parent: string | null = null, count = -1;
669
+ for (const [candidate, occurrences] of entry.parents) {
670
+ if (occurrences > count || (occurrences === count && (candidate ?? '') < (parent ?? ''))) {
671
+ parent = candidate; count = occurrences;
672
+ }
673
+ }
674
+ return parent;
675
+ };
676
+
677
+ interface RollupSummary {
678
+ parent: string | null;
679
+ rolledUpMs: number;
680
+ rolledUp: [string, number, number][];
681
+ rolledUpRest: RolledRest;
682
+ }
683
+
465
684
  export const summarizeWindowProfile = (
466
685
  window: WindowProfile,
467
- options: { budget?: FrameBudget; limits?: Partial<typeof PROFILE_LIMITS> } = {},
686
+ options: { budget?: FrameBudget; limits?: Partial<typeof PROFILE_LIMITS>; t0?: number } = {},
468
687
  ) => {
469
688
  const limits = { ...PROFILE_LIMITS, ...options.limits };
470
689
  const budget = options.budget ?? DEFAULT_BUDGET;
@@ -475,31 +694,54 @@ export const summarizeWindowProfile = (
475
694
  b[1].renders - a[1].renders ||
476
695
  (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0),
477
696
  );
478
- const kept = all.slice(0, limits.components);
479
- const components: ComponentRow[] = kept.map(([name, entry]) => [
697
+ const rowOf = ([name, entry]: [string, ComponentProfile]): ComponentRow => [
480
698
  name,
481
699
  entry.renders,
482
700
  entry.mounts,
483
701
  entry.instances.size,
484
702
  timed ? round(entry.selfMs) : null,
485
- timed ? round(entry.totalMs) : null,
486
- ]);
703
+ timed && entry.timed > 0 ? round(entry.totalMs) : null,
704
+ ];
705
+ const components: ComponentRow[] = [];
706
+ let rowBytes = 0;
707
+ for (const item of all) {
708
+ const row = rowOf(item);
709
+ const bytes = utf8Length(JSON.stringify(row)) + 1;
710
+ if (components.length >= limits.rows || rowBytes + bytes > limits.rowBytes) break;
711
+ components.push(row);
712
+ rowBytes += bytes;
713
+ }
714
+ const kept = all.slice(0, components.length);
487
715
  const causes: Record<string, CauseSummary> = {};
488
716
  const shares: Record<string, Record<string, number>> = {};
489
- for (const [name, entry] of kept) {
717
+ const rollup: Record<string, RollupSummary> = {};
718
+ // Detail is a prefix of the rows: sorted by self time, the rows at FULL_ROW_MS follow the top rows directly.
719
+ let detailRows = 0, detailBytes = 0;
720
+ for (const [index, [name, entry]] of kept.entries()) {
721
+ if (index >= limits.detailTop && !(timed && round(entry.selfMs) >= FULL_ROW_MS)) break;
490
722
  const summary = causeSummary(entry, limits.causeKeys);
723
+ const share = causeShares(entry, limits.causeKeys);
724
+ const parent = dominantParent(entry);
725
+ const rolled = timed && (parent !== null || entry.rolledMs > 0)
726
+ ? { parent, rolledUpMs: round(entry.rolledMs), ...rolledSummary(entry, limits.rollupTop) }
727
+ : null;
728
+ const bytes = 3 * utf8Length(name) + utf8Length(JSON.stringify([summary, share, rolled])) + 12;
729
+ if (rowBytes + detailBytes + bytes > limits.profileBytes) break;
730
+ detailBytes += bytes;
731
+ detailRows++;
491
732
  if (Object.keys(summary).length > 0) {
492
733
  causes[name] = summary;
493
734
  }
494
- shares[name] = causeShares(entry, limits.causeKeys);
735
+ shares[name] = share;
736
+ if (rolled) rollup[name] = rolled;
495
737
  }
496
738
  const updaters = Object.entries(window.updaters).sort(ranked);
497
739
  const renderSum = window.renderMs.reduce((s, n) => s + n, 0);
498
740
  const selfTotal = timed
499
- ? [...window.components.values()].reduce((s, e) => s + e.selfMs, 0)
741
+ ? [...window.components.values()].reduce((s, e) => s + e.selfMs, window.outside.selfMs)
500
742
  : null;
501
743
  const mountSelfTotal = timed
502
- ? [...window.components.values()].reduce((s, e) => s + e.mountSelfMs, 0)
744
+ ? [...window.components.values()].reduce((s, e) => s + e.mountSelfMs, window.outside.mountSelfMs)
503
745
  : null;
504
746
  return {
505
747
  renderer: timed ? 'profiling' : 'production',
@@ -508,6 +750,10 @@ export const summarizeWindowProfile = (
508
750
  window.firstSeq === null || window.lastSeq === null
509
751
  ? null
510
752
  : [window.firstSeq, window.lastSeq],
753
+ slowestCommitSeq: timed ? window.slowest?.seq ?? null : null,
754
+ // When that commit landed, ms after the window start; negative for a commit a later-opened window took by backfill.
755
+ slowestCommitEndAtMs:
756
+ timed && window.slowest?.endMs != null && options.t0 !== undefined ? round(window.slowest.endMs - options.t0) : null,
511
757
  renderMs: timed
512
758
  ? {
513
759
  sum: round(renderSum),
@@ -521,9 +767,17 @@ export const summarizeWindowProfile = (
521
767
  : null,
522
768
  selfMsTotal: selfTotal === null ? null : round(selfTotal),
523
769
  mountSelfMs: mountSelfTotal === null ? null : round(mountSelfTotal),
770
+ stamped: all.filter(([, entry]) => entry.renders > 0).length,
771
+ ...(timed ? {
772
+ rollup,
773
+ outside: { selfMs: round(window.outside.selfMs), mountSelfMs: round(window.outside.mountSelfMs), ...rolledSummary(window.outside, limits.rollupTop) },
774
+ rolledUpMsTotal: round(all.reduce((sum, [, entry]) => sum + entry.rolledMs, window.outside.selfMs)),
775
+ } : {}),
524
776
  updaters: timed ? sortedCounts(Object.fromEntries(updaters.slice(0, limits.updaters))) : null,
525
777
  updatersOmitted: timed ? Math.max(0, updaters.length - limits.updaters) : 0,
778
+ updatersUnresolved: window.updatersUnresolved,
526
779
  components,
780
+ detailRows,
527
781
  causes,
528
782
  shares,
529
783
  omitted: all.length - kept.length,
@@ -5,6 +5,7 @@
5
5
  import { AppState, Platform } from 'react-native';
6
6
 
7
7
  import { MODE } from './activation';
8
+ import { deviceModelOf } from './device-model';
8
9
  import { type ArtifactUploadNative, createArtifactUploader } from './artifacts';
9
10
  import {
10
11
  addStartupSection,
@@ -41,7 +42,7 @@ const readDevice = async (): Promise<DeviceInfo> => {
41
42
  let build: string | null = null;
42
43
  try {
43
44
  const Device = require('expo-device');
44
- model = Device.modelName ?? Device.modelId ?? null;
45
+ model = deviceModelOf(Device);
45
46
  os = Device.osVersion ?? null;
46
47
  } catch {
47
48
  // device fields stay null
package/js/sdk/push.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  // rewritten to the live queue whenever it outgrows twice the queue caps, so it
12
12
  // stays bounded while the ingest is unreachable.
13
13
 
14
+ import { MAX_BATCH_BYTES, utf8Length } from './bytes';
15
+
14
16
  export interface DeviceInfo {
15
17
  model: string | null;
16
18
  os: string | null;
@@ -208,31 +210,13 @@ export const readSpool = (content: string | null): { pending: PendingLine[]; dro
208
210
  export const pendingFromSpool = (content: string | null): PendingLine[] =>
209
211
  readSpool(content).pending;
210
212
 
211
- const utf8Length = (text: string) => {
212
- let n = 0;
213
- for (let i = 0; i < text.length; i++) {
214
- const c = text.charCodeAt(i);
215
- if (c < 0x80) {
216
- n += 1;
217
- } else if (c < 0x800) {
218
- n += 2;
219
- } else if (c >= 0xd800 && c <= 0xdbff) {
220
- n += 4;
221
- i++;
222
- } else {
223
- n += 3;
224
- }
225
- }
226
- return n;
227
- };
228
-
229
213
  // Retrying cannot fix these; the batch is dropped instead of blocking the queue.
230
214
  const isPermanent = (status: number) =>
231
215
  status >= 400 && status < 500 && status !== 408 && status !== 429;
232
216
 
233
217
  export const createPushClient = (deps: PushDeps) => {
234
218
  const idleFlushMs = deps.idleFlushMs ?? 10_000;
235
- const maxBatchBytes = deps.maxBatchBytes ?? 256 * 1024;
219
+ const maxBatchBytes = deps.maxBatchBytes ?? MAX_BATCH_BYTES;
236
220
  const maxBatchItems = deps.maxBatchItems ?? 400;
237
221
  const maxQueueLines = deps.maxQueueLines ?? 2000;
238
222
  const maxQueueBytes = deps.maxQueueBytes ?? 8 * 1024 * 1024;
package/js/sdk/screens.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  } from './core';
27
27
  import { retainFloorOf } from './retain-floor';
28
28
  import { type CauseRoot, currentCause, latestActionAt, launchedAt, launchRootId } from './causes';
29
- import { routeOfScreenHost, routes } from './fiber';
29
+ import { buildPlaceId, routeOfScreenHost, routes, screenKeyForRoute, setScreenKeyProvider, currentFiber, type ScreenRouteResolution, type Fiber } from './fiber';
30
30
  import {
31
31
  lastPressStart,
32
32
  type NativeScreenEvent,
@@ -64,6 +64,11 @@ interface Visit {
64
64
  chainKey: string;
65
65
  keyChain: string;
66
66
  visit: number;
67
+ screenKey: string | null;
68
+ legacyName: string;
69
+ pendingCommits: number;
70
+ resolution: ScreenRouteResolution;
71
+ lazyBoundary: Fiber | null;
67
72
  t0: number;
68
73
  openedAt: number;
69
74
  endedAt: number | null;
@@ -89,6 +94,7 @@ interface Resolution {
89
94
  let current: Visit | null = null;
90
95
  let seq = 0;
91
96
  const visitsByChain = new Map<string, number>();
97
+ const visitsByScreen = new Map<string, number>();
92
98
  let lastActionAt = -Infinity;
93
99
  interface AppLink {
94
100
  t: number;
@@ -248,6 +254,7 @@ const endVisit = (reason: string, at?: number) => {
248
254
  if (!visit || visit.endedAt !== null) {
249
255
  return;
250
256
  }
257
+ if (visit.pendingCommits > 0) resolveVisit(visit, true);
251
258
  const t = Math.max(visit.t0, at ?? now());
252
259
  visit.endedAt = t;
253
260
  stats.ends[reason] = (stats.ends[reason] ?? 0) + 1;
@@ -332,6 +339,28 @@ const isStrictExtension = (prev: RoutePart[], next: RoutePart[]) =>
332
339
  (part.key ?? null) === (next[i]?.key ?? null),
333
340
  );
334
341
 
342
+ const screenResolution = (chain: RoutePart[]) => {
343
+ const leaf = chain[chain.length - 1]!;
344
+ return screenKeyForRoute(leaf.key ?? null, leaf.name);
345
+ };
346
+
347
+ const moveScreenCounter = (visit: Visit, screen: string) => {
348
+ if (visit.screenKey === screen) return;
349
+ if (visit.screenKey !== null) visitsByScreen.set(visit.screenKey, (visitsByScreen.get(visit.screenKey) ?? 1) - 1);
350
+ visit.visit = (visitsByScreen.get(screen) ?? 0) + 1;
351
+ visitsByScreen.set(screen, visit.visit);
352
+ visit.screenKey = screen;
353
+ };
354
+
355
+ const resolveVisit = (visit: Visit, final = false) => {
356
+ const resolved = visit.resolution;
357
+ if (resolved.screenPending && !final) return;
358
+ moveScreenCounter(visit, resolved.screen);
359
+ visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
360
+ visit.pendingCommits = 0;
361
+ markers.rename(visit.key, visit.name, visit.legacyName);
362
+ };
363
+
335
364
  const extend = (visit: Visit, chain: RoutePart[], chainKey: string) => {
336
365
  const previous = visitsByChain.get(visit.chainKey) ?? 1;
337
366
  visitsByChain.set(visit.chainKey, previous - 1);
@@ -341,9 +370,19 @@ const extend = (visit: Visit, chain: RoutePart[], chainKey: string) => {
341
370
  visit.chain = chain;
342
371
  visit.chainKey = chainKey;
343
372
  visit.keyChain = keyChainOf(chain);
344
- visit.visit = n;
345
- visit.name = nameOf(chainKey, n);
346
- markers.rename(visit.key, visit.name);
373
+ visit.legacyName = nameOf(chainKey, n);
374
+ const resolution = screenResolution(chain);
375
+ visit.resolution = resolution;
376
+ visit.lazyBoundary = resolution.suspended ?? null;
377
+ if (resolution.screenPending) {
378
+ if (visit.screenKey !== null) {
379
+ visitsByScreen.set(visit.screenKey, (visitsByScreen.get(visit.screenKey) ?? 1) - 1);
380
+ visit.screenKey = null;
381
+ }
382
+ visit.name = visit.legacyName;
383
+ visit.pendingCommits = 3;
384
+ markers.rename(visit.key, visit.name, visit.legacyName);
385
+ } else resolveVisit(visit);
347
386
  stats.extended++;
348
387
  };
349
388
 
@@ -398,6 +437,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
398
437
  const n = (visitsByChain.get(chainKey) ?? 0) + 1;
399
438
  visitsByChain.set(chainKey, n);
400
439
  seq++;
440
+ const resolved = screenResolution(chain);
401
441
  const visit: Visit = {
402
442
  key: `screen#${seq}`,
403
443
  name: nameOf(chainKey, n),
@@ -405,6 +445,11 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
405
445
  chainKey,
406
446
  keyChain,
407
447
  visit: n,
448
+ legacyName: nameOf(chainKey, n),
449
+ screenKey: null,
450
+ pendingCommits: 3,
451
+ resolution: resolved,
452
+ lazyBoundary: resolved.suspended ?? null,
408
453
  t0: start.t0,
409
454
  openedAt: detectedAt,
410
455
  endedAt: null,
@@ -417,6 +462,11 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
417
462
  launch,
418
463
  cause: snapshot.cause,
419
464
  };
465
+ if (!resolved.screenPending) {
466
+ moveScreenCounter(visit, resolved.screen);
467
+ visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
468
+ visit.pendingCommits = 0;
469
+ }
420
470
  current = visit;
421
471
  pressesSinceVisit = [];
422
472
  stats.visits++;
@@ -429,6 +479,12 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
429
479
  markers.start(visit.name, {
430
480
  key: visit.key,
431
481
  kind: 'screen',
482
+ legacyName: visit.legacyName,
483
+ beforeEnd: () => {
484
+ if (visit.pendingCommits > 0) resolveVisit(visit, true);
485
+ visit.lazyBoundary = null;
486
+ visit.resolution = { screen: visit.screenKey ?? visit.resolution.screen };
487
+ },
432
488
  at: visit.t0,
433
489
  details: details(visit),
434
490
  // The launch window belongs to the launch, whatever action came before the first state (REQ-MEAS-22).
@@ -530,6 +586,7 @@ export const screenStats = () => ({
530
586
  visitsByChain: Object.fromEntries(
531
587
  [...visitsByChain].filter(([, n]) => n > 0).slice(0, MAX_LISTED),
532
588
  ),
589
+ visitsByScreen: Object.fromEntries([...visitsByScreen].filter(([, n]) => n > 0).slice(0, MAX_LISTED)),
533
590
  trackedRoutes: routes.size,
534
591
  nav: navStats(),
535
592
  native: {
@@ -569,6 +626,10 @@ export const installScreens = () => {
569
626
  return;
570
627
  }
571
628
  installed = true;
629
+ setScreenKeyProvider(() => current ? {
630
+ screen: current.screenKey ?? current.resolution.screen,
631
+ ...(current.pendingCommits > 0 || current.lazyBoundary ? { screenPending: true } : {}),
632
+ } : { screen: '(no screen)' });
572
633
  setRetainFloor(screenRetainFloor);
573
634
  onWindowsEnd(onWindowsEnded);
574
635
  onNavSnapshot(snapshot => onSnapshot(snapshot, 'state'));
@@ -578,6 +639,24 @@ export const installScreens = () => {
578
639
  setTimeout(() => logNativeEvent(event), RESOLVE_DELAY_MS);
579
640
  });
580
641
  onAfterCommit(() => {
642
+ const visit = current;
643
+ if (visit && isWindowOpen(visit.key)) {
644
+ if (visit.pendingCommits > 0) {
645
+ visit.resolution = screenResolution(visit.chain);
646
+ visit.lazyBoundary = visit.resolution.suspended ?? null;
647
+ visit.pendingCommits--;
648
+ resolveVisit(visit, visit.pendingCommits === 0);
649
+ } else if (visit.lazyBoundary) {
650
+ const boundary = currentFiber(visit.lazyBoundary);
651
+ if (!boundary || boundary.memoizedState == null) {
652
+ visit.lazyBoundary = null;
653
+ if (boundary) {
654
+ visit.resolution = screenResolution(visit.chain);
655
+ resolveVisit(visit, true);
656
+ }
657
+ }
658
+ }
659
+ }
581
660
  if (!checkScheduled && containerCount() === 0) {
582
661
  checkScheduled = true;
583
662
  setTimeout(checkRouteProps, 0);