@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.
- package/js/babel-transformer.js +11 -4
- package/js/component-stamps.js +22 -7
- package/js/sdk/core.ts +37 -8
- package/js/sdk/fiber.ts +77 -7
- package/js/sdk/interactions.ts +19 -8
- package/js/sdk/push.ts +67 -27
- package/js/sdk/sampler.ts +1 -1
- package/js/sdk/screens.ts +19 -13
- package/js/sdk/stream.ts +6 -0
- package/package.json +1 -1
package/js/babel-transformer.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 =
|
package/js/component-stamps.js
CHANGED
|
@@ -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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
t.binaryExpression('
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 >
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
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,
|
package/js/sdk/interactions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
552
|
+
wire += next.wire;
|
|
539
553
|
}
|
|
540
|
-
return { id: first.batch, items, token: first.token,
|
|
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 &&
|
|
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
|
-
|
|
570
|
+
wire += next.wire;
|
|
557
571
|
}
|
|
558
|
-
return { id: '', items, token: first.token,
|
|
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
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|