@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -2
- package/android/build.gradle +35 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabModule.kt +445 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabSampler.kt +515 -0
- package/app.plugin.js +105 -0
- package/expo-module.config.json +9 -0
- package/ios/PerfLab.podspec +29 -0
- package/ios/PerfLabHermes.h +18 -0
- package/ios/PerfLabHermes.mm +64 -0
- package/ios/PerfLabModule.swift +371 -0
- package/ios/PerfLabSampler.swift +364 -0
- package/js/babel-transformer.js +71 -0
- package/js/hook.polyfill.js +333 -0
- package/js/renderer-switch.js +16 -0
- package/js/sdk/activation.ts +375 -0
- package/js/sdk/active.ts +62 -0
- package/js/sdk/artifacts.ts +632 -0
- package/js/sdk/boot.ts +21 -0
- package/js/sdk/causes.ts +932 -0
- package/js/sdk/core.ts +2403 -0
- package/js/sdk/cpuprof.ts +133 -0
- package/js/sdk/dormant.ts +77 -0
- package/js/sdk/env.d.ts +2 -0
- package/js/sdk/fiber.ts +591 -0
- package/js/sdk/interactions.ts +531 -0
- package/js/sdk/links.ts +201 -0
- package/js/sdk/nav.ts +383 -0
- package/js/sdk/net-install.ts +49 -0
- package/js/sdk/net.ts +684 -0
- package/js/sdk/profile-export.ts +257 -0
- package/js/sdk/profile.ts +539 -0
- package/js/sdk/push-install.ts +158 -0
- package/js/sdk/push.ts +819 -0
- package/js/sdk/retain-floor.ts +11 -0
- package/js/sdk/sampler.ts +236 -0
- package/js/sdk/screens.ts +586 -0
- package/js/sdk/stream.ts +125 -0
- package/js/zustand-vanilla.js +31 -0
- package/metro.js +237 -0
- package/package.json +26 -4
package/js/sdk/causes.ts
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
// Which source caused each React commit (REQ-MEAS-32): the touch a window measures, a push from outside the device, an
|
|
2
|
+
// earlier action, or nothing we can see.
|
|
3
|
+
//
|
|
4
|
+
// Every state update is stamped with the cause of the JavaScript task that scheduled it. React adds the updating fiber
|
|
5
|
+
// to root.pendingUpdatersLaneMap synchronously inside scheduleUpdateOnFiber whenever a DevTools hook is present, so
|
|
6
|
+
// wrapping those Sets' `add` sees every setState, dispatch and forceUpdate. The cause is carried from the code that
|
|
7
|
+
// registers a continuation to the code that runs it: timers, animation frames, microtasks, promise reactions (async/await
|
|
8
|
+
// goes through Promise.prototype.then in Hermes), XMLHttpRequest events, React's own scheduler tasks, and the first
|
|
9
|
+
// layout or load event of a view. New causes start at a touch, at the user's typing, at a deep link, at app start, and
|
|
10
|
+
// at every native event.
|
|
11
|
+
|
|
12
|
+
type AnyFn = (...args: unknown[]) => unknown;
|
|
13
|
+
|
|
14
|
+
/** `action`: a touch, typing, a deep link or app start. `push`: data that arrived from outside the device. `native`: any other native event. */
|
|
15
|
+
export type RootKind = 'action' | 'push' | 'native';
|
|
16
|
+
|
|
17
|
+
export interface CauseRoot {
|
|
18
|
+
readonly id: number;
|
|
19
|
+
readonly kind: RootKind;
|
|
20
|
+
/** The event behind the root: `touch`, `input`, `link`, `launch`, or the native event name. */
|
|
21
|
+
readonly source: string;
|
|
22
|
+
/** performance.now() when the event behind it reached JavaScript. */
|
|
23
|
+
readonly t: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One run of a timer callback after a newer action than the one behind the code that registered the timer: a long-lived
|
|
28
|
+
* timer (a queue drain, a poller, a ticker) carries an old cause into later work. Every run is its own tick, whether the
|
|
29
|
+
* timer repeats (setInterval) or re-registers itself (setTimeout, rAF chains). `net` turns true when the run, or a
|
|
30
|
+
* continuation of it, sends a request; work in the response is then poller-like. It is read when a commit is reported, so
|
|
31
|
+
* a run's render that comes before its request reads `net` as the run ends up. Nothing decides on it in the SDK beyond
|
|
32
|
+
* the window's split of its foreign part; the backend compares attribution approaches from it.
|
|
33
|
+
*/
|
|
34
|
+
export interface Tick {
|
|
35
|
+
readonly net: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Facts the commit stream records (phase 1 of "the SDK records, the backend decides"): one run of a timer callback, with
|
|
40
|
+
* the cause it was registered under. Every run gets one; whether it ran after a newer action is the backend's call.
|
|
41
|
+
*/
|
|
42
|
+
export interface TimerRun extends Tick {
|
|
43
|
+
readonly id: number;
|
|
44
|
+
readonly timer: string;
|
|
45
|
+
/** performance.now() when it ran, and when it was registered. */
|
|
46
|
+
readonly t: number;
|
|
47
|
+
readonly registeredT: number;
|
|
48
|
+
readonly registered: CauseSet;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One `setState` of a wrapped store, under the writer's own cause (before any move). */
|
|
52
|
+
export interface StoreWrite {
|
|
53
|
+
readonly id: number;
|
|
54
|
+
readonly store: number;
|
|
55
|
+
readonly t: number;
|
|
56
|
+
readonly writer: CauseSet;
|
|
57
|
+
readonly run: TimerRun | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Up to this many parts are kept per commit; the rest are counted. */
|
|
61
|
+
export const FACTS_CAP = 16;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One kind of update in a set: the roots behind it (the writer's own for a store write, before any move), the timer run
|
|
65
|
+
* it ran in, and the store write it came from. Updates with the same three are one part.
|
|
66
|
+
*/
|
|
67
|
+
export interface Part {
|
|
68
|
+
readonly ids: readonly number[];
|
|
69
|
+
readonly run: TimerRun | null;
|
|
70
|
+
readonly write: StoreWrite | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The facts behind a set, kept in full where the four roots are a reduction (merged sets only). */
|
|
74
|
+
export interface Facts {
|
|
75
|
+
readonly parts: readonly Part[];
|
|
76
|
+
/** Parts past the cap. */
|
|
77
|
+
readonly more: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A store write a carried tick made, moved to the newer action that wrote the same store before it. */
|
|
81
|
+
export interface StoreMove {
|
|
82
|
+
readonly from: CauseRoot;
|
|
83
|
+
readonly to: CauseRoot;
|
|
84
|
+
/** The store's literal last writer, when it is not `to` (a late write of an older action came after `to`'s). */
|
|
85
|
+
readonly lastWrite?: CauseRoot;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The roots classification reads, and the tick, of a set. */
|
|
89
|
+
interface Roots {
|
|
90
|
+
readonly native: CauseRoot | null;
|
|
91
|
+
readonly push: CauseRoot | null;
|
|
92
|
+
readonly newest: CauseRoot | null;
|
|
93
|
+
readonly oldest: CauseRoot | null;
|
|
94
|
+
readonly carried?: Tick;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A set of causes, kept as the four roots classification reads, so merging any number of causes costs the same: the
|
|
99
|
+
* first native root, the first push root, and the newest and oldest actions. `carried`: every update in it came from a
|
|
100
|
+
* timer tick after a newer action. `store`: some update in it came from a store write moved to a newer action; `task` is
|
|
101
|
+
* then the set as it would be without any move (the backend replays the other approaches from it).
|
|
102
|
+
*/
|
|
103
|
+
export interface CauseSet extends Roots {
|
|
104
|
+
readonly store?: StoreMove;
|
|
105
|
+
readonly task?: Roots;
|
|
106
|
+
/** The timer run this code runs in, and the store write it runs inside (the stream's facts). */
|
|
107
|
+
readonly run?: TimerRun;
|
|
108
|
+
readonly write?: StoreWrite;
|
|
109
|
+
/** Set by merges: the full facts of every set merged in. */
|
|
110
|
+
readonly facts?: Facts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** How timer-carried a commit is (REQ-MEAS-32 timers): `timer`, `timer-net` (its tick sent a request), or none. */
|
|
114
|
+
export type Carrier = 'timer' | 'timer-net' | null;
|
|
115
|
+
|
|
116
|
+
export const carrierOf = (set: CauseSet): Carrier => (set.carried ? (set.carried.net ? 'timer-net' : 'timer') : null);
|
|
117
|
+
|
|
118
|
+
export interface CommitCauses {
|
|
119
|
+
readonly set: CauseSet;
|
|
120
|
+
/** Some update in the commit (or the commit itself) had no cause. */
|
|
121
|
+
readonly unknown: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Where a commit's work goes for one window: the judged part, or out of it as foreign work of one source. */
|
|
125
|
+
export type WindowPart = { readonly judged: true } | { readonly judged: false; readonly source: string };
|
|
126
|
+
|
|
127
|
+
/** Push events: the native event name and the source a background place is named after. */
|
|
128
|
+
const PUSH_EVENTS: Readonly<Record<string, string>> = { websocketMessage: 'websocket' };
|
|
129
|
+
const LINK_EVENTS = new Set(['url']);
|
|
130
|
+
const SOCKET_EVENTS = new Set(['websocketOpen', 'websocketClosed', 'websocketFailed']);
|
|
131
|
+
const SOCKET_END_EVENTS = new Set(['websocketClosed', 'websocketFailed']);
|
|
132
|
+
/** Events the user produces without a new touch. They belong to the latest touch. */
|
|
133
|
+
const USER_INPUT_EVENTS = new Set([
|
|
134
|
+
'topChange',
|
|
135
|
+
'topChangeText',
|
|
136
|
+
'topTextInput',
|
|
137
|
+
'topKeyPress',
|
|
138
|
+
'topSubmitEditing',
|
|
139
|
+
'topEndEditing',
|
|
140
|
+
'topSelectionChange',
|
|
141
|
+
'topFocus',
|
|
142
|
+
'topBlur',
|
|
143
|
+
]);
|
|
144
|
+
/** Events a finger drives: the touch itself, scrolling and its momentum, gesture handlers, pull to refresh, sliders. */
|
|
145
|
+
const TOUCH_DRIVEN = /^top(Touch|Click|Pointer|Scroll|MomentumScroll|Refresh|ValueChange|Sliding)|GestureHandler/;
|
|
146
|
+
/** Events that report on a view's first rendered output: the first of each kind after the view mounted takes its cause. */
|
|
147
|
+
const FIRST_OUTPUT_EVENTS = new Set(['topLayout', 'topLoadStart', 'topLoad', 'topLoadEnd']);
|
|
148
|
+
const XHR_EVENTS = [
|
|
149
|
+
'__didCreateRequest',
|
|
150
|
+
'__didUploadProgress',
|
|
151
|
+
'__didReceiveResponse',
|
|
152
|
+
'__didReceiveData',
|
|
153
|
+
'__didReceiveIncrementalData',
|
|
154
|
+
'__didReceiveDataProgress',
|
|
155
|
+
'__didCompleteResponse',
|
|
156
|
+
];
|
|
157
|
+
const GLOBAL_CONTINUATIONS = [
|
|
158
|
+
'setTimeout',
|
|
159
|
+
'setInterval',
|
|
160
|
+
'setImmediate',
|
|
161
|
+
'requestAnimationFrame',
|
|
162
|
+
'requestIdleCallback',
|
|
163
|
+
'queueMicrotask',
|
|
164
|
+
];
|
|
165
|
+
const HOST_COMPONENT = 5;
|
|
166
|
+
|
|
167
|
+
const EMPTY: CauseSet = { native: null, push: null, newest: null, oldest: null };
|
|
168
|
+
|
|
169
|
+
const isEmpty = (s: CauseSet) => s.native === null && s.push === null && s.newest === null;
|
|
170
|
+
|
|
171
|
+
const setOf = (r: CauseRoot): CauseSet => {
|
|
172
|
+
if (r.kind === 'action') return { native: null, push: null, newest: r, oldest: r };
|
|
173
|
+
return r.kind === 'push' ? { ...EMPTY, push: r } : { ...EMPTY, native: r };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Carried only when both sides are; then `net` only when both ticks sent a request (read when the commit is classified).
|
|
177
|
+
const mergeTicks = (a: Tick | undefined, b: Tick | undefined): Tick | undefined => {
|
|
178
|
+
if (!a || !b) return undefined;
|
|
179
|
+
if (a === b) return a;
|
|
180
|
+
return {
|
|
181
|
+
get net() {
|
|
182
|
+
return a.net && b.net;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const mergeRoots = (a: Roots, b: Roots): Roots => {
|
|
188
|
+
const carried = mergeTicks(a.carried, b.carried);
|
|
189
|
+
return {
|
|
190
|
+
native: a.native ?? b.native,
|
|
191
|
+
push: a.push ?? b.push,
|
|
192
|
+
newest: a.newest === null || (b.newest !== null && b.newest.id > a.newest.id) ? b.newest : a.newest,
|
|
193
|
+
oldest: a.oldest === null || (b.oldest !== null && b.oldest.id < a.oldest.id) ? b.oldest : a.oldest,
|
|
194
|
+
...(carried ? { carried } : {}),
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const rootsOf = (s: CauseSet): Roots => {
|
|
199
|
+
const { store: _store, task: _task, ...roots } = s;
|
|
200
|
+
return roots;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const rootIds = (s: Roots): number[] => {
|
|
204
|
+
const out: number[] = [];
|
|
205
|
+
for (const r of [s.native, s.push, s.oldest, s.newest]) if (r !== null && !out.includes(r.id)) out.push(r.id);
|
|
206
|
+
return out;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** The facts of a set: its own single part, or the parts its merges kept. */
|
|
210
|
+
export const factsOf = (s: CauseSet): Facts =>
|
|
211
|
+
s.facts ?? { parts: [{ ids: rootIds(s), run: s.run ?? null, write: s.write ?? null }], more: 0 };
|
|
212
|
+
|
|
213
|
+
/** Every root id of a set's parts, once. */
|
|
214
|
+
export const factIds = (f: Facts): number[] => {
|
|
215
|
+
const out: number[] = [];
|
|
216
|
+
for (const p of f.parts) for (const id of p.ids) if (!out.includes(id)) out.push(id);
|
|
217
|
+
return out;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const samePart = (x: Part, y: Part) =>
|
|
221
|
+
x.run === y.run && x.write === y.write && x.ids.length === y.ids.length && x.ids.every((id, i) => id === y.ids[i]);
|
|
222
|
+
|
|
223
|
+
const mergeFacts = (a: Facts, b: Facts): Facts => {
|
|
224
|
+
let parts: Part[] | null = null;
|
|
225
|
+
let more = a.more + b.more;
|
|
226
|
+
for (const part of b.parts) {
|
|
227
|
+
if (a.parts.some(p => samePart(p, part)) || parts?.some(p => samePart(p, part))) continue;
|
|
228
|
+
if ((parts ?? a.parts).length >= FACTS_CAP) {
|
|
229
|
+
more++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
(parts ??= [...a.parts]).push(part);
|
|
233
|
+
}
|
|
234
|
+
return parts === null && more === a.more ? a : { parts: parts ?? a.parts, more };
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const sameRoots = (x: Roots, y: Roots) =>
|
|
238
|
+
x.native === y.native && x.push === y.push && x.newest === y.newest && x.oldest === y.oldest && x.carried === y.carried;
|
|
239
|
+
|
|
240
|
+
const merge = (a: CauseSet, b: CauseSet): CauseSet => {
|
|
241
|
+
if (isEmpty(a)) return b;
|
|
242
|
+
if (isEmpty(b) || a === b) return a;
|
|
243
|
+
const store = a.store ?? b.store;
|
|
244
|
+
const task = a.task || b.task ? mergeRoots(a.task ?? rootsOf(a), b.task ?? rootsOf(b)) : undefined;
|
|
245
|
+
const fa = factsOf(a);
|
|
246
|
+
const facts = mergeFacts(fa, factsOf(b));
|
|
247
|
+
const out: CauseSet = {
|
|
248
|
+
...mergeRoots(a, b),
|
|
249
|
+
...(store ? { store } : {}),
|
|
250
|
+
...(task ? { task } : {}),
|
|
251
|
+
facts,
|
|
252
|
+
};
|
|
253
|
+
return sameRoots(out, a) &&
|
|
254
|
+
out.store === a.store &&
|
|
255
|
+
facts === fa &&
|
|
256
|
+
(task === undefined || (a.task !== undefined && sameRoots(task, a.task)))
|
|
257
|
+
? a
|
|
258
|
+
: out;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** Marks the tasks React's scheduler runs: they take the causes of the render or effects they process. */
|
|
262
|
+
const REACT_TASK: unique symbol = Symbol('react-task');
|
|
263
|
+
type Current = CauseSet | typeof REACT_TASK | null;
|
|
264
|
+
|
|
265
|
+
let current: Current = null;
|
|
266
|
+
let nextRoot = 1;
|
|
267
|
+
let installed = false;
|
|
268
|
+
|
|
269
|
+
/** Causes of the render React is running or the commit whose effects it is flushing. */
|
|
270
|
+
let reactCauses: CauseSet = EMPTY;
|
|
271
|
+
let reactUnknown = false;
|
|
272
|
+
/** The ambient cause when React scheduled the task it is running now (a ping, a retry, a render with no updaters). */
|
|
273
|
+
let taskCauses: CauseSet | null = null;
|
|
274
|
+
let renderStarted = false;
|
|
275
|
+
|
|
276
|
+
let latestTouch: CauseRoot | null = null;
|
|
277
|
+
/** App start: the bundle's first task and every surface the app starts. */
|
|
278
|
+
let launchRoot: CauseRoot | null = null;
|
|
279
|
+
let latestAction = 0;
|
|
280
|
+
let newestActionT = -Infinity;
|
|
281
|
+
/** performance.now() when the app last started a surface (REQ-MEAS-22): the start of the launch screen window. */
|
|
282
|
+
let launchT: number | null = null;
|
|
283
|
+
|
|
284
|
+
const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
|
285
|
+
|
|
286
|
+
let inLaunch = false;
|
|
287
|
+
|
|
288
|
+
/** When the app last started a surface (`AppRegistry.runApplication` or bridgeless `renderSurface`), or null before that. */
|
|
289
|
+
export const launchedAt = (): number | null => launchT;
|
|
290
|
+
|
|
291
|
+
/** The launch's own cause root: the launch screen window anchors at it (REQ-MEAS-22). */
|
|
292
|
+
export const launchRootId = (): number | null => launchRoot?.id ?? null;
|
|
293
|
+
|
|
294
|
+
export const causeStats = {
|
|
295
|
+
installed: false,
|
|
296
|
+
/** Whether `await` runs through Promise.prototype.then on this engine (checked at install). */
|
|
297
|
+
awaitThroughThen: null as boolean | null,
|
|
298
|
+
continuations: 0,
|
|
299
|
+
nativeEvents: 0,
|
|
300
|
+
touches: 0,
|
|
301
|
+
stamps: 0,
|
|
302
|
+
unknownStamps: 0,
|
|
303
|
+
hostEvents: 0,
|
|
304
|
+
hostEventsAttributed: 0,
|
|
305
|
+
carriedTicks: 0,
|
|
306
|
+
/** zustand stores created through the store wrapper (js/zustand-vanilla.js); 0 means no store write can move. */
|
|
307
|
+
storesWrapped: 0,
|
|
308
|
+
storeMoves: 0,
|
|
309
|
+
/** Writes by an action older than the store's newest writer: "last write" would have taken the store back to them. */
|
|
310
|
+
storeLateWrites: 0,
|
|
311
|
+
errors: 0,
|
|
312
|
+
patched: [] as string[],
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const rootListeners = new Set<(root: CauseRoot) => void>();
|
|
316
|
+
const writeListeners = new Set<(write: StoreWrite) => void>();
|
|
317
|
+
|
|
318
|
+
/** Listeners (the commit stream) for every root the tracker creates and every store write it sees; each returns its removal. */
|
|
319
|
+
export const addRootListener = (listener: (root: CauseRoot) => void) => {
|
|
320
|
+
rootListeners.add(listener);
|
|
321
|
+
return () => rootListeners.delete(listener);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export const addWriteListener = (listener: (write: StoreWrite) => void) => {
|
|
325
|
+
writeListeners.add(listener);
|
|
326
|
+
return () => writeListeners.delete(listener);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const newRoot = (kind: RootKind, source: string): CauseRoot => {
|
|
330
|
+
const root = { id: nextRoot++, kind, source, t: clock() };
|
|
331
|
+
if (kind === 'action') {
|
|
332
|
+
latestAction = root.id;
|
|
333
|
+
newestActionT = root.t;
|
|
334
|
+
}
|
|
335
|
+
for (const listener of rootListeners) listener(root);
|
|
336
|
+
return root;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
let nextRun = 1;
|
|
340
|
+
let nextWrite = 1;
|
|
341
|
+
let nextStore = 1;
|
|
342
|
+
|
|
343
|
+
const resolved = (): CommitCauses => {
|
|
344
|
+
if (current === REACT_TASK) {
|
|
345
|
+
if (!isEmpty(reactCauses)) return { set: reactCauses, unknown: reactUnknown };
|
|
346
|
+
return taskCauses ? { set: taskCauses, unknown: false } : { set: EMPTY, unknown: true };
|
|
347
|
+
}
|
|
348
|
+
return current ? { set: current, unknown: false } : { set: EMPTY, unknown: true };
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const runUnder = <T>(cause: Current, fn: () => T): T => {
|
|
352
|
+
const prev = current;
|
|
353
|
+
current = cause;
|
|
354
|
+
try {
|
|
355
|
+
return fn();
|
|
356
|
+
} finally {
|
|
357
|
+
current = prev;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// The cause at registration, frozen: a React task resolves to the causes it is processing now.
|
|
362
|
+
const captured = (): CauseSet | null => {
|
|
363
|
+
if (current === null) return null;
|
|
364
|
+
const { set } = resolved();
|
|
365
|
+
return isEmpty(set) ? null : set;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const bind = (fn: unknown): unknown => {
|
|
369
|
+
if (typeof fn !== 'function') return fn;
|
|
370
|
+
const cause = captured();
|
|
371
|
+
if (cause === null) return fn;
|
|
372
|
+
causeStats.continuations++;
|
|
373
|
+
const f = fn as AnyFn;
|
|
374
|
+
return function (this: unknown, ...args: unknown[]) {
|
|
375
|
+
return runUnder(cause, () => f.apply(this, args));
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// A timer callback runs under its registration's cause; when a newer action came since, the run is a carried tick (one
|
|
380
|
+
// tick record per run, shared by its continuations, so a request it sends marks its responses too).
|
|
381
|
+
const bindTimer = (fn: unknown, timer: string): unknown => {
|
|
382
|
+
if (typeof fn !== 'function') return fn;
|
|
383
|
+
const cause = captured();
|
|
384
|
+
if (cause === null) return fn;
|
|
385
|
+
causeStats.continuations++;
|
|
386
|
+
const registeredT = clock();
|
|
387
|
+
const f = fn as AnyFn;
|
|
388
|
+
return function (this: unknown, ...args: unknown[]) {
|
|
389
|
+
const tick: TimerRun = { id: nextRun++, timer, t: clock(), registeredT, registered: cause, net: false };
|
|
390
|
+
const stale = cause.newest !== null && cause.newest.id < latestAction;
|
|
391
|
+
const { facts: _facts, write: _write, ...own } = cause;
|
|
392
|
+
const run: CauseSet = stale ? { ...own, run: tick, carried: tick } : { ...own, run: tick };
|
|
393
|
+
if (stale) causeStats.carriedTicks++;
|
|
394
|
+
return runUnder(run, () => f.apply(this, args));
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const TIMERS = new Set(['setTimeout', 'setInterval', 'setImmediate', 'requestAnimationFrame', 'requestIdleCallback']);
|
|
399
|
+
|
|
400
|
+
/** Called by the request counter when the app starts a request (fetch or XHR): a carried tick's chain is then poller-like. */
|
|
401
|
+
export const noteRequestSent = () => {
|
|
402
|
+
if (current === null) return;
|
|
403
|
+
const set = resolved().set;
|
|
404
|
+
const tick = (set.run ?? set.carried) as { net: boolean } | undefined;
|
|
405
|
+
// A merged tick (two ticks in one render) is read-only: it reports `net` from both.
|
|
406
|
+
if (tick && !tick.net && Object.getOwnPropertyDescriptor(tick, 'net')?.writable) tick.net = true;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A zustand store's setState (REQ-MEAS-32 timers, option B). Each store keeps the newest action that wrote it (a late
|
|
411
|
+
* response of an older action does not take it back). A write under a carried tick, to a store a newer action wrote, runs
|
|
412
|
+
* under that action, and keeps the set it had as `task`: a timer that serves a queue does the work of the action that
|
|
413
|
+
* queued it. A poller's own writes stay its own.
|
|
414
|
+
*/
|
|
415
|
+
export const storeWriter = (set: AnyFn): AnyFn => {
|
|
416
|
+
causeStats.storesWrapped++;
|
|
417
|
+
const store = nextStore++;
|
|
418
|
+
let last: CauseRoot | null = null;
|
|
419
|
+
// The other ruling ("the last write"), kept so the backend can compare the two.
|
|
420
|
+
let lastWrite: CauseRoot | null = null;
|
|
421
|
+
return function (this: unknown, ...args: unknown[]) {
|
|
422
|
+
if (current === null) return set.apply(this, args);
|
|
423
|
+
const own = resolved().set;
|
|
424
|
+
const write: StoreWrite = { id: nextWrite++, store, t: clock(), writer: own, run: own.run ?? null };
|
|
425
|
+
for (const listener of writeListeners) listener(write);
|
|
426
|
+
const part: Facts = { parts: [{ ids: factIds(factsOf(own)), run: own.run ?? null, write }], more: 0 };
|
|
427
|
+
if (own.carried && own.newest !== null && last !== null && last.id > own.newest.id) {
|
|
428
|
+
causeStats.storeMoves++;
|
|
429
|
+
const move = lastWrite !== null && lastWrite !== last ? { from: own.newest, to: last, lastWrite } : { from: own.newest, to: last };
|
|
430
|
+
const moved: CauseSet = { ...setOf(last), store: move, task: rootsOf(own), write, facts: part };
|
|
431
|
+
return runUnder(moved, () => set.apply(this, args));
|
|
432
|
+
}
|
|
433
|
+
if (own.newest !== null && last !== null && own.newest.id < last.id) causeStats.storeLateWrites++;
|
|
434
|
+
if (own.newest !== null) lastWrite = own.newest;
|
|
435
|
+
if (own.newest !== null && (last === null || own.newest.id > last.id)) last = own.newest;
|
|
436
|
+
const { facts: _facts, ...rest } = own;
|
|
437
|
+
return runUnder({ ...rest, write, facts: part }, () => set.apply(this, args));
|
|
438
|
+
};
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const wrapGlobal = (target: Record<string, unknown>, name: string) => {
|
|
442
|
+
const orig = target[name];
|
|
443
|
+
if (typeof orig !== 'function') return;
|
|
444
|
+
const fn = orig as AnyFn;
|
|
445
|
+
const timer = TIMERS.has(name);
|
|
446
|
+
const wrapped = function (this: unknown, ...args: unknown[]) {
|
|
447
|
+
args[0] = timer ? bindTimer(args[0], name) : bind(args[0]);
|
|
448
|
+
return fn.apply(this, args);
|
|
449
|
+
};
|
|
450
|
+
try {
|
|
451
|
+
Object.defineProperty(target, name, { value: wrapped, writable: true, configurable: true, enumerable: true });
|
|
452
|
+
} catch {
|
|
453
|
+
target[name] = wrapped;
|
|
454
|
+
}
|
|
455
|
+
causeStats.patched.push(name);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const wrapPromise = (P: { prototype: { then: AnyFn } } | undefined) => {
|
|
459
|
+
if (!P || typeof P.prototype.then !== 'function') return null;
|
|
460
|
+
const then = P.prototype.then;
|
|
461
|
+
P.prototype.then = function (this: unknown, onFulfilled: unknown, onRejected: unknown) {
|
|
462
|
+
return then.call(this, bind(onFulfilled), bind(onRejected));
|
|
463
|
+
};
|
|
464
|
+
causeStats.patched.push('Promise.then');
|
|
465
|
+
return then;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// --- React: stamps and render causes ---
|
|
469
|
+
|
|
470
|
+
interface UpdaterSet {
|
|
471
|
+
add: (fiber: unknown) => unknown;
|
|
472
|
+
clear: () => unknown;
|
|
473
|
+
forEach: (cb: (fiber: unknown) => void) => void;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
interface StampedFiber {
|
|
477
|
+
alternate?: StampedFiber | null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The merged causes of the updates a fiber scheduled since the last render took them. */
|
|
481
|
+
interface Stamp {
|
|
482
|
+
set: CauseSet;
|
|
483
|
+
unknown: boolean;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const pending = new WeakMap<object, Stamp>();
|
|
487
|
+
const patchedRoots = new WeakSet<object>();
|
|
488
|
+
|
|
489
|
+
const stampOf = (fiber: StampedFiber): Stamp | undefined =>
|
|
490
|
+
pending.get(fiber) ?? (fiber.alternate ? pending.get(fiber.alternate) : undefined);
|
|
491
|
+
|
|
492
|
+
const stamp = (fiber: StampedFiber) => {
|
|
493
|
+
const { set, unknown } = resolved();
|
|
494
|
+
causeStats.stamps++;
|
|
495
|
+
if (unknown && isEmpty(set)) causeStats.unknownStamps++;
|
|
496
|
+
const s = stampOf(fiber);
|
|
497
|
+
if (s) {
|
|
498
|
+
s.set = merge(s.set, set);
|
|
499
|
+
s.unknown = s.unknown || unknown;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const fresh = { set, unknown };
|
|
503
|
+
pending.set(fiber, fresh);
|
|
504
|
+
if (fiber.alternate) pending.set(fiber.alternate, fresh);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const beginRender = () => {
|
|
508
|
+
if (renderStarted) return;
|
|
509
|
+
renderStarted = true;
|
|
510
|
+
reactCauses = EMPTY;
|
|
511
|
+
reactUnknown = false;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// React moves the pending updaters into root.memoizedUpdaters when a render starts, so their stamps are the render's causes.
|
|
515
|
+
const takeStamps = (fiber: StampedFiber) => {
|
|
516
|
+
beginRender();
|
|
517
|
+
const s = stampOf(fiber);
|
|
518
|
+
if (s) {
|
|
519
|
+
reactCauses = merge(reactCauses, s.set);
|
|
520
|
+
if (s.unknown) reactUnknown = true;
|
|
521
|
+
}
|
|
522
|
+
pending.delete(fiber);
|
|
523
|
+
if (fiber.alternate) pending.delete(fiber.alternate);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const patchSet = (set: UpdaterSet, onAdd: (fiber: StampedFiber) => void, onClear?: () => void) => {
|
|
527
|
+
const add = set.add;
|
|
528
|
+
set.add = function (this: unknown, fiber: unknown) {
|
|
529
|
+
try {
|
|
530
|
+
onAdd(fiber as StampedFiber);
|
|
531
|
+
} catch {
|
|
532
|
+
causeStats.errors++;
|
|
533
|
+
}
|
|
534
|
+
return add.call(this, fiber);
|
|
535
|
+
};
|
|
536
|
+
if (onClear) {
|
|
537
|
+
const clear = set.clear;
|
|
538
|
+
set.clear = function (this: unknown) {
|
|
539
|
+
onClear();
|
|
540
|
+
return clear.call(this);
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
/** Patches a root's updater Sets the first time the SDK sees it; the root's first render has no stamps. */
|
|
546
|
+
const patchRoot = (root: { pendingUpdatersLaneMap?: unknown; memoizedUpdaters?: unknown }) => {
|
|
547
|
+
if (patchedRoots.has(root)) return;
|
|
548
|
+
patchedRoots.add(root);
|
|
549
|
+
if (Array.isArray(root.pendingUpdatersLaneMap)) {
|
|
550
|
+
for (const set of root.pendingUpdatersLaneMap as UpdaterSet[]) patchSet(set, stamp);
|
|
551
|
+
}
|
|
552
|
+
const memoized = root.memoizedUpdaters as UpdaterSet | undefined;
|
|
553
|
+
if (memoized && typeof memoized.add === 'function') patchSet(memoized, takeStamps, beginRender);
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The causes of the commit React is reporting. Called from the commit hook before anything else reads the root. A commit
|
|
558
|
+
* whose render took no updaters (a Suspense retry, a render with nothing pending) has the cause of whoever scheduled it.
|
|
559
|
+
*/
|
|
560
|
+
export const commitCauses = (root: object): CommitCauses => {
|
|
561
|
+
patchRoot(root as { pendingUpdatersLaneMap?: unknown; memoizedUpdaters?: unknown });
|
|
562
|
+
let set = renderStarted ? reactCauses : EMPTY;
|
|
563
|
+
let unknown = renderStarted ? reactUnknown : true;
|
|
564
|
+
if (isEmpty(set)) {
|
|
565
|
+
const ambient = current === REACT_TASK ? taskCauses : current;
|
|
566
|
+
if (ambient && !isEmpty(ambient)) {
|
|
567
|
+
set = ambient;
|
|
568
|
+
unknown = false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
reactCauses = set;
|
|
572
|
+
reactUnknown = unknown;
|
|
573
|
+
renderStarted = false;
|
|
574
|
+
return { set, unknown };
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/** A view's cause until its first layout and load events have taken it. */
|
|
578
|
+
interface ViewCause {
|
|
579
|
+
readonly set: CauseSet;
|
|
580
|
+
readonly taken: Set<string>;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const viewCause = new Map<number, ViewCause>();
|
|
584
|
+
|
|
585
|
+
const nativeTagOf = (fiber: { stateNode: unknown }) => {
|
|
586
|
+
const tag = (fiber.stateNode as { canonical?: { nativeTag?: unknown } } | null)?.canonical?.nativeTag;
|
|
587
|
+
return typeof tag === 'number' ? tag : null;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/** Records the cause of a host view the commit mounted, so the view's first layout and load events inherit it. */
|
|
591
|
+
export const noteHostFiber = (fiber: { tag: number; alternate: unknown; stateNode: unknown }, causes: CommitCauses) => {
|
|
592
|
+
if (fiber.tag !== HOST_COMPONENT || fiber.alternate !== null || isEmpty(causes.set)) return;
|
|
593
|
+
const tag = nativeTagOf(fiber);
|
|
594
|
+
if (tag !== null) viewCause.set(tag, { set: causes.set, taken: new Set() });
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
export const forgetHostFiber = (fiber: { stateNode: unknown }) => {
|
|
598
|
+
const tag = nativeTagOf(fiber);
|
|
599
|
+
if (tag !== null) viewCause.delete(tag);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// --- Classification ---
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The newest action so far (a touch, typing with no touch yet, a deep link, the launch). A window takes it as its anchor
|
|
606
|
+
* when it starts: the action that opened it, or the one whose work led to it (a screen opened after an `await`).
|
|
607
|
+
*/
|
|
608
|
+
export const latestActionId = (): number => latestAction;
|
|
609
|
+
|
|
610
|
+
/** When the newest action (a touch, typing, a link, the launch) reached JavaScript, or -Infinity before any. */
|
|
611
|
+
export const latestActionAt = (): number => newestActionT;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* What started the code running now: its newest action (a touch, typing, a link, the launch), else the data pushed to the
|
|
615
|
+
* app, else the native event; null when nothing is known.
|
|
616
|
+
*/
|
|
617
|
+
export const currentCause = (): CauseRoot | null => {
|
|
618
|
+
const { set } = resolved();
|
|
619
|
+
return set.newest ?? set.push ?? set.native;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The part of a window a commit belongs to. It leaves the judged part only when every cause is proven foreign to the
|
|
624
|
+
* window: a push from outside the device, or an action older than the window's anchor. Work with no cause, work mixed
|
|
625
|
+
* with the window's own action, and work from other native events stay judged. The source of foreign work is the push's
|
|
626
|
+
* source (`websocket`), else the oldest action's (`launch`, `link`, `touch`, `input`).
|
|
627
|
+
*/
|
|
628
|
+
export const windowPart = (causes: CommitCauses, anchorId: number): WindowPart => {
|
|
629
|
+
const { set } = causes;
|
|
630
|
+
if (causes.unknown || isEmpty(set) || set.native !== null) return { judged: true };
|
|
631
|
+
if (set.newest !== null && set.newest.id >= anchorId) return { judged: true };
|
|
632
|
+
return { judged: false, source: set.push?.source ?? (set.oldest as CauseRoot).source };
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
/** Foreign to any window that could open now: the source of a commit that belongs to no open window's judged part. */
|
|
636
|
+
export const backgroundSource = (causes: CommitCauses): string | null => {
|
|
637
|
+
const part = windowPart(causes, Number.POSITIVE_INFINITY);
|
|
638
|
+
return part.judged ? null : part.source;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
/** Compact labels for the React export: `touch#12`, `push:websocket#40`, `native:keyboardDidShow#41`, `unknown`. */
|
|
642
|
+
const keyOf = (r: CauseRoot) => (r.kind === 'action' ? `${r.source}#${r.id}` : `${r.kind}:${r.source}#${r.id}`);
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The store move behind a commit (`touch#3>touch#9`), or null. With `/touch#8`: the store's last write came from that older
|
|
646
|
+
* action, so the "last write" ruling would have moved the write there (or not at all when it is not newer than `from`).
|
|
647
|
+
*/
|
|
648
|
+
export const describeStore = (set: CauseSet): string | null =>
|
|
649
|
+
set.store ? `${keyOf(set.store.from)}>${keyOf(set.store.to)}${set.store.lastWrite ? `/${keyOf(set.store.lastWrite)}` : ''}` : null;
|
|
650
|
+
|
|
651
|
+
/** A commit's carrier: none when some update had no cause (it may not be the tick's). */
|
|
652
|
+
export const commitCarrier = (causes: CommitCauses): Carrier => (causes.unknown ? null : carrierOf(causes.set));
|
|
653
|
+
|
|
654
|
+
/** The commit without any store move, when it had one: its causes and carrier as the registering code left them. */
|
|
655
|
+
export const describeTask = (causes: CommitCauses): { causes: string[]; carrier: Carrier } | null =>
|
|
656
|
+
causes.set.task
|
|
657
|
+
? { causes: describeCauses({ set: causes.set.task, unknown: causes.unknown }), carrier: commitCarrier({ set: causes.set.task, unknown: causes.unknown }) }
|
|
658
|
+
: null;
|
|
659
|
+
|
|
660
|
+
export const describeCauses = (causes: CommitCauses): string[] => {
|
|
661
|
+
const { set } = causes;
|
|
662
|
+
const roots = [set.native, set.push, set.oldest, set.newest].filter((r, i, all): r is CauseRoot => r !== null && all.indexOf(r) === i);
|
|
663
|
+
const out = roots.map(keyOf);
|
|
664
|
+
if (causes.unknown) out.push('unknown');
|
|
665
|
+
return out;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// --- Entry points ---
|
|
669
|
+
|
|
670
|
+
interface RawEvent {
|
|
671
|
+
eventName?: string;
|
|
672
|
+
nativeEvent?: { target?: unknown };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// The latest touch, while it is the latest action: after a deep link, a scroll is not proven to be the user's (a push can
|
|
676
|
+
// scroll the list), so it gets no cause.
|
|
677
|
+
const touchCause = (): CauseSet | null =>
|
|
678
|
+
latestTouch !== null && latestTouch.id === latestAction ? setOf(latestTouch) : null;
|
|
679
|
+
|
|
680
|
+
const hostEventCause = (name: string, target: unknown): CauseSet | null => {
|
|
681
|
+
causeStats.hostEvents++;
|
|
682
|
+
if (!FIRST_OUTPUT_EVENTS.has(name) || typeof target !== 'number') return null;
|
|
683
|
+
const view = viewCause.get(target);
|
|
684
|
+
if (!view || view.taken.has(name)) return null;
|
|
685
|
+
view.taken.add(name);
|
|
686
|
+
if (view.taken.size === FIRST_OUTPUT_EVENTS.size) viewCause.delete(target);
|
|
687
|
+
causeStats.hostEventsAttributed++;
|
|
688
|
+
return view.set;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* RawEventEmitter `*` listener: runs before any handler of the event. React Native can dispatch several events in one
|
|
693
|
+
* task, so the microtask after them leaves no cause behind rather than restoring one of theirs.
|
|
694
|
+
*/
|
|
695
|
+
const onRawEvent = (event: RawEvent) => {
|
|
696
|
+
const name = event?.eventName ?? '';
|
|
697
|
+
let cause: CauseSet | null;
|
|
698
|
+
if (name === 'topTouchStart') {
|
|
699
|
+
causeStats.touches++;
|
|
700
|
+
latestTouch = newRoot('action', 'touch');
|
|
701
|
+
cause = setOf(latestTouch);
|
|
702
|
+
} else if (TOUCH_DRIVEN.test(name)) {
|
|
703
|
+
cause = touchCause();
|
|
704
|
+
} else if (USER_INPUT_EVENTS.has(name)) {
|
|
705
|
+
cause = touchCause() ?? setOf(newRoot('action', 'input'));
|
|
706
|
+
} else {
|
|
707
|
+
cause = hostEventCause(name, event?.nativeEvent?.target);
|
|
708
|
+
}
|
|
709
|
+
current = cause;
|
|
710
|
+
rawThen?.call(Promise.resolve(), () => {
|
|
711
|
+
current = null;
|
|
712
|
+
});
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
let rawThen: AnyFn | null = null;
|
|
716
|
+
|
|
717
|
+
const socketCause = new Map<unknown, CauseSet>();
|
|
718
|
+
|
|
719
|
+
const wrapDeviceEmitter = (emitter: { emit: AnyFn } | undefined) => {
|
|
720
|
+
if (!emitter || typeof emitter.emit !== 'function') return;
|
|
721
|
+
const emit = emitter.emit;
|
|
722
|
+
emitter.emit = function (this: unknown, ...args: unknown[]) {
|
|
723
|
+
causeStats.nativeEvents++;
|
|
724
|
+
const type = typeof args[0] === 'string' ? args[0] : '';
|
|
725
|
+
let cause: CauseSet;
|
|
726
|
+
const push = PUSH_EVENTS[type];
|
|
727
|
+
if (push !== undefined) {
|
|
728
|
+
cause = setOf(newRoot('push', push));
|
|
729
|
+
} else if (LINK_EVENTS.has(type)) {
|
|
730
|
+
cause = setOf(newRoot('action', 'link'));
|
|
731
|
+
} else if (SOCKET_EVENTS.has(type)) {
|
|
732
|
+
const id = (args[1] as { id?: unknown } | undefined)?.id;
|
|
733
|
+
cause = socketCause.get(id) ?? setOf(newRoot('native', type));
|
|
734
|
+
if (SOCKET_END_EVENTS.has(type)) socketCause.delete(id);
|
|
735
|
+
} else {
|
|
736
|
+
cause = setOf(newRoot('native', type));
|
|
737
|
+
}
|
|
738
|
+
return runUnder(cause, () => emit.apply(this, args));
|
|
739
|
+
};
|
|
740
|
+
causeStats.patched.push('RCTDeviceEventEmitter.emit');
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
// Responses of a request run under the cause of the code that sent it.
|
|
744
|
+
const wrapXhr = (proto: Record<string, unknown> | undefined) => {
|
|
745
|
+
if (!proto || typeof proto.send !== 'function') return;
|
|
746
|
+
const CAUSE = '__perflabCause';
|
|
747
|
+
const send = proto.send as AnyFn;
|
|
748
|
+
proto.send = function (this: Record<string, unknown>, ...args: unknown[]) {
|
|
749
|
+
this[CAUSE] = captured();
|
|
750
|
+
return send.apply(this, args);
|
|
751
|
+
};
|
|
752
|
+
for (const name of XHR_EVENTS) {
|
|
753
|
+
const fn = proto[name];
|
|
754
|
+
if (typeof fn !== 'function') continue;
|
|
755
|
+
const f = fn as AnyFn;
|
|
756
|
+
proto[name] = function (this: Record<string, unknown>, ...args: unknown[]) {
|
|
757
|
+
const cause = this[CAUSE] as CauseSet | null | undefined;
|
|
758
|
+
return cause ? runUnder(cause, () => f.apply(this, args)) : f.apply(this, args);
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
causeStats.patched.push('XMLHttpRequest');
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
// A socket's open, close and failure belong to the code that opened it; its messages are pushes.
|
|
765
|
+
const wrapWebSocket = (proto: Record<string, unknown> | undefined) => {
|
|
766
|
+
if (!proto || typeof proto._registerEvents !== 'function') return;
|
|
767
|
+
const register = proto._registerEvents as AnyFn;
|
|
768
|
+
proto._registerEvents = function (this: Record<string, unknown>, ...args: unknown[]) {
|
|
769
|
+
const cause = captured();
|
|
770
|
+
if (cause) socketCause.set(this._socketId, cause);
|
|
771
|
+
return register.apply(this, args);
|
|
772
|
+
};
|
|
773
|
+
causeStats.patched.push('WebSocket');
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const wrapScheduler = (scheduler: { unstable_scheduleCallback?: AnyFn } | undefined) => {
|
|
777
|
+
if (!scheduler || typeof scheduler.unstable_scheduleCallback !== 'function') return;
|
|
778
|
+
const schedule = scheduler.unstable_scheduleCallback;
|
|
779
|
+
const inReact = (cb: AnyFn, cause: CauseSet | null): AnyFn =>
|
|
780
|
+
function (this: unknown, ...args: unknown[]) {
|
|
781
|
+
const prevTask = taskCauses;
|
|
782
|
+
taskCauses = cause;
|
|
783
|
+
try {
|
|
784
|
+
const r = runUnder(REACT_TASK, () => cb.apply(this, args));
|
|
785
|
+
return typeof r === 'function' ? inReact(r as AnyFn, cause) : r;
|
|
786
|
+
} finally {
|
|
787
|
+
taskCauses = prevTask;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
scheduler.unstable_scheduleCallback = (priority: unknown, cb: unknown, options: unknown) =>
|
|
791
|
+
schedule(priority, typeof cb === 'function' ? inReact(cb as AnyFn, captured()) : cb, options);
|
|
792
|
+
causeStats.patched.push('scheduler');
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// The first render of every surface runs under app start, so work the app starts at launch (polls, subscriptions,
|
|
796
|
+
// loops) keeps that cause instead of having none.
|
|
797
|
+
const wrapAppRegistry = (registry: Record<string, unknown> | undefined, surfaces: Record<string, unknown> | undefined) => {
|
|
798
|
+
const wrapIn = (target: Record<string, unknown> | undefined, key: string) => {
|
|
799
|
+
const run = target?.[key];
|
|
800
|
+
if (!target || typeof run !== 'function') return false;
|
|
801
|
+
const fn = run as AnyFn;
|
|
802
|
+
try {
|
|
803
|
+
target[key] = function (this: unknown, ...args: unknown[]) {
|
|
804
|
+
// The bundle's first task already holds a launch root; a later start (an activity recreated in the same
|
|
805
|
+
// process) is a new launch. A surface started from inside another start is the same launch.
|
|
806
|
+
if (!inLaunch) {
|
|
807
|
+
if (launchT !== null || !launchRoot) launchRoot = newRoot('action', 'launch');
|
|
808
|
+
launchT = clock();
|
|
809
|
+
}
|
|
810
|
+
const outer = inLaunch;
|
|
811
|
+
inLaunch = true;
|
|
812
|
+
try {
|
|
813
|
+
return runUnder(launchRoot ? setOf(launchRoot) : null, () => fn.apply(this, args));
|
|
814
|
+
} finally {
|
|
815
|
+
inLaunch = outer;
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
} catch {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
return target[key] !== fn;
|
|
822
|
+
};
|
|
823
|
+
// Bridgeless React Native starts surfaces through RN$SurfaceRegistry, which holds its own reference.
|
|
824
|
+
if (wrapIn(registry, 'runApplication')) causeStats.patched.push('AppRegistry');
|
|
825
|
+
if (wrapIn(surfaces, 'renderSurface')) causeStats.patched.push('SurfaceRegistry');
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const load = <T>(id: () => unknown): T | undefined => {
|
|
829
|
+
try {
|
|
830
|
+
const mod = id() as { default?: T } & T;
|
|
831
|
+
return (mod?.default ?? mod) as T;
|
|
832
|
+
} catch {
|
|
833
|
+
return undefined;
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
export interface CauseDeps {
|
|
838
|
+
/** RawEventEmitter, RCTDeviceEventEmitter, XMLHttpRequest, WebSocket, scheduler and AppRegistry modules; injectable for tests. */
|
|
839
|
+
readonly modules?: {
|
|
840
|
+
readonly raw?: unknown;
|
|
841
|
+
readonly device?: unknown;
|
|
842
|
+
readonly xhr?: unknown;
|
|
843
|
+
readonly webSocket?: unknown;
|
|
844
|
+
readonly scheduler?: unknown;
|
|
845
|
+
readonly appRegistry?: unknown;
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Installs the tracker. Must run before the React renderer loads (it reads the scheduler's exports once) and before the
|
|
851
|
+
* app's own modules capture timers. The SDK boot module runs before the app entry, so both hold.
|
|
852
|
+
*/
|
|
853
|
+
export const installCauses = (deps: CauseDeps = {}) => {
|
|
854
|
+
if (installed) return;
|
|
855
|
+
installed = true;
|
|
856
|
+
launchRoot = newRoot('action', 'launch');
|
|
857
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
858
|
+
const m = deps.modules ?? {};
|
|
859
|
+
try {
|
|
860
|
+
for (const name of GLOBAL_CONTINUATIONS) wrapGlobal(g, name);
|
|
861
|
+
rawThen = wrapPromise(g.Promise as { prototype: { then: AnyFn } } | undefined);
|
|
862
|
+
wrapDeviceEmitter(
|
|
863
|
+
(m.device ?? load(() => require('react-native/Libraries/EventEmitter/RCTDeviceEventEmitter'))) as { emit: AnyFn } | undefined,
|
|
864
|
+
);
|
|
865
|
+
wrapXhr(
|
|
866
|
+
((m.xhr ?? load(() => require('react-native/Libraries/Network/XMLHttpRequest'))) as { prototype?: Record<string, unknown> } | undefined)
|
|
867
|
+
?.prototype,
|
|
868
|
+
);
|
|
869
|
+
wrapWebSocket(
|
|
870
|
+
((m.webSocket ?? load(() => require('react-native/Libraries/WebSocket/WebSocket'))) as { prototype?: Record<string, unknown> } | undefined)
|
|
871
|
+
?.prototype,
|
|
872
|
+
);
|
|
873
|
+
const registry = (m.appRegistry ??
|
|
874
|
+
(load<{ AppRegistry?: unknown }>(() => require('react-native/Libraries/ReactNative/AppRegistry'))?.AppRegistry ?? g.RN$AppRegistry)) as
|
|
875
|
+
| Record<string, unknown>
|
|
876
|
+
| undefined;
|
|
877
|
+
wrapAppRegistry(registry, g.RN$SurfaceRegistry as Record<string, unknown> | undefined);
|
|
878
|
+
wrapScheduler((m.scheduler ?? load(() => require('scheduler'))) as { unstable_scheduleCallback?: AnyFn } | undefined);
|
|
879
|
+
const raw = (m.raw ?? load(() => require('react-native/Libraries/Core/RawEventEmitter'))) as
|
|
880
|
+
| { addListener?: (type: string, fn: (event: RawEvent) => void) => unknown }
|
|
881
|
+
| undefined;
|
|
882
|
+
raw?.addListener?.('*', onRawEvent);
|
|
883
|
+
g.__PERFLAB_STORES__ = { writer: storeWriter };
|
|
884
|
+
causeStats.installed = true;
|
|
885
|
+
} catch {
|
|
886
|
+
causeStats.errors++;
|
|
887
|
+
}
|
|
888
|
+
// The bundle's first task is app start; anything native calls later starts with no cause.
|
|
889
|
+
current = setOf(launchRoot);
|
|
890
|
+
if (rawThen) {
|
|
891
|
+
const then = rawThen;
|
|
892
|
+
let before = 0;
|
|
893
|
+
const probe = async () => {
|
|
894
|
+
before = causeStats.continuations;
|
|
895
|
+
await null;
|
|
896
|
+
causeStats.awaitThroughThen = causeStats.continuations > before;
|
|
897
|
+
};
|
|
898
|
+
probe().catch(() => undefined);
|
|
899
|
+
then.call(Promise.resolve(), () => {
|
|
900
|
+
current = null;
|
|
901
|
+
});
|
|
902
|
+
} else {
|
|
903
|
+
current = null;
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
/** Test hook: the current cause as a list of labels. */
|
|
908
|
+
export const currentCauseForTest = (): string[] => describeCauses(resolved());
|
|
909
|
+
|
|
910
|
+
export const runAsActionForTest = <T>(source: string | CauseRoot, fn: (root: CauseRoot) => T): T => {
|
|
911
|
+
const root = typeof source === 'string' ? newRoot('action', source) : source;
|
|
912
|
+
return runUnder(setOf(root), () => fn(root));
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
/** Test hook: an app start at `t` with a launch root of its own (null: no start known). */
|
|
916
|
+
export const markLaunchForTest = (t: number | null): CauseRoot | null => {
|
|
917
|
+
launchT = t;
|
|
918
|
+
launchRoot = t === null ? launchRoot : { ...newRoot('action', 'launch'), t };
|
|
919
|
+
return t === null ? null : launchRoot;
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
/** Test hook: forget every root and cause. */
|
|
923
|
+
export const resetCausesForTest = () => {
|
|
924
|
+
current = null;
|
|
925
|
+
reactCauses = EMPTY;
|
|
926
|
+
reactUnknown = false;
|
|
927
|
+
taskCauses = null;
|
|
928
|
+
renderStarted = false;
|
|
929
|
+
latestTouch = null;
|
|
930
|
+
viewCause.clear();
|
|
931
|
+
socketCause.clear();
|
|
932
|
+
};
|