@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
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// Per-component render cost and update causes, read from fibers in the commit
|
|
2
|
+
// hook. Causes need only fields every renderer keeps (props, hook list,
|
|
3
|
+
// context dependencies), so they work in a plain Release build. Times
|
|
4
|
+
// (actualDuration / selfBaseDuration) and updaters (root.memoizedUpdaters)
|
|
5
|
+
// exist only when the app runs React's profiling renderer.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
CLASS_COMPONENT,
|
|
9
|
+
componentName,
|
|
10
|
+
type Fiber,
|
|
11
|
+
FORWARD_REF,
|
|
12
|
+
FUNCTION_COMPONENT,
|
|
13
|
+
SIMPLE_MEMO_COMPONENT,
|
|
14
|
+
} from './fiber';
|
|
15
|
+
|
|
16
|
+
export interface HookNode {
|
|
17
|
+
memoizedState: unknown;
|
|
18
|
+
queue: unknown;
|
|
19
|
+
next: HookNode | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ContextDependency {
|
|
23
|
+
memoizedValue: unknown;
|
|
24
|
+
next: ContextDependency | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ProfiledFiber extends Fiber {
|
|
28
|
+
memoizedState?: unknown;
|
|
29
|
+
dependencies?: { firstContext: ContextDependency | null } | null;
|
|
30
|
+
actualDuration?: number;
|
|
31
|
+
selfBaseDuration?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProfiledRoot {
|
|
35
|
+
current: ProfiledFiber;
|
|
36
|
+
memoizedUpdaters?: Set<ProfiledFiber>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UpdateCause {
|
|
40
|
+
mount: boolean;
|
|
41
|
+
props: string[] | null;
|
|
42
|
+
hooks: number[] | null;
|
|
43
|
+
state: boolean;
|
|
44
|
+
context: boolean;
|
|
45
|
+
// Re-rendered only because the parent did: new props object, same values.
|
|
46
|
+
parent: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const isProfilingFiber = (fiber: ProfiledFiber) =>
|
|
50
|
+
typeof fiber.actualDuration === 'number';
|
|
51
|
+
|
|
52
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
53
|
+
value !== null && typeof value === 'object';
|
|
54
|
+
|
|
55
|
+
export const changedKeys = (prev: unknown, next: unknown): string[] | null => {
|
|
56
|
+
if (prev === next || !isObject(prev) || !isObject(next)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const keys: string[] = [];
|
|
60
|
+
for (const key in next) {
|
|
61
|
+
if (!Object.is(prev[key], next[key])) {
|
|
62
|
+
keys.push(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const key in prev) {
|
|
66
|
+
if (!(key in next)) {
|
|
67
|
+
keys.push(key);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return keys.sort();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Same rule as React DevTools: only hooks that can schedule an update
|
|
74
|
+
// (useState/useReducer have `queue.pending`, useSyncExternalStore has
|
|
75
|
+
// `queue.value` + `queue.getSnapshot`). Indexes count every hook in the list.
|
|
76
|
+
const canScheduleUpdate = (hook: HookNode) => {
|
|
77
|
+
const queue = hook.queue;
|
|
78
|
+
if (!isObject(queue)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if ('pending' in queue) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return 'value' in queue && typeof queue.getSnapshot === 'function';
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isHook = (value: unknown): value is HookNode =>
|
|
88
|
+
isObject(value) && 'memoizedState' in value && 'next' in value;
|
|
89
|
+
|
|
90
|
+
export const changedHooks = (prev: unknown, next: unknown): number[] => {
|
|
91
|
+
const out: number[] = [];
|
|
92
|
+
let a = isHook(prev) ? prev : null;
|
|
93
|
+
let b = isHook(next) ? next : null;
|
|
94
|
+
let index = 0;
|
|
95
|
+
while (a !== null && b !== null) {
|
|
96
|
+
if (canScheduleUpdate(b) && !Object.is(a.memoizedState, b.memoizedState)) {
|
|
97
|
+
out.push(index);
|
|
98
|
+
}
|
|
99
|
+
a = a.next;
|
|
100
|
+
b = b.next;
|
|
101
|
+
index++;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const contextChanged = (prev: ProfiledFiber, next: ProfiledFiber) => {
|
|
107
|
+
let a = prev.dependencies?.firstContext ?? null;
|
|
108
|
+
let b = next.dependencies?.firstContext ?? null;
|
|
109
|
+
while (a !== null && b !== null) {
|
|
110
|
+
if (!Object.is(a.memoizedValue, b.memoizedValue)) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
a = a.next;
|
|
114
|
+
b = b.next;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const usesHooks = (tag: number) =>
|
|
120
|
+
tag === FUNCTION_COMPONENT ||
|
|
121
|
+
tag === FORWARD_REF ||
|
|
122
|
+
tag === SIMPLE_MEMO_COMPONENT;
|
|
123
|
+
|
|
124
|
+
// `fiber` is the committed fiber that performed work; its alternate is the
|
|
125
|
+
// previous commit's version (null on mount).
|
|
126
|
+
export const updateCause = (fiber: ProfiledFiber): UpdateCause => {
|
|
127
|
+
const prev = fiber.alternate as ProfiledFiber | null;
|
|
128
|
+
if (prev === null) {
|
|
129
|
+
return {
|
|
130
|
+
mount: true,
|
|
131
|
+
props: null,
|
|
132
|
+
hooks: null,
|
|
133
|
+
state: false,
|
|
134
|
+
context: false,
|
|
135
|
+
parent: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const props = changedKeys(prev.memoizedProps, fiber.memoizedProps);
|
|
139
|
+
const hooks = usesHooks(fiber.tag)
|
|
140
|
+
? changedHooks(prev.memoizedState, fiber.memoizedState)
|
|
141
|
+
: null;
|
|
142
|
+
const state =
|
|
143
|
+
fiber.tag === CLASS_COMPONENT &&
|
|
144
|
+
!Object.is(prev.memoizedState, fiber.memoizedState);
|
|
145
|
+
const context = contextChanged(prev, fiber);
|
|
146
|
+
const parent =
|
|
147
|
+
(props === null || props.length === 0) &&
|
|
148
|
+
(hooks === null || hooks.length === 0) &&
|
|
149
|
+
!state &&
|
|
150
|
+
!context &&
|
|
151
|
+
prev.memoizedProps !== fiber.memoizedProps;
|
|
152
|
+
return {
|
|
153
|
+
mount: false,
|
|
154
|
+
props: props && props.length > 0 ? props : null,
|
|
155
|
+
hooks: hooks && hooks.length > 0 ? hooks : null,
|
|
156
|
+
state,
|
|
157
|
+
context,
|
|
158
|
+
parent,
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Time the fiber spent in its own render function (profiling renderer only).
|
|
163
|
+
export const selfMs = (fiber: ProfiledFiber): number | null =>
|
|
164
|
+
typeof fiber.selfBaseDuration === 'number' ? fiber.selfBaseDuration : null;
|
|
165
|
+
|
|
166
|
+
// Own render plus every descendant rendered in the same commit.
|
|
167
|
+
export const totalMs = (fiber: ProfiledFiber): number | null =>
|
|
168
|
+
typeof fiber.actualDuration === 'number' ? fiber.actualDuration : null;
|
|
169
|
+
|
|
170
|
+
export const updaterNames = (root: ProfiledRoot): string[] | null => {
|
|
171
|
+
const updaters = root.memoizedUpdaters;
|
|
172
|
+
if (!(updaters instanceof Set)) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const names: string[] = [];
|
|
176
|
+
for (const fiber of updaters) {
|
|
177
|
+
names.push(componentName(fiber));
|
|
178
|
+
}
|
|
179
|
+
return names.sort();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export interface ComponentProfile {
|
|
183
|
+
renders: number;
|
|
184
|
+
mounts: number;
|
|
185
|
+
// Distinct instances (fiber pairs) that rendered.
|
|
186
|
+
instances: Set<number>;
|
|
187
|
+
selfMs: number;
|
|
188
|
+
// Self time of the renders that were first mounts.
|
|
189
|
+
mountSelfMs: number;
|
|
190
|
+
totalMs: number;
|
|
191
|
+
timed: number;
|
|
192
|
+
props: Record<string, number>;
|
|
193
|
+
hooks: Record<string, number>;
|
|
194
|
+
state: number;
|
|
195
|
+
context: number;
|
|
196
|
+
parent: number;
|
|
197
|
+
other: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface CommitProfile {
|
|
201
|
+
// Index of the commit in this process (1-based), for aligning windows.
|
|
202
|
+
seq: number;
|
|
203
|
+
timed: boolean;
|
|
204
|
+
renderMs: number | null;
|
|
205
|
+
updaters: string[] | null;
|
|
206
|
+
components: Map<string, ComponentProfile>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const emptyComponent = (): ComponentProfile => ({
|
|
210
|
+
renders: 0,
|
|
211
|
+
mounts: 0,
|
|
212
|
+
instances: new Set(),
|
|
213
|
+
selfMs: 0,
|
|
214
|
+
mountSelfMs: 0,
|
|
215
|
+
totalMs: 0,
|
|
216
|
+
timed: 0,
|
|
217
|
+
props: {},
|
|
218
|
+
hooks: {},
|
|
219
|
+
state: 0,
|
|
220
|
+
context: 0,
|
|
221
|
+
parent: 0,
|
|
222
|
+
other: 0,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
export const createCommitProfile = (root: ProfiledRoot, seq = 0): CommitProfile => {
|
|
226
|
+
const timed = isProfilingFiber(root.current);
|
|
227
|
+
return {
|
|
228
|
+
seq,
|
|
229
|
+
timed,
|
|
230
|
+
renderMs: timed ? totalMs(root.current) : null,
|
|
231
|
+
updaters: timed ? updaterNames(root) : null,
|
|
232
|
+
components: new Map(),
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const bump = (map: Record<string, number>, key: string, by = 1) => {
|
|
237
|
+
map[key] = (map[key] ?? 0) + by;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const recordFiber = (
|
|
241
|
+
commit: CommitProfile,
|
|
242
|
+
name: string,
|
|
243
|
+
fiber: ProfiledFiber,
|
|
244
|
+
cause: UpdateCause,
|
|
245
|
+
instance?: number,
|
|
246
|
+
) => {
|
|
247
|
+
let entry = commit.components.get(name);
|
|
248
|
+
if (!entry) {
|
|
249
|
+
entry = emptyComponent();
|
|
250
|
+
commit.components.set(name, entry);
|
|
251
|
+
}
|
|
252
|
+
entry.renders++;
|
|
253
|
+
if (instance !== undefined) {
|
|
254
|
+
entry.instances.add(instance);
|
|
255
|
+
}
|
|
256
|
+
if (cause.mount) {
|
|
257
|
+
entry.mounts++;
|
|
258
|
+
} else {
|
|
259
|
+
let explained = false;
|
|
260
|
+
for (const key of cause.props ?? []) {
|
|
261
|
+
bump(entry.props, key);
|
|
262
|
+
explained = true;
|
|
263
|
+
}
|
|
264
|
+
for (const index of cause.hooks ?? []) {
|
|
265
|
+
bump(entry.hooks, String(index));
|
|
266
|
+
explained = true;
|
|
267
|
+
}
|
|
268
|
+
if (cause.state) {
|
|
269
|
+
entry.state++;
|
|
270
|
+
explained = true;
|
|
271
|
+
}
|
|
272
|
+
if (cause.context) {
|
|
273
|
+
entry.context++;
|
|
274
|
+
explained = true;
|
|
275
|
+
}
|
|
276
|
+
if (cause.parent) {
|
|
277
|
+
entry.parent++;
|
|
278
|
+
explained = true;
|
|
279
|
+
}
|
|
280
|
+
if (!explained) {
|
|
281
|
+
entry.other++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const self = selfMs(fiber);
|
|
285
|
+
const total = totalMs(fiber);
|
|
286
|
+
if (self !== null && total !== null) {
|
|
287
|
+
entry.selfMs += self;
|
|
288
|
+
if (cause.mount) {
|
|
289
|
+
entry.mountSelfMs += self;
|
|
290
|
+
}
|
|
291
|
+
entry.totalMs += total;
|
|
292
|
+
entry.timed++;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export interface WindowProfile {
|
|
297
|
+
commits: number;
|
|
298
|
+
firstSeq: number | null;
|
|
299
|
+
lastSeq: number | null;
|
|
300
|
+
timedCommits: number;
|
|
301
|
+
renderMs: number[];
|
|
302
|
+
updaters: Record<string, number>;
|
|
303
|
+
components: Map<string, ComponentProfile>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const createWindowProfile = (): WindowProfile => ({
|
|
307
|
+
commits: 0,
|
|
308
|
+
firstSeq: null,
|
|
309
|
+
lastSeq: null,
|
|
310
|
+
timedCommits: 0,
|
|
311
|
+
renderMs: [],
|
|
312
|
+
updaters: {},
|
|
313
|
+
components: new Map(),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const mergeCounts = (into: Record<string, number>, from: Record<string, number>) => {
|
|
317
|
+
for (const key in from) {
|
|
318
|
+
bump(into, key, from[key] as number);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const addCommitProfile = (window: WindowProfile, commit: CommitProfile) => {
|
|
323
|
+
window.commits++;
|
|
324
|
+
if (commit.seq > 0) {
|
|
325
|
+
window.firstSeq = window.firstSeq === null ? commit.seq : Math.min(window.firstSeq, commit.seq);
|
|
326
|
+
window.lastSeq = window.lastSeq === null ? commit.seq : Math.max(window.lastSeq, commit.seq);
|
|
327
|
+
}
|
|
328
|
+
if (commit.timed) {
|
|
329
|
+
window.timedCommits++;
|
|
330
|
+
}
|
|
331
|
+
if (commit.renderMs !== null) {
|
|
332
|
+
window.renderMs.push(commit.renderMs);
|
|
333
|
+
}
|
|
334
|
+
for (const name of commit.updaters ?? []) {
|
|
335
|
+
bump(window.updaters, name);
|
|
336
|
+
}
|
|
337
|
+
for (const [name, from] of commit.components) {
|
|
338
|
+
let into = window.components.get(name);
|
|
339
|
+
if (!into) {
|
|
340
|
+
into = emptyComponent();
|
|
341
|
+
window.components.set(name, into);
|
|
342
|
+
}
|
|
343
|
+
into.renders += from.renders;
|
|
344
|
+
into.mounts += from.mounts;
|
|
345
|
+
for (const id of from.instances) {
|
|
346
|
+
into.instances.add(id);
|
|
347
|
+
}
|
|
348
|
+
into.selfMs += from.selfMs;
|
|
349
|
+
into.mountSelfMs += from.mountSelfMs;
|
|
350
|
+
into.totalMs += from.totalMs;
|
|
351
|
+
into.timed += from.timed;
|
|
352
|
+
into.state += from.state;
|
|
353
|
+
into.context += from.context;
|
|
354
|
+
into.parent += from.parent;
|
|
355
|
+
into.other += from.other;
|
|
356
|
+
mergeCounts(into.props, from.props);
|
|
357
|
+
mergeCounts(into.hooks, from.hooks);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const round = (ms: number) => Math.round(ms * 1000) / 1000;
|
|
362
|
+
|
|
363
|
+
const sortedCounts = (map: Record<string, number>) => {
|
|
364
|
+
const out: Record<string, number> = {};
|
|
365
|
+
for (const key of Object.keys(map).sort()) {
|
|
366
|
+
out[key] = map[key] as number;
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// Sizes of the per-window summary (plan 40 §3.1): component rows, cause keys
|
|
372
|
+
// per component, updaters.
|
|
373
|
+
export const PROFILE_LIMITS = { components: 30, causeKeys: 3, updaters: 5 };
|
|
374
|
+
|
|
375
|
+
// The budgets every timed window counts commits against (120 Hz and 60 Hz),
|
|
376
|
+
// plus the device's own when it is neither.
|
|
377
|
+
export const STANDARD_BUDGETS_MS = [8.3, 16.7];
|
|
378
|
+
|
|
379
|
+
export interface FrameBudget {
|
|
380
|
+
ms: number;
|
|
381
|
+
hz: number | null;
|
|
382
|
+
source: 'sampler' | 'default';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const ranked = (a: [string, number], b: [string, number]) =>
|
|
386
|
+
b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
387
|
+
|
|
388
|
+
// Every cause key of a component with its render count, flat:
|
|
389
|
+
// `mount`, `props.<key>`, `hooks.<index>`, `state`, `context`, `parent`, `other`.
|
|
390
|
+
export const causeCounts = (entry: ComponentProfile): [string, number][] => {
|
|
391
|
+
const out: [string, number][] = [];
|
|
392
|
+
if (entry.mounts > 0) out.push(['mount', entry.mounts]);
|
|
393
|
+
for (const key in entry.props) out.push([`props.${key}`, entry.props[key] as number]);
|
|
394
|
+
for (const key in entry.hooks) out.push([`hooks.${key}`, entry.hooks[key] as number]);
|
|
395
|
+
if (entry.state > 0) out.push(['state', entry.state]);
|
|
396
|
+
if (entry.context > 0) out.push(['context', entry.context]);
|
|
397
|
+
if (entry.parent > 0) out.push(['parent', entry.parent]);
|
|
398
|
+
if (entry.other > 0) out.push(['other', entry.other]);
|
|
399
|
+
return out.sort(ranked);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
export interface CauseSummary {
|
|
403
|
+
props?: Record<string, number>;
|
|
404
|
+
hooks?: Record<string, number>;
|
|
405
|
+
state?: number;
|
|
406
|
+
context?: number;
|
|
407
|
+
parent?: number;
|
|
408
|
+
other?: number;
|
|
409
|
+
// Cause keys left out (beyond the top keys).
|
|
410
|
+
omitted?: number;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Render counts of the top cause keys (mounts excluded: the row has them), in
|
|
414
|
+
// the nested shape `derive` reads. Counts, not shares, so windows add up.
|
|
415
|
+
export const causeSummary = (entry: ComponentProfile, limit = Infinity): CauseSummary => {
|
|
416
|
+
const all = causeCounts(entry).filter(([key]) => key !== 'mount');
|
|
417
|
+
const kept = all.slice(0, limit);
|
|
418
|
+
const out: CauseSummary = {};
|
|
419
|
+
for (const [key, n] of kept.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
|
|
420
|
+
const dot = key.indexOf('.');
|
|
421
|
+
if (dot > 0) {
|
|
422
|
+
const group = key.slice(0, dot) as 'props' | 'hooks';
|
|
423
|
+
out[group] = { ...(out[group] ?? {}), [key.slice(dot + 1)]: n };
|
|
424
|
+
} else {
|
|
425
|
+
out[key as 'state' | 'context' | 'parent' | 'other'] = n;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (all.length > kept.length) {
|
|
429
|
+
out.omitted = all.length - kept.length;
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Share of the component's renders per cause key (mount included), top keys
|
|
435
|
+
// only. One render can have several causes, so shares can sum past 1.
|
|
436
|
+
export const causeShares = (entry: ComponentProfile, limit = PROFILE_LIMITS.causeKeys) => {
|
|
437
|
+
const out: Record<string, number> = {};
|
|
438
|
+
if (entry.renders === 0) {
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
for (const [key, n] of causeCounts(entry).slice(0, limit)) {
|
|
442
|
+
out[key] = round(n / entry.renders);
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Commits whose render time exceeds each budget. Keys are the budget in ms
|
|
448
|
+
// with one decimal (`"8.3"`, `"16.7"`).
|
|
449
|
+
export const overBudgetCounts = (renderMs: number[], budgets: number[]) => {
|
|
450
|
+
const out: Record<string, number> = {};
|
|
451
|
+
for (const budget of [...new Set(budgets.map(b => Math.round(b * 10) / 10))].sort((a, b) => a - b)) {
|
|
452
|
+
out[budget.toFixed(1)] = renderMs.filter(ms => ms > budget).length;
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const DEFAULT_BUDGET: FrameBudget = { ms: 16.7, hz: null, source: 'default' };
|
|
458
|
+
|
|
459
|
+
// Component rows: [name, renders, mounts, instances, selfMs, totalMs]; times
|
|
460
|
+
// are null without the profiling renderer. Rows are the top components by self
|
|
461
|
+
// time when timed, else by renders (then name), so a production-renderer run
|
|
462
|
+
// with the same renders prints the same rows.
|
|
463
|
+
export type ComponentRow = [string, number, number, number, number | null, number | null];
|
|
464
|
+
|
|
465
|
+
export const summarizeWindowProfile = (
|
|
466
|
+
window: WindowProfile,
|
|
467
|
+
options: { budget?: FrameBudget; limits?: Partial<typeof PROFILE_LIMITS> } = {},
|
|
468
|
+
) => {
|
|
469
|
+
const limits = { ...PROFILE_LIMITS, ...options.limits };
|
|
470
|
+
const budget = options.budget ?? DEFAULT_BUDGET;
|
|
471
|
+
const timed = window.timedCommits > 0;
|
|
472
|
+
const all = [...window.components.entries()].sort(
|
|
473
|
+
(a, b) =>
|
|
474
|
+
(timed ? b[1].selfMs - a[1].selfMs : 0) ||
|
|
475
|
+
b[1].renders - a[1].renders ||
|
|
476
|
+
(a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0),
|
|
477
|
+
);
|
|
478
|
+
const kept = all.slice(0, limits.components);
|
|
479
|
+
const components: ComponentRow[] = kept.map(([name, entry]) => [
|
|
480
|
+
name,
|
|
481
|
+
entry.renders,
|
|
482
|
+
entry.mounts,
|
|
483
|
+
entry.instances.size,
|
|
484
|
+
timed ? round(entry.selfMs) : null,
|
|
485
|
+
timed ? round(entry.totalMs) : null,
|
|
486
|
+
]);
|
|
487
|
+
const causes: Record<string, CauseSummary> = {};
|
|
488
|
+
const shares: Record<string, Record<string, number>> = {};
|
|
489
|
+
for (const [name, entry] of kept) {
|
|
490
|
+
const summary = causeSummary(entry, limits.causeKeys);
|
|
491
|
+
if (Object.keys(summary).length > 0) {
|
|
492
|
+
causes[name] = summary;
|
|
493
|
+
}
|
|
494
|
+
shares[name] = causeShares(entry, limits.causeKeys);
|
|
495
|
+
}
|
|
496
|
+
const updaters = Object.entries(window.updaters).sort(ranked);
|
|
497
|
+
const renderSum = window.renderMs.reduce((s, n) => s + n, 0);
|
|
498
|
+
const selfTotal = timed
|
|
499
|
+
? [...window.components.values()].reduce((s, e) => s + e.selfMs, 0)
|
|
500
|
+
: null;
|
|
501
|
+
const mountSelfTotal = timed
|
|
502
|
+
? [...window.components.values()].reduce((s, e) => s + e.mountSelfMs, 0)
|
|
503
|
+
: null;
|
|
504
|
+
return {
|
|
505
|
+
renderer: timed ? 'profiling' : 'production',
|
|
506
|
+
commits: window.commits,
|
|
507
|
+
commitSeq:
|
|
508
|
+
window.firstSeq === null || window.lastSeq === null
|
|
509
|
+
? null
|
|
510
|
+
: [window.firstSeq, window.lastSeq],
|
|
511
|
+
renderMs: timed
|
|
512
|
+
? {
|
|
513
|
+
sum: round(renderSum),
|
|
514
|
+
max: round(window.renderMs.reduce((m, n) => Math.max(m, n), 0)),
|
|
515
|
+
n: window.renderMs.length,
|
|
516
|
+
}
|
|
517
|
+
: null,
|
|
518
|
+
frameBudget: budget,
|
|
519
|
+
overBudget: timed
|
|
520
|
+
? overBudgetCounts(window.renderMs, [...STANDARD_BUDGETS_MS, budget.ms])
|
|
521
|
+
: null,
|
|
522
|
+
selfMsTotal: selfTotal === null ? null : round(selfTotal),
|
|
523
|
+
mountSelfMs: mountSelfTotal === null ? null : round(mountSelfTotal),
|
|
524
|
+
updaters: timed ? sortedCounts(Object.fromEntries(updaters.slice(0, limits.updaters))) : null,
|
|
525
|
+
updatersOmitted: timed ? Math.max(0, updaters.length - limits.updaters) : 0,
|
|
526
|
+
components,
|
|
527
|
+
causes,
|
|
528
|
+
shares,
|
|
529
|
+
omitted: all.length - kept.length,
|
|
530
|
+
};
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
export type WindowProfileSummary = ReturnType<typeof summarizeWindowProfile>;
|
|
534
|
+
|
|
535
|
+
// The window broke the device frame budget: a commit took longer than a frame.
|
|
536
|
+
export const brokeFrameBudget = (summary: WindowProfileSummary) => {
|
|
537
|
+
const key = (Math.round(summary.frameBudget.ms * 10) / 10).toFixed(1);
|
|
538
|
+
return (summary.overBudget?.[key] ?? 0) > 0;
|
|
539
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Wires the push client to React Native: env config, device info, AppState.
|
|
2
|
+
// Push stays off unless both EXPO_PUBLIC_KEEPSNAPPY_INGEST_URL and
|
|
3
|
+
// EXPO_PUBLIC_KEEPSNAPPY_KEY are set at build time.
|
|
4
|
+
|
|
5
|
+
import { AppState, Platform } from 'react-native';
|
|
6
|
+
|
|
7
|
+
import { MODE } from './activation';
|
|
8
|
+
import { type ArtifactUploadNative, createArtifactUploader } from './artifacts';
|
|
9
|
+
import {
|
|
10
|
+
addStartupSection,
|
|
11
|
+
getRunToken,
|
|
12
|
+
isWindowOpen,
|
|
13
|
+
perfLog,
|
|
14
|
+
setArtifactHandler,
|
|
15
|
+
setFlushHandler,
|
|
16
|
+
setWindowListener,
|
|
17
|
+
} from './core';
|
|
18
|
+
import { ownFetch } from './net-install';
|
|
19
|
+
import { createPushClient, type DeviceInfo, type PushSpool } from './push';
|
|
20
|
+
|
|
21
|
+
const INGEST_URL = (process.env.EXPO_PUBLIC_KEEPSNAPPY_INGEST_URL);
|
|
22
|
+
const INGEST_KEY = (process.env.EXPO_PUBLIC_KEEPSNAPPY_KEY);
|
|
23
|
+
|
|
24
|
+
const randomId = () =>
|
|
25
|
+
`r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
26
|
+
|
|
27
|
+
// The published package.json carries the release version (scripts/release/stage.ts).
|
|
28
|
+
const sdkVersion = (): string | null => {
|
|
29
|
+
try {
|
|
30
|
+
const v = (require('../../package.json') as { version?: unknown }).version;
|
|
31
|
+
return typeof v === 'string' ? v : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const readDevice = async (): Promise<DeviceInfo> => {
|
|
38
|
+
let installId = randomId();
|
|
39
|
+
let model: string | null = null;
|
|
40
|
+
let os: string | null = null;
|
|
41
|
+
let build: string | null = null;
|
|
42
|
+
try {
|
|
43
|
+
const Device = require('expo-device');
|
|
44
|
+
model = Device.modelName ?? Device.modelId ?? null;
|
|
45
|
+
os = Device.osVersion ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
// device fields stay null
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const Application = require('expo-application');
|
|
51
|
+
build = Application.nativeBuildVersion ?? null;
|
|
52
|
+
const id =
|
|
53
|
+
Platform.OS === 'ios'
|
|
54
|
+
? await Application.getIosIdForVendorAsync()
|
|
55
|
+
: Application.getAndroidId?.();
|
|
56
|
+
if (id) {
|
|
57
|
+
installId = String(id);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// keeps the per-process random id
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
model,
|
|
64
|
+
os,
|
|
65
|
+
install_id: installId,
|
|
66
|
+
app_build: build,
|
|
67
|
+
platform: Platform.OS,
|
|
68
|
+
sdk_version: sdkVersion(),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
interface SpoolNative {
|
|
73
|
+
spoolRead?: () => string | null;
|
|
74
|
+
spoolAppend?: (record: string) => void;
|
|
75
|
+
spoolReplace?: (content: string) => boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Gzip + PUT of a raw profile file. Without it (an older binary) the files of
|
|
79
|
+
// a run stay on the device and the manifest says so.
|
|
80
|
+
const nativeArtifacts = (): ArtifactUploadNative | null => {
|
|
81
|
+
const native = (
|
|
82
|
+
globalThis as unknown as { expo?: { modules?: Record<string, unknown> } }
|
|
83
|
+
).expo?.modules?.PerfLab as Partial<ArtifactUploadNative> | undefined;
|
|
84
|
+
if (!native || typeof native.artifactUpload !== 'function') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const { artifactUpload } = native;
|
|
88
|
+
return { artifactUpload: (...args) => artifactUpload.apply(native, args) };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// The journal file next to the log (perf-lab/push-spool.jsonl). Without these
|
|
92
|
+
// native functions (an older binary) the queue is memory-only, as before.
|
|
93
|
+
const nativeSpool = (): PushSpool | null => {
|
|
94
|
+
const native = (
|
|
95
|
+
globalThis as unknown as { expo?: { modules?: Record<string, unknown> } }
|
|
96
|
+
).expo?.modules?.PerfLab as SpoolNative | undefined;
|
|
97
|
+
if (
|
|
98
|
+
!native ||
|
|
99
|
+
typeof native.spoolRead !== 'function' ||
|
|
100
|
+
typeof native.spoolAppend !== 'function' ||
|
|
101
|
+
typeof native.spoolReplace !== 'function'
|
|
102
|
+
) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const { spoolRead, spoolAppend, spoolReplace } = native;
|
|
106
|
+
return {
|
|
107
|
+
read: () => spoolRead.call(native),
|
|
108
|
+
append: record => spoolAppend.call(native, record),
|
|
109
|
+
replace: content => spoolReplace.call(native, content),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const installPush = () => {
|
|
114
|
+
if (!INGEST_URL || !INGEST_KEY) {
|
|
115
|
+
addStartupSection('push', () => ({ enabled: false }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// A measured run (dormant build activated, or a process started with a run
|
|
119
|
+
// token) sends nothing while it runs: lines stay in the journal file until a
|
|
120
|
+
// `flush=1` link at the end of the iteration. Other builds push when idle.
|
|
121
|
+
const autoFlush = MODE !== 'dormant' && getRunToken() === null;
|
|
122
|
+
const push = createPushClient({
|
|
123
|
+
autoFlush,
|
|
124
|
+
url: INGEST_URL,
|
|
125
|
+
key: INGEST_KEY,
|
|
126
|
+
fetch: ownFetch,
|
|
127
|
+
now: Date.now,
|
|
128
|
+
setTimer: (run, ms) => setTimeout(run, ms),
|
|
129
|
+
clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
130
|
+
isWindowOpen: () => isWindowOpen(),
|
|
131
|
+
getRunToken,
|
|
132
|
+
getDevice: readDevice,
|
|
133
|
+
spool: nativeSpool(),
|
|
134
|
+
});
|
|
135
|
+
// The journal of earlier processes is read at the first idle send or at the
|
|
136
|
+
// flush link, never during startup or inside a measured window.
|
|
137
|
+
setWindowListener(push.enqueue);
|
|
138
|
+
setFlushHandler(deadline => push.flushAll(3, deadline));
|
|
139
|
+
const artifacts = createArtifactUploader({
|
|
140
|
+
url: INGEST_URL,
|
|
141
|
+
key: INGEST_KEY,
|
|
142
|
+
fetch: ownFetch,
|
|
143
|
+
native: nativeArtifacts,
|
|
144
|
+
isWindowOpen: () => isWindowOpen(),
|
|
145
|
+
now: Date.now,
|
|
146
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
147
|
+
log: message => perfLog(message),
|
|
148
|
+
});
|
|
149
|
+
setArtifactHandler(artifacts.upload);
|
|
150
|
+
if (autoFlush) {
|
|
151
|
+
AppState.addEventListener('change', state => {
|
|
152
|
+
if (state === 'background' || state === 'inactive') {
|
|
153
|
+
void push.flush('background');
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
addStartupSection('push', () => push.stats());
|
|
158
|
+
};
|