@keepsnappy/react-native 0.1.0-alpha.16 → 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.
@@ -68,20 +68,27 @@ module.exports = {
68
68
  : args.src;
69
69
  args = { ...args, src: inlineEnv(src) };
70
70
  }
71
+ const root = args.options?.projectRoot ?? projectRoot ?? process.cwd();
72
+ // A client file built without its stamps keys its components to an ancestor's: never silently.
73
+ const skipped = error => console.warn(`KeepSnappy: component stamps skipped for ${path.relative(root, file)}: ${String(error)}`);
71
74
  let stamps;
72
75
  try {
73
76
  stamps = require('./component-stamps.js').instrumentation(
74
- original.src, file, args.options?.projectRoot ?? projectRoot ?? process.cwd(), clientPackages, file === entryFile ? 1 : 0,
77
+ original.src, file, root, clientPackages, file === entryFile ? 1 : 0,
75
78
  );
76
- } catch { return upstream.transform(args); }
79
+ } catch (error) { skipped(error); return upstream.transform(args); }
77
80
  if (!stamps) return upstream.transform(args);
78
81
  const complete = result => { stamps.complete(); return result; };
79
- const passthrough = () => { stamps.complete(true); return upstream.transform(args); };
82
+ const passthrough = error => {
83
+ skipped(error);
84
+ stamps.complete(true);
85
+ return upstream.transform(args);
86
+ };
80
87
  try {
81
88
  const result = upstream.transform({ ...args, plugins: [stamps.plugin, ...(args.plugins ?? [])] });
82
89
  return result && typeof result.then === 'function'
83
90
  ? result.then(complete, passthrough) : complete(result);
84
- } catch { return passthrough(); }
91
+ } catch (error) { return passthrough(error); }
85
92
  },
86
93
  getCacheKey(...args) {
87
94
  const base =
@@ -119,6 +119,15 @@ const anonymousRender = (node, t) => {
119
119
  && !node.id && !node.async && !node.generator && returnsJSX(node.body, t);
120
120
  };
121
121
 
122
+ // A plugin that ran first (React Compiler listed in the app's babel.config.js) may put back regenerated nodes without
123
+ // `loc`; the line then comes from the declaration in the source.
124
+ const declarationLine = (src, id, statement, lineOffset) => {
125
+ const line = id.loc?.start?.line ?? statement.loc?.start?.line;
126
+ if (typeof line === 'number') return Math.max(1, line - lineOffset);
127
+ const match = new RegExp(`^[ \\t]*(?:export[ \\t]+(?:default[ \\t]+)?)?(?:async[ \\t]+)?(?:function\\*?|class|const)[ \\t]+${id.name}\\b`, 'm').exec(src);
128
+ return match ? src.slice(0, match.index).split('\n').length : 1;
129
+ };
130
+
122
131
  function instrumentation(src, filename, projectRoot, clientPackages, lineOffset = 0) {
123
132
  const client = clientFile(filename, projectRoot, clientPackages);
124
133
  if (!client) return null;
@@ -166,13 +175,19 @@ function instrumentation(src, filename, projectRoot, clientPackages, lineOffset
166
175
  const stamps = [];
167
176
  for (const id of found) {
168
177
  const componentName = id === defaultId ? 'default' : id.name;
169
- const key = componentKey(componentName, client.file);
170
- components.push({ key, name: componentName, file: client.file, line: Math.max(1, id.loc.start.line - lineOffset) });
171
- const name = () => t.identifier(id.name);
172
- const typeIs = value => t.binaryExpression('===', t.unaryExpression('typeof', name()), t.stringLiteral(value));
173
- stamps.push(t.ifStatement(t.logicalExpression('||', typeIs('function'), t.logicalExpression('&&',
174
- t.binaryExpression('!==', name(), t.nullLiteral()), typeIs('object'))),
175
- t.expressionStatement(t.assignmentExpression('=', t.memberExpression(name(), t.identifier('__ks')), t.stringLiteral(key)))));
178
+ // One declaration that cannot be stamped must not cost the file its other stamps.
179
+ try {
180
+ const key = componentKey(componentName, client.file);
181
+ const line = declarationLine(src, id, statement.node, lineOffset);
182
+ const name = () => t.identifier(id.name);
183
+ const typeIs = value => t.binaryExpression('===', t.unaryExpression('typeof', name()), t.stringLiteral(value));
184
+ stamps.push(t.ifStatement(t.logicalExpression('||', typeIs('function'), t.logicalExpression('&&',
185
+ t.binaryExpression('!==', name(), t.nullLiteral()), typeIs('object'))),
186
+ t.expressionStatement(t.assignmentExpression('=', t.memberExpression(name(), t.identifier('__ks')), t.stringLiteral(key)))));
187
+ components.push({ key, name: componentName, file: client.file, line });
188
+ } catch (error) {
189
+ console.warn(`KeepSnappy: could not stamp ${componentName} in ${client.file}: ${String(error)}`);
190
+ }
176
191
  }
177
192
  if (stamps.length) statement.insertAfter(stamps);
178
193
  }
package/js/sdk/core.ts CHANGED
@@ -473,8 +473,10 @@ interface OpenSampleState {
473
473
  // - so does a window that starts within OPEN_SAMPLE_TOLERANCE_MS of the close sample (restarted key);
474
474
  // - a window detected long after its t0 (a screen window opened by a press, seen after the new screen rendered) gets
475
475
  // none: its bound is the press's open sample;
476
- // - else the sampler is asked to sample on its own thread.
477
- const openSampleFor = (id: number, t0: number): OpenSampleState => {
476
+ // - else the sampler is asked to sample on its own thread. A press always is, however late its touch reached JS: with
477
+ // no window before it (after a GC command, the background or the flush) the sample before the touch can be seconds
478
+ // old, so its own sample, a negative lead past t0, is the closer bound.
479
+ const openSampleFor = (id: number, t0: number, press: boolean): OpenSampleState => {
478
480
  const none = { openRecord: null, openPending: null };
479
481
  if (samplerState.mode === 'off') {
480
482
  return none;
@@ -487,7 +489,7 @@ const openSampleFor = (id: number, t0: number): OpenSampleState => {
487
489
  ) {
488
490
  return { openRecord: lastClose.record, openPending: null };
489
491
  }
490
- if (now() - samplerState.offsetMs - t0Native > OPEN_SAMPLE_TOLERANCE_MS) {
492
+ if (!press && now() - samplerState.offsetMs - t0Native > OPEN_SAMPLE_TOLERANCE_MS) {
491
493
  return none;
492
494
  }
493
495
  const reason = `window-start:${id}`;
@@ -938,7 +940,14 @@ const routeToBackground = (record: CommitRecord) => {
938
940
 
939
941
  // Segments written and not yet listed for upload (listed at the next flush).
940
942
  const pendingStreamFiles: WrittenFile[] = [];
941
- // A segment is cut at this many commits even between flushes, so a long stretch outside a run stays bounded.
943
+ // What the run lost since the last flush, which reports it. Commit-stream segments that failed to write are missing data
944
+ // (the backend splits work by cause from them). Kept React exports dropped at the run cap or not written, and stream fibers
945
+ // past a segment's cap, leave every measured value complete: counted notes only.
946
+ const runLosses = { stream: 0, exportsCapped: 0, exportsUnwritten: 0, streamFibersDropped: 0 };
947
+ export const setStreamFiberCapForTest = (fibers: number) => commitStream.setMaxFibers(fibers);
948
+ // A segment is cut at this many commits even between flushes, so a long stretch outside a run stays bounded. In a run
949
+ // the cut waits while a window is open (no file I/O inside a measured window, REQ-MEAS-23): every window ends at the
950
+ // flush, which cuts it, and a segment past its fiber cap keeps only the commits' facts.
942
951
  const MAX_SEGMENT_COMMITS = 5000;
943
952
 
944
953
  const cutStream = () => {
@@ -954,7 +963,10 @@ const cutStream = () => {
954
963
  }
955
964
  if (path) {
956
965
  pendingStreamFiles.push({ name: segment.name, windows: [], bytes: segment.json.length });
966
+ } else if (inRun()) {
967
+ runLosses.stream++;
957
968
  }
969
+ if (inRun()) runLosses.streamFibersDropped += segment.fibersDropped;
958
970
  perfLog(`stream ${JSON.stringify({ name: segment.name, commits: segment.commits, bytes: segment.json.length, fibersDropped: segment.fibersDropped, written: path !== null })}`);
959
971
  };
960
972
 
@@ -998,7 +1010,7 @@ const start = (name: string, options: StartOptions = {}) => {
998
1010
  const statsStart = readHermesStats();
999
1011
  const id = nextWindowId++;
1000
1012
  const startT = options.at ?? now();
1001
- const openSample = openSampleFor(id, startT);
1013
+ const openSample = openSampleFor(id, startT, options.kind === 'press');
1002
1014
  const window: MarkerWindow = {
1003
1015
  id,
1004
1016
  key,
@@ -1301,7 +1313,7 @@ const onCommitFiberRoot = (rendererId: number, root: FiberRoot) => {
1301
1313
  // Work no open window judges is the background of its source: pushes, and loops of earlier actions or of the launch.
1302
1314
  record.background = record.judged ? null : backgroundSource(causes);
1303
1315
  commitStream.noteCommit({ seq, t: t0, renderMs, causes, fibers: exported?.fibers ?? null, updaters: profile?.updaters ?? null });
1304
- if (commitStream.size() >= MAX_SEGMENT_COMMITS) {
1316
+ if (commitStream.size() >= MAX_SEGMENT_COMMITS && !(inRun() && openWindows.size > 0)) {
1305
1317
  cutStream();
1306
1318
  }
1307
1319
  recordCommit(record);
@@ -1525,7 +1537,12 @@ interface PendingExport {
1525
1537
  }
1526
1538
 
1527
1539
  // React exports held until flush (inside a run), capped like a window's.
1528
- const MAX_PENDING_EXPORT_FIBERS = 400000;
1540
+ let maxPendingExportFibers = 400000;
1541
+ export const setPendingExportCapForTest = (fibers: number) => {
1542
+ const previous = maxPendingExportFibers;
1543
+ maxPendingExportFibers = fibers;
1544
+ return previous;
1545
+ };
1529
1546
  const pendingExports: PendingExport[] = [];
1530
1547
  let pendingExportFibers = 0;
1531
1548
 
@@ -1546,7 +1563,8 @@ const writeReactExport = (window: MarkerWindow) => {
1546
1563
  truncated: window.exportTruncated,
1547
1564
  };
1548
1565
  if (inRun()) {
1549
- if (pendingExportFibers + window.exportFibers > MAX_PENDING_EXPORT_FIBERS) {
1566
+ if (pendingExportFibers + window.exportFibers > maxPendingExportFibers) {
1567
+ if (settings.diag || budgetBreaks.has(window.id)) runLosses.exportsCapped++;
1550
1568
  return { ...base, deferred: true, dropped: true };
1551
1569
  }
1552
1570
  pendingExports.push({
@@ -1623,6 +1641,7 @@ const writeDeferredFiles = (): ArtifactEntry[] => {
1623
1641
  } catch (error) {
1624
1642
  result = { error: String(error) };
1625
1643
  }
1644
+ if (!result.path && (settings.diag || budgetBreaks.has(pending.window))) runLosses.exportsUnwritten++;
1626
1645
  perfLog(
1627
1646
  `rexport ${JSON.stringify({ name: pending.name, commits: pending.commits.length, fibers: pending.fibers, ...result })}`,
1628
1647
  );
@@ -2127,6 +2146,16 @@ const flushOnce = async (ack: string | undefined) => {
2127
2146
  } catch (error) {
2128
2147
  perfLog(`artifacts failed ${String(error)}`);
2129
2148
  }
2149
+ if (runToken !== null) {
2150
+ if (runLosses.stream > 0) {
2151
+ if (status === 'ok') status = 'error';
2152
+ detail += ` lost stream=${runLosses.stream}`;
2153
+ }
2154
+ if (runLosses.exportsCapped > 0) detail += ` exports capped=${runLosses.exportsCapped}`;
2155
+ if (runLosses.exportsUnwritten > 0) detail += ` exports unwritten=${runLosses.exportsUnwritten}`;
2156
+ if (runLosses.streamFibersDropped > 0) detail += ` stream fibersDropped=${runLosses.streamFibersDropped}`;
2157
+ }
2158
+ Object.assign(runLosses, { stream: 0, exportsCapped: 0, exportsUnwritten: 0, streamFibersDropped: 0 });
2130
2159
  try {
2131
2160
  await sendEarlierArtifacts(deadline);
2132
2161
  } catch (error) {
package/js/sdk/fiber.ts CHANGED
@@ -230,9 +230,11 @@ export const noteMounted = (fiber: Fiber) => {
230
230
  const props = propsOf(fiber);
231
231
  if (props && props.route !== undefined) {
232
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.
233
235
  const route = props.route as { key?: unknown } | null;
234
236
  if (typeof route?.key === 'string' &&
235
- typeof props.getState === 'function' &&
237
+ props.screen !== null && typeof props.screen === 'object' &&
236
238
  typeof props.clearOptions === 'function') {
237
239
  scenes.set(route.key, fiber);
238
240
  }
@@ -582,9 +584,11 @@ export interface TargetPlace {
582
584
  manySiblings?: number;
583
585
  }
584
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.
585
589
  const actionableFiber = (f: Fiber) => {
586
590
  const props = propsOf(f);
587
- return props !== null && isActionable(props);
591
+ return props !== null && isActionable(props) && !isScrollHost(f);
588
592
  };
589
593
  const isListCell = (f: Fiber) => {
590
594
  if (!isComponentTag(f.tag) || clientKeyOf(f) !== null) return false;
@@ -594,6 +598,69 @@ const isListCell = (f: Fiber) => {
594
598
  (typeof p.getRenderedItem === 'function' && 'itemKey' in p));
595
599
  };
596
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
+
597
664
  const occurrenceOf = (
598
665
  parent: Fiber,
599
666
  target: Fiber,
@@ -611,13 +678,19 @@ const occurrenceOf = (
611
678
  const stack: { f: Fiber; inside: boolean }[] = root.child ? [{ f: root.child, inside: false }] : [];
612
679
  let count = 0;
613
680
  let index = 0;
681
+ const isParent = (f: Fiber | null) => f !== null && (f === root || f === root.alternate);
614
682
  while (stack.length && visited < 1000) {
615
683
  const { f, inside } = stack.pop()!;
616
684
  visited++;
617
685
  if (f.sibling) stack.push({ f: f.sibling, inside });
618
686
  if (isListCell(f)) continue;
619
687
  const client = clientFiber(f);
620
- if (client && (inside || clientKeyOf(f) !== key)) continue;
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;
621
694
  const inTarget = inside || client;
622
695
  if (inTarget && ((scroll && client) || (!scroll && actionableFiber(f)))) {
623
696
  count++;
@@ -649,10 +722,7 @@ const placeOf = (host: Fiber | null, scroll: boolean, screen: ScreenKeyResolutio
649
722
  if (!scroll && actionableFiber(f)) region = f;
650
723
  if (clientFiber(f)) { target = f; break; }
651
724
  }
652
- let parent: Fiber | null = null;
653
- for (let f = target?.return ?? null, n = 0; f && n < MAX_UP * 2; f = f.return, n++) {
654
- if (clientFiber(f)) { parent = f; break; }
655
- }
725
+ const parent = target ? placeParentOf(target) : null;
656
726
  return {
657
727
  screen: screen.screen,
658
728
  parent: parent ? clientKeyOf(parent) : null,
@@ -270,6 +270,19 @@ const finishPress = (press: ActivePress, reason: string, at?: number) => {
270
270
  }
271
271
  };
272
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
+
273
286
  const onTouchStart = (ne: Record<string, unknown>) => {
274
287
  const touches = ne.touches;
275
288
  if (Array.isArray(touches) && touches.length > 1) {
@@ -326,6 +339,11 @@ const onTouchStart = (ne: Record<string, unknown>) => {
326
339
  legacyName: `press:${target.legacyName}`,
327
340
  at: touchStartT,
328
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
+ },
329
347
  });
330
348
  };
331
349
 
@@ -420,14 +438,7 @@ const onTouchEnd = (ne: Record<string, unknown>, cancelled: boolean) => {
420
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,
421
439
  // before the finger passes the slop, so the move alone would call a fling a press on the row under the finger.
422
440
  press.takenByScroll = cancelled && press.scrollHosts.length > 0;
423
- const drag = press.maxMovePt >= DRAG_SLOP_PT || press.scrolled || press.takenByScroll;
424
- press.kind = drag ? 'drag' : 'press';
425
- if (drag) {
426
- const axis = dragAxis(press);
427
- const candidate = selectScrollHost(press.scrollHosts, axis);
428
- press.container = candidate ? nameScrollContainer(candidate, press.screen) : null;
429
- const name = dragName(press);
430
- markers.rename(press.key, name, `drag:${press.container?.legacyName ?? press.target.legacyName}`);
441
+ if (classify(press)) {
431
442
  return;
432
443
  }
433
444
  if (cancelled) {
package/js/sdk/push.ts CHANGED
@@ -115,6 +115,8 @@ const serverClock = async (res: { json?: () => Promise<unknown> }, bound: Promis
115
115
  interface Item {
116
116
  line: string;
117
117
  bytes: number;
118
+ // The line's size inside the request body: a JSON string (escaped) plus its comma.
119
+ wire: number;
118
120
  token: string | null;
119
121
  // The process that recorded the line (its bootId) and its sequence there.
120
122
  origin: string;
@@ -127,7 +129,7 @@ interface Batch {
127
129
  id: string;
128
130
  items: Item[];
129
131
  token: string | null;
130
- bytes: number;
132
+ wire: number;
131
133
  }
132
134
 
133
135
  interface SpoolAdd {
@@ -211,6 +213,8 @@ export const pendingFromSpool = (content: string | null): PendingLine[] =>
211
213
  readSpool(content).pending;
212
214
 
213
215
  // Retrying cannot fix these; the batch is dropped instead of blocking the queue.
216
+ const wireBytes = (line: string) => utf8Length(JSON.stringify(line)) + 1;
217
+
214
218
  const isPermanent = (status: number) =>
215
219
  status >= 400 && status < 500 && status !== 408 && status !== 429;
216
220
 
@@ -261,6 +265,11 @@ export const createPushClient = (deps: PushDeps) => {
261
265
  let journalRecords = 0;
262
266
  // Lines dropped before delivery, by this process and (from the note) earlier ones.
263
267
  let undelivered = 0;
268
+ // Lines of a run dropped before delivery (queue cap, line over the batch limit), by run token, until its next flush.
269
+ const droppedOfRun = new Map<string, number>();
270
+ const noteDropped = (token: string | null) => {
271
+ if (token !== null) droppedOfRun.set(token, (droppedOfRun.get(token) ?? 0) + 1);
272
+ };
264
273
  // Round trips not reported yet, each with the run token it was measured under.
265
274
  let clockPending: { token: string; sample: ClockSample }[] = [];
266
275
  const clockOf = (token: string | null) =>
@@ -424,6 +433,7 @@ export const createPushClient = (deps: PushDeps) => {
424
433
  }
425
434
  }
426
435
  undelivered += dropped.length;
436
+ for (const item of dropped) noteDropped(item.token);
427
437
  return dropped;
428
438
  };
429
439
 
@@ -432,12 +442,14 @@ export const createPushClient = (deps: PushDeps) => {
432
442
  if (bytes > maxBatchBytes) {
433
443
  stats.dropped++;
434
444
  undelivered++;
445
+ noteDropped(deps.getRunToken());
435
446
  stats.lastError = `line of ${bytes} bytes exceeds batch limit`;
436
447
  return;
437
448
  }
438
449
  const item: Item = {
439
450
  line,
440
451
  bytes,
452
+ wire: wireBytes(line),
441
453
  token: deps.getRunToken(),
442
454
  origin: bootId,
443
455
  seq: nextSeq++,
@@ -486,11 +498,13 @@ export const createPushClient = (deps: PushDeps) => {
486
498
  if (bytes > maxBatchBytes) {
487
499
  stats.resumeDropped++;
488
500
  undelivered++;
501
+ noteDropped(record.t);
489
502
  continue;
490
503
  }
491
504
  earlier.push({
492
505
  line: record.l,
493
506
  bytes,
507
+ wire: wireBytes(record.l),
494
508
  token: record.t,
495
509
  origin: record.o,
496
510
  seq: record.s,
@@ -528,16 +542,16 @@ export const createPushClient = (deps: PushDeps) => {
528
542
  }
529
543
  const first = queue[0] as Item;
530
544
  const items: Item[] = [];
531
- let bytes = 0;
545
+ let wire = 0;
532
546
  if (first.batch !== undefined) {
533
547
  // Already sent once (by a killed process): resend exactly that batch.
534
548
  while (queue.length > 0 && queue[0]?.batch === first.batch) {
535
549
  const next = queue.shift() as Item;
536
550
  queuedBytes -= next.bytes;
537
551
  items.push(next);
538
- bytes += next.bytes;
552
+ wire += next.wire;
539
553
  }
540
- return { id: first.batch, items, token: first.token, bytes };
554
+ return { id: first.batch, items, token: first.token, wire };
541
555
  }
542
556
  while (queue.length > 0 && items.length < maxBatchItems) {
543
557
  const next = queue[0];
@@ -546,16 +560,16 @@ export const createPushClient = (deps: PushDeps) => {
546
560
  next.batch !== undefined ||
547
561
  next.token !== first.token ||
548
562
  next.origin !== first.origin ||
549
- (items.length > 0 && bytes + next.bytes > maxBatchBytes)
563
+ (items.length > 0 && wire + next.wire > maxBatchBytes)
550
564
  ) {
551
565
  break;
552
566
  }
553
567
  queue.shift();
554
568
  queuedBytes -= next.bytes;
555
569
  items.push(next);
556
- bytes += next.bytes;
570
+ wire += next.wire;
557
571
  }
558
- return { id: '', items, token: first.token, bytes };
572
+ return { id: '', items, token: first.token, wire };
559
573
  };
560
574
 
561
575
  let inflight: Promise<void> | null = null;
@@ -601,29 +615,48 @@ export const createPushClient = (deps: PushDeps) => {
601
615
  bound.clear();
602
616
  }
603
617
  }
618
+ const runToken = batch.token;
619
+ const reported = clockOf(runToken);
620
+ const sentDevice = device;
621
+ const bodyOf = (id: string) => JSON.stringify({
622
+ batch_id: id,
623
+ device: sentDevice,
624
+ items: batch.items.map(item => item.line),
625
+ ...(runToken ? { run_token: runToken } : {}),
626
+ ...(reported.length ? { clock: reported } : {}),
627
+ });
628
+ // Names exactly these lines (process, first and last sequence, count),
629
+ // so a different set of lines is always a different batch.
630
+ const idOf = () => {
631
+ const first = batch.items[0] as Item;
632
+ const last = batch.items[batch.items.length - 1] as Item;
633
+ return `${sentDevice.install_id}-${first.origin}-${first.seq}-${last.seq}-${batch.items.length}`;
634
+ };
635
+ let body = bodyOf(batch.id || idOf());
604
636
  if (!batch.id) {
605
- // Names exactly these lines (process, first and last sequence, count),
606
- // so a different set of lines is always a different batch. It is
607
- // journalled before the first send: a later process that resends after
637
+ // The cap is on the body as sent (escaped lines, device, clock samples): lines over it go back to the queue
638
+ // front for the next batch. A lone line still goes; a line alone over the cap was dropped at enqueue.
639
+ for (let over = utf8Length(body) - maxBatchBytes; over > 0 && batch.items.length > 1; over = utf8Length(body) - maxBatchBytes) {
640
+ const back: Item[] = [];
641
+ for (let freed = 0; freed < over && batch.items.length > 1;) {
642
+ const item = batch.items.pop() as Item;
643
+ freed += item.wire;
644
+ back.unshift(item);
645
+ }
646
+ queue.unshift(...back);
647
+ for (const item of back) queuedBytes += item.bytes;
648
+ body = bodyOf(idOf());
649
+ }
650
+ // Journalled before the first send: a later process that resends after
608
651
  // a kill sends the same lines under the same id, and the server's
609
652
  // replay check can only ever drop lines it already has.
653
+ batch.id = idOf();
610
654
  const first = batch.items[0] as Item;
611
- const last = batch.items[batch.items.length - 1] as Item;
612
- batch.id = `${device.install_id}-${first.origin}-${first.seq}-${last.seq}-${batch.items.length}`;
613
655
  for (const item of batch.items) {
614
656
  item.batch = batch.id;
615
657
  }
616
658
  journal({ b: batch.id, o: first.origin, s: batch.items.map(item => item.seq) });
617
659
  }
618
- const runToken = batch.token;
619
- const reported = clockOf(runToken);
620
- const body = JSON.stringify({
621
- batch_id: batch.id,
622
- device,
623
- items: batch.items.map(item => item.line),
624
- ...(runToken ? { run_token: runToken } : {}),
625
- ...(reported.length ? { clock: reported } : {}),
626
- });
627
660
  const headers: Record<string, string> = {
628
661
  'content-type': 'application/json',
629
662
  'x-keepsnappy-key': deps.key,
@@ -804,16 +837,23 @@ export const createPushClient = (deps: PushDeps) => {
804
837
  await sendClock(deadline);
805
838
  }
806
839
  const refused = refusedRunBatches - refusedBefore;
807
- const ok = sentAll && refused === 0;
840
+ // A run that lost lines before they were sent is missing windows, whatever went up after.
841
+ const token = deps.getRunToken();
842
+ const lost = token === null ? 0 : (droppedOfRun.get(token) ?? 0);
843
+ if (token !== null) droppedOfRun.delete(token);
844
+ const ok = sentAll && refused === 0 && lost === 0;
845
+ const error = ok
846
+ ? null
847
+ : !sentAll
848
+ ? (stats.lastError ?? 'flush deadline')
849
+ : refused > 0
850
+ ? `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`
851
+ : 'lines of the run dropped before sending';
808
852
  return {
809
853
  ok,
810
854
  batches: stats.batchesSent - sentBefore,
811
855
  lines: stats.linesSent - linesBefore,
812
- error: ok
813
- ? null
814
- : !sentAll
815
- ? (stats.lastError ?? 'flush deadline')
816
- : `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`,
856
+ error: error !== null && lost > 0 ? `${error} dropped=${lost}` : error,
817
857
  };
818
858
  } finally {
819
859
  flushLimit = null;
package/js/sdk/sampler.ts CHANGED
@@ -132,7 +132,7 @@ export const estimateOffset = (before: number, native: number, after: number) =>
132
132
  // late after its t0 a window can start and still take one. It gates only screen windows (a screen window opened by a
133
133
  // press is detected after the new screen rendered and takes the press's open sample, taken the touch delivery delay
134
134
  // past the press's t0) and the reuse of a close sample for a restarted key. Press windows do not depend on it: they
135
- // take the previous window's close sample by contiguity. Evidence for 50: idle lab event timing of 4.9-19.1 ms
135
+ // take the previous window's close sample by contiguity, else a sample of their own however late it is. Evidence for 50: idle lab event timing of 4.9-19.1 ms
136
136
  // (packages/derive/test/fixtures/p37-*.log); no loaded-device data. Measure the touchstart delivery delay p99 per
137
137
  // device from farm data (docs/log/followups.md).
138
138
  export const OPEN_SAMPLE_TOLERANCE_MS = 50;
package/js/sdk/screens.ts CHANGED
@@ -66,7 +66,8 @@ interface Visit {
66
66
  visit: number;
67
67
  screenKey: string | null;
68
68
  legacyName: string;
69
- pendingCommits: number;
69
+ // The screen component is not known yet: its scene has not mounted, or it is suspended.
70
+ pending: boolean;
70
71
  resolution: ScreenRouteResolution;
71
72
  lazyBoundary: Fiber | null;
72
73
  t0: number;
@@ -254,7 +255,7 @@ const endVisit = (reason: string, at?: number) => {
254
255
  if (!visit || visit.endedAt !== null) {
255
256
  return;
256
257
  }
257
- if (visit.pendingCommits > 0) resolveVisit(visit, true);
258
+ if (visit.pending) resolveVisit(visit, true);
258
259
  const t = Math.max(visit.t0, at ?? now());
259
260
  visit.endedAt = t;
260
261
  stats.ends[reason] = (stats.ends[reason] ?? 0) + 1;
@@ -269,7 +270,9 @@ const endVisit = (reason: string, at?: number) => {
269
270
  // state event.
270
271
  const chooseStart = (snapshot: NavSnapshot, floor: number) => {
271
272
  const inSpan = (t: number) => t >= floor && t <= snapshot.stateT;
272
- const presses = pressesSinceVisit.filter(p => inSpan(p.t0));
273
+ // Touches delivered since the previous visit opened, whatever their finger time: a finger that landed while the
274
+ // previous screen was mounting reaches JS after its state was read.
275
+ const presses = pressesSinceVisit.filter(p => p.t0 >= windowsEndedAt && p.t0 <= snapshot.stateT);
273
276
  const link = pendingLink && inSpan(pendingLink.t) ? pendingLink : null;
274
277
  const action = snapshot.action && inSpan(snapshot.action.t) ? snapshot.action : null;
275
278
  const touchStart = (p: Press) => ({
@@ -357,7 +360,7 @@ const resolveVisit = (visit: Visit, final = false) => {
357
360
  if (resolved.screenPending && !final) return;
358
361
  moveScreenCounter(visit, resolved.screen);
359
362
  visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
360
- visit.pendingCommits = 0;
363
+ visit.pending = false;
361
364
  markers.rename(visit.key, visit.name, visit.legacyName);
362
365
  };
363
366
 
@@ -380,7 +383,7 @@ const extend = (visit: Visit, chain: RoutePart[], chainKey: string) => {
380
383
  visit.screenKey = null;
381
384
  }
382
385
  visit.name = visit.legacyName;
383
- visit.pendingCommits = 3;
386
+ visit.pending = true;
384
387
  markers.rename(visit.key, visit.name, visit.legacyName);
385
388
  } else resolveVisit(visit);
386
389
  stats.extended++;
@@ -447,7 +450,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
447
450
  visit: n,
448
451
  legacyName: nameOf(chainKey, n),
449
452
  screenKey: null,
450
- pendingCommits: 3,
453
+ pending: true,
451
454
  resolution: resolved,
452
455
  lazyBoundary: resolved.suspended ?? null,
453
456
  t0: start.t0,
@@ -465,7 +468,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
465
468
  if (!resolved.screenPending) {
466
469
  moveScreenCounter(visit, resolved.screen);
467
470
  visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
468
- visit.pendingCommits = 0;
471
+ visit.pending = false;
469
472
  }
470
473
  current = visit;
471
474
  pressesSinceVisit = [];
@@ -481,7 +484,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
481
484
  kind: 'screen',
482
485
  legacyName: visit.legacyName,
483
486
  beforeEnd: () => {
484
- if (visit.pendingCommits > 0) resolveVisit(visit, true);
487
+ if (visit.pending) resolveVisit(visit, true);
485
488
  visit.lazyBoundary = null;
486
489
  visit.resolution = { screen: visit.screenKey ?? visit.resolution.screen };
487
490
  },
@@ -526,12 +529,14 @@ export const noteAppLink = (url: string, via: string) => {
526
529
  }
527
530
  };
528
531
 
532
+ // Every touch delivered while a visit is open is an action after the one that opened it, even when its finger landed
533
+ // before the SDK read the navigation state (the JS thread was busy with the mount): order decides, not the clocks.
529
534
  const onPress = (t0: number) => {
530
535
  const press = lastPressStart();
531
536
  if (press) {
532
537
  pressesSinceVisit.push(press);
533
538
  }
534
- if (current && current.endedAt === null && t0 > current.openedAt) {
539
+ if (current && current.endedAt === null) {
535
540
  endVisit('next-action', t0);
536
541
  }
537
542
  };
@@ -628,7 +633,7 @@ export const installScreens = () => {
628
633
  installed = true;
629
634
  setScreenKeyProvider(() => current ? {
630
635
  screen: current.screenKey ?? current.resolution.screen,
631
- ...(current.pendingCommits > 0 || current.lazyBoundary ? { screenPending: true } : {}),
636
+ ...(current.pending || current.lazyBoundary ? { screenPending: true } : {}),
632
637
  } : { screen: '(no screen)' });
633
638
  setRetainFloor(screenRetainFloor);
634
639
  onWindowsEnd(onWindowsEnded);
@@ -641,11 +646,12 @@ export const installScreens = () => {
641
646
  onAfterCommit(() => {
642
647
  const visit = current;
643
648
  if (visit && isWindowOpen(visit.key)) {
644
- if (visit.pendingCommits > 0) {
649
+ // A pending visit is resolved when its scene mounts, however many commits come first; it falls back to the route
650
+ // name only when it ends.
651
+ if (visit.pending && !visit.lazyBoundary) {
645
652
  visit.resolution = screenResolution(visit.chain);
646
653
  visit.lazyBoundary = visit.resolution.suspended ?? null;
647
- visit.pendingCommits--;
648
- resolveVisit(visit, visit.pendingCommits === 0);
654
+ resolveVisit(visit);
649
655
  } else if (visit.lazyBoundary) {
650
656
  const boundary = currentFiber(visit.lazyBoundary);
651
657
  if (!boundary || boundary.memoizedState == null) {
package/js/sdk/stream.ts CHANGED
@@ -39,6 +39,12 @@ export const createStream = (processId: string, maxFibers = MAX_SEGMENT_FIBERS)
39
39
  /** The segment the next commit goes into: windows name it so the backend can tell a stream is complete. */
40
40
  seq: () => seq,
41
41
  size: () => commits.length,
42
+ /** Sets the fiber cap of the segments from now on; returns the previous cap. */
43
+ setMaxFibers(n: number) {
44
+ const previous = maxFibers;
45
+ maxFibers = n;
46
+ return previous;
47
+ },
42
48
  noteRoot(root: CauseRoot) {
43
49
  roots.push(root);
44
50
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepsnappy/react-native",
3
- "version": "0.1.0-alpha.16",
3
+ "version": "0.1.0-alpha.17",
4
4
  "description": "KeepSnappy in-app SDK for React Native / Expo: zero-code measurement windows, React commit hook, native signposts and log file",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {