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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,18 @@ const { withKeepSnappy } = require('@keepsnappy/react-native/metro');
19
19
  module.exports = withKeepSnappy(config);
20
20
  ```
21
21
 
22
+ Shared client packages can opt into component keys:
23
+
24
+ ```js
25
+ module.exports = withKeepSnappy(config, { clientPackages: ['@my-app/ui'] });
26
+ ```
27
+
28
+ With the flag on, `.js/.jsx/.ts/.tsx` files inside the real project root, outside `node_modules`, and owned by the root package count as client code. Another package (including a workspace sibling or linked SDK) counts only when its name is in `clientPackages` (default `[]`). Package keys use `<package>/<path inside it>`, never a parent-relative path.
29
+
30
+ A plugin in the client's own Babel pipeline stamps only module-scope named functions, React component classes, function-valued constants and outer `memo`/`forwardRef` bindings. Nested declarations and anonymous default exports remain unstamped. An unsupported instrumentation path falls back to the client's transform. The key is `Name@file`; the file part uses `encodeURIComponent`, keeps `/` and `@` readable, and additionally encodes `~`. Names support uppercase/titlecase Unicode identifiers. Parsing accepts only the exact canonical encoding. `@keepsnappy/facts` owns the grammar and fragment schema. The SDK's small build-time encoder is pinned by a round-trip test through facts, without adding a private workspace dependency to the shipped SDK.
31
+
32
+ Workers write atomic, content-addressed fragments under `.keepsnappy/components/`, with source paths relative to the project root. Metro excludes that directory from its resolver. `keepsnappy sourcemaps upload` sends source maps first, then prepares sorted `.keepsnappy/components.json`; component-map errors only print a warning. Use `--project-root PATH` for a moved checkout. Each configuration has its own `.config.json`; when several exist, select its directory name with `--components-config ID`. Keep this build-output directory out of version control. The map's upload awaits API support.
33
+
22
34
  `app.config.js` / `app.json`:
23
35
 
24
36
  ```js
@@ -1,5 +1,5 @@
1
- // Wraps the app's Babel transformer (set by withKeepSnappy) and adds one import to
2
- // the entry module: the keepsnappy boot module. That import is what puts the SDK
1
+ // Wraps the app's Babel transformer (set by withKeepSnappy), stamps client
2
+ // declarations and imports the keepsnappy boot module in the entry. That import puts the SDK
3
3
  // in the graph; getModulesRunBeforeMainModule then runs it before the entry.
4
4
  //
5
5
  // babel-preset-expo inlines EXPO_PUBLIC_* only outside node_modules, and the
@@ -12,6 +12,9 @@ const upstreamPath = (process.env.KEEPSNAPPY_UPSTREAM_BABEL_TRANSFORMER);
12
12
  const entryFile = (process.env.KEEPSNAPPY_ENTRY_FILE);
13
13
  const bootFile = (process.env.KEEPSNAPPY_BOOT_FILE);
14
14
  const sdkVersion = process.env.KEEPSNAPPY_SDK_VERSION;
15
+ const clientPackages = JSON.parse(process.env.KEEPSNAPPY_CLIENT_PACKAGES ?? '[]');
16
+ const projectRoot = process.env.KEEPSNAPPY_PROJECT_ROOT;
17
+ const componentCacheKey = process.env.KEEPSNAPPY_COMPONENT_CACHE_KEY ?? '';
15
18
 
16
19
  if (!upstreamPath || !entryFile || !bootFile || !sdkVersion) {
17
20
  throw new Error(
@@ -53,23 +56,38 @@ const absolute = (filename, options) =>
53
56
  module.exports = {
54
57
  ...upstream,
55
58
  transform(args) {
59
+ if (process.env.EXPO_PUBLIC_KEEPSNAPPY !== '1') return upstream.transform(args);
56
60
  const file = absolute(args.filename, args.options);
61
+ const original = args;
57
62
  if (file === entryFile) {
58
- return upstream.transform({ ...args, src: bootImport() + args.src });
63
+ args = { ...args, src: bootImport() + args.src };
59
64
  }
60
65
  if (file.startsWith(packageRoot)) {
61
66
  const src = file === path.join(packageRoot, 'js', 'sdk', 'push-install.ts')
62
67
  ? args.src.replace("require('../../package.json')", JSON.stringify({ version: sdkVersion }))
63
68
  : args.src;
64
- return upstream.transform({ ...args, src: inlineEnv(src) });
69
+ args = { ...args, src: inlineEnv(src) };
65
70
  }
66
- return upstream.transform(args);
71
+ let stamps;
72
+ try {
73
+ stamps = require('./component-stamps.js').instrumentation(
74
+ original.src, file, args.options?.projectRoot ?? projectRoot ?? process.cwd(), clientPackages, file === entryFile ? 1 : 0,
75
+ );
76
+ } catch { return upstream.transform(args); }
77
+ if (!stamps) return upstream.transform(args);
78
+ const complete = result => { stamps.complete(); return result; };
79
+ const passthrough = () => { stamps.complete(true); return upstream.transform(args); };
80
+ try {
81
+ const result = upstream.transform({ ...args, plugins: [stamps.plugin, ...(args.plugins ?? [])] });
82
+ return result && typeof result.then === 'function'
83
+ ? result.then(complete, passthrough) : complete(result);
84
+ } catch { return passthrough(); }
67
85
  },
68
86
  getCacheKey(...args) {
69
87
  const base =
70
88
  typeof upstream.getCacheKey === 'function'
71
89
  ? upstream.getCacheKey(...args)
72
90
  : '';
73
- return `${base}$keepsnappy:${entryFile}:${bootFile}:${sdkVersion}:${envSignature()}`;
91
+ return `${base}$keepsnappy:${entryFile}:${bootFile}:${sdkVersion}:${envSignature()}:components-v2:${JSON.stringify(clientPackages)}:${componentCacheKey}`;
74
92
  },
75
93
  };
@@ -0,0 +1,14 @@
1
+ // Build-time copy of @keepsnappy/facts. The SDK ships without the
2
+ // private facts workspace; Metro's round-trip test pins this encoder to it.
3
+ const FRAGMENT_VERSION = 2;
4
+ const isComponentName = name => typeof name === 'string' && /^[\p{Lu}\p{Lt}][\p{ID_Continue}$\u200C\u200D]*$/u.test(name);
5
+ function componentKey(name, file) {
6
+ if ((name !== 'default' && !isComponentName(name)) || typeof file !== 'string' || !file
7
+ || /^(?:\/|[A-Za-z]:)/.test(file) || file.includes('\\')
8
+ || file.split('/').some(part => part === '..' || part === '.' || part === '')) {
9
+ throw new Error('invalid client component name or file');
10
+ }
11
+ const encoded = encodeURIComponent(file).replace(/%2F/g, '/').replace(/%40/g, '@').replace(/~/g, '%7E');
12
+ return `${name}@${encoded}`;
13
+ }
14
+ module.exports = { FRAGMENT_VERSION, componentKey, isComponentName };
@@ -0,0 +1,192 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { createHash, randomUUID } = require('crypto');
4
+ const { FRAGMENT_VERSION, componentKey, isComponentName } = require('./component-contract.cjs');
5
+
6
+ const hash = value => createHash('sha256').update(value).digest('hex');
7
+ const posix = value => value.replace(/\\/g, '/');
8
+ const realPath = file => {
9
+ try { return fs.realpathSync(file); }
10
+ catch (error) {
11
+ if (error.code !== 'ENOENT') throw error;
12
+ return path.resolve(file);
13
+ }
14
+ };
15
+
16
+ function atomicJson(file, value) {
17
+ fs.mkdirSync(path.dirname(file), { recursive: true });
18
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
19
+ try {
20
+ fs.writeFileSync(temporary, JSON.stringify(value));
21
+ fs.renameSync(temporary, file);
22
+ } finally { fs.rmSync(temporary, { force: true }); }
23
+ }
24
+
25
+ const configuration = clientPackages => hash(JSON.stringify([FRAGMENT_VERSION, clientPackages]));
26
+ const contexts = new Map();
27
+ function context(projectRoot) {
28
+ const absolute = path.resolve(projectRoot);
29
+ if (contexts.has(absolute)) return contexts.get(absolute);
30
+ const root = realPath(absolute);
31
+ const owners = new Map();
32
+ const owningPackage = directory => {
33
+ if (owners.has(directory)) return owners.get(directory);
34
+ let owner;
35
+ try {
36
+ const pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
37
+ if (typeof pkg.name === 'string') owner = { name: pkg.name, directory };
38
+ } catch (error) {
39
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
40
+ }
41
+ if (!owner) {
42
+ const parent = path.dirname(directory);
43
+ owner = parent === directory ? null : owningPackage(parent);
44
+ }
45
+ owners.set(directory, owner);
46
+ return owner;
47
+ };
48
+ const value = { root, owningPackage, projectPackage: owningPackage(root)?.name };
49
+ contexts.set(absolute, value);
50
+ return value;
51
+ }
52
+
53
+ function configure(projectRoot, clientPackages) {
54
+ context(projectRoot);
55
+ const directory = configuration(clientPackages);
56
+ const selected = path.join(projectRoot, '.keepsnappy', 'components', directory);
57
+ fs.mkdirSync(selected, { recursive: true });
58
+ const cacheFile = path.join(selected, '.cache-key');
59
+ try { fs.writeFileSync(cacheFile, randomUUID(), { flag: 'wx' }); }
60
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
61
+ atomicJson(path.join(selected, '.config.json'), { version: FRAGMENT_VERSION, directory });
62
+ return fs.readFileSync(cacheFile, 'utf8');
63
+ }
64
+
65
+ function clientFile(filename, projectRoot, clientPackages) {
66
+ if (!/\.[jt]sx?$/.test(filename)) return null;
67
+ const source = realPath(filename);
68
+ if (source.split(path.sep).includes('node_modules') && clientPackages.length === 0) return null;
69
+ const { root, owningPackage, projectPackage } = context(projectRoot);
70
+ const pkg = owningPackage(path.dirname(source));
71
+ const relative = posix(path.relative(root, source));
72
+ if (pkg && clientPackages.includes(pkg.name)) {
73
+ return { source: relative, file: `${pkg.name}/${posix(path.relative(pkg.directory, source))}` };
74
+ }
75
+ if (relative.startsWith('../') || path.isAbsolute(relative) || source.split(path.sep).includes('node_modules')) return null;
76
+ if (pkg && pkg.name !== projectPackage) return null;
77
+ return { source: relative, file: relative };
78
+ }
79
+
80
+ const reactMember = (node, names) => node?.type === 'Identifier'
81
+ ? names.includes(node.name)
82
+ : node?.type === 'MemberExpression' && !node.computed && node.object.type === 'Identifier'
83
+ && node.object.name === 'React' && names.includes(node.property.name);
84
+ const unwrap = node => {
85
+ while (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TypeCastExpression', 'ParenthesizedExpression'].includes(node?.type)) node = node.expression;
86
+ return node;
87
+ };
88
+ const componentValue = node => {
89
+ node = unwrap(node);
90
+ return node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression'
91
+ || (node?.type === 'CallExpression' && reactMember(node.callee, ['memo', 'forwardRef']));
92
+ };
93
+
94
+ const jsxResult = node => {
95
+ node = unwrap(node);
96
+ if (!node) return false;
97
+ if (['JSXElement', 'JSXFragment'].includes(node.type)) return true;
98
+ if (node.type === 'ConditionalExpression') return jsxResult(node.consequent) || jsxResult(node.alternate);
99
+ if (node.type === 'LogicalExpression') return jsxResult(node.right) || (node.operator !== '&&' && jsxResult(node.left));
100
+ if (node.type === 'SequenceExpression') return jsxResult(node.expressions.at(-1));
101
+ return false;
102
+ };
103
+ const returnsJSX = (body, t) => {
104
+ if (body.type !== 'BlockStatement') return jsxResult(body);
105
+ const visit = node => {
106
+ if (!node || t.isFunction(node) || t.isClass(node)) return false;
107
+ if (node.type === 'ReturnStatement') return jsxResult(node.argument);
108
+ return (t.VISITOR_KEYS[node.type] ?? []).some(key => {
109
+ const child = node[key];
110
+ return Array.isArray(child) ? child.some(visit) : visit(child);
111
+ });
112
+ };
113
+ return visit(body);
114
+ };
115
+ const anonymousRender = (node, t) => {
116
+ node = unwrap(node);
117
+ if (node?.type === 'CallExpression' && reactMember(node.callee, ['memo', 'forwardRef'])) return anonymousRender(node.arguments[0], t);
118
+ return ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node?.type)
119
+ && !node.id && !node.async && !node.generator && returnsJSX(node.body, t);
120
+ };
121
+
122
+ function instrumentation(src, filename, projectRoot, clientPackages, lineOffset = 0) {
123
+ const client = clientFile(filename, projectRoot, clientPackages);
124
+ if (!client) return null;
125
+ const components = [];
126
+ const contentHash = hash(src);
127
+ const target = path.join(projectRoot, '.keepsnappy', 'components', configuration(clientPackages), `${hash(client.source)}-${contentHash}.json`);
128
+ const plugin = ({ types: t }) => ({
129
+ name: 'keepsnappy-components',
130
+ visitor: {
131
+ Program(program) {
132
+ // Inspect the original module body once, before preset visitors lower declarations.
133
+ for (const statement of program.get('body')) {
134
+ const node = statement.node;
135
+ const declaration = ['ExportNamedDeclaration', 'ExportDefaultDeclaration'].includes(node.type) ? node.declaration : node;
136
+ if (!declaration || declaration.declare) continue;
137
+ const found = [];
138
+ if (declaration.type === 'FunctionDeclaration' && isComponentName(declaration.id?.name)) found.push(declaration.id);
139
+ else if (declaration.type === 'ClassDeclaration' && isComponentName(declaration.id?.name)
140
+ && reactMember(declaration.superClass, ['Component', 'PureComponent'])) found.push(declaration.id);
141
+ else if (declaration.type === 'VariableDeclaration' && declaration.kind === 'const') {
142
+ for (const binding of declaration.declarations) {
143
+ if (binding.id.type === 'Identifier' && isComponentName(binding.id.name) && componentValue(binding.init)) found.push(binding.id);
144
+ }
145
+ }
146
+ let defaultId;
147
+ if (node.type === 'ExportDefaultDeclaration' && (
148
+ anonymousRender(declaration, t) || (declaration.type === 'ClassDeclaration' && !declaration.id
149
+ && reactMember(declaration.superClass, ['Component', 'PureComponent']))
150
+ )) {
151
+ // Only real component defaults gain an uppercase binding.
152
+ let name = 'KeepSnappyDefault';
153
+ while (program.scope.hasBinding(name) || program.scope.hasGlobal(name) || program.scope.hasReference(name)) name += '_';
154
+ defaultId = t.identifier(name);
155
+ defaultId.loc = declaration.loc;
156
+ if (['FunctionDeclaration', 'ClassDeclaration'].includes(declaration.type)) {
157
+ // Keep declarations in place, including function hoisting through circular imports.
158
+ declaration.id = defaultId;
159
+ } else {
160
+ statement.insertBefore(t.variableDeclaration('const', [t.variableDeclarator(defaultId, declaration)]));
161
+ statement.get('declaration').replaceWith(t.cloneNode(defaultId));
162
+ }
163
+ found.push(defaultId);
164
+ program.scope.crawl();
165
+ }
166
+ const stamps = [];
167
+ for (const id of found) {
168
+ const componentName = id === defaultId ? 'default' : id.name;
169
+ const key = componentKey(componentName, client.file);
170
+ components.push({ key, name: componentName, file: client.file, line: Math.max(1, id.loc.start.line - lineOffset) });
171
+ const name = () => t.identifier(id.name);
172
+ const typeIs = value => t.binaryExpression('===', t.unaryExpression('typeof', name()), t.stringLiteral(value));
173
+ stamps.push(t.ifStatement(t.logicalExpression('||', typeIs('function'), t.logicalExpression('&&',
174
+ t.binaryExpression('!==', name(), t.nullLiteral()), typeIs('object'))),
175
+ t.expressionStatement(t.assignmentExpression('=', t.memberExpression(name(), t.identifier('__ks')), t.stringLiteral(key)))));
176
+ }
177
+ if (stamps.length) statement.insertAfter(stamps);
178
+ }
179
+ },
180
+ },
181
+ });
182
+ return {
183
+ plugin,
184
+ complete(skipped = false) {
185
+ // Inventory output is optional; source compilation must remain usable on write failures.
186
+ try { atomicJson(target, { version: FRAGMENT_VERSION, source: client.source, hash: contentHash, components: skipped ? [] : components }); }
187
+ catch { /* The CLI reports an unavailable inventory without blocking source maps. */ }
188
+ },
189
+ };
190
+ }
191
+
192
+ module.exports = { configure, instrumentation };
@@ -0,0 +1,20 @@
1
+ // The push queue drops a window line over this many bytes (push.ts enqueue). The window summary budgets against half of it.
2
+ export const MAX_BATCH_BYTES = 256 * 1024;
3
+
4
+ export const utf8Length = (text: string) => {
5
+ let n = 0;
6
+ for (let i = 0; i < text.length; i++) {
7
+ const c = text.charCodeAt(i);
8
+ if (c < 0x80) {
9
+ n += 1;
10
+ } else if (c < 0x800) {
11
+ n += 2;
12
+ } else if (c >= 0xd800 && c <= 0xdbff) {
13
+ n += 4;
14
+ i++;
15
+ } else {
16
+ n += 3;
17
+ }
18
+ }
19
+ return n;
20
+ };
package/js/sdk/core.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // keepsnappy core: settings, windows, the commit walk, observers, faults, logs.
2
2
  // Loaded by boot.ts, which Metro runs before the app's entry module.
3
3
 
4
+ import { setPlaceLog } from './place';
5
+
4
6
  import {
5
7
  type ActivationStore,
6
8
  type Decision,
@@ -12,6 +14,8 @@ import {
12
14
  } from './activation';
13
15
  import {
14
16
  componentName,
17
+ clientKeyOf,
18
+ OUTSIDE_APP,
15
19
  type Fiber,
16
20
  type FiberRoot,
17
21
  isComponentTag,
@@ -66,6 +70,7 @@ import {
66
70
  type ProfiledFiber,
67
71
  type ProfiledRoot,
68
72
  recordFiber,
73
+ recordRolledUp,
69
74
  selfMs,
70
75
  summarizeWindowProfile,
71
76
  totalMs,
@@ -364,6 +369,8 @@ export const perfLog = (message: string, sink: LogSink = settings.log) => {
364
369
  }
365
370
  }
366
371
  };
372
+ setPlaceLog(perfLog);
373
+
367
374
 
368
375
  // Requests started per window (fetch/XHR hooks in net-install.ts feed it).
369
376
  export const net = createNetCounter({ now, onStart: noteRequestSent });
@@ -611,6 +618,8 @@ const diffStats = (
611
618
  export type WindowKind = 'marker' | 'press' | 'screen' | 'background';
612
619
 
613
620
  interface MarkerWindow {
621
+ legacyName: string | null;
622
+ beforeEnd: (() => void) | null;
614
623
  id: number;
615
624
  key: string;
616
625
  name: string;
@@ -839,9 +848,12 @@ export const lastCommitAt = (key: string) =>
839
848
  export const isWindowOpen = (key?: string) =>
840
849
  key ? openWindows.has(key) : openWindows.size > 0;
841
850
 
842
- const traceName = (name: string) => `keepsnappy:${name}`;
851
+ const traceName = (name: string) => `keepsnappy:${name}`.slice(0, 120);
852
+ const fileNamePart = (name: string) => name.replace(/[^\w.:+-]/g, '_').slice(0, 64);
843
853
 
844
854
  interface StartOptions {
855
+ legacyName?: string | null;
856
+ beforeEnd?: () => void;
845
857
  key?: string;
846
858
  kind?: WindowKind;
847
859
  // Backdates t0 (e.g. to the hardware touch time). performance.now() timebase.
@@ -879,6 +891,8 @@ const backgroundWindow = (source: string, t: number): MarkerWindow => {
879
891
  key: name,
880
892
  name,
881
893
  kind: 'background',
894
+ legacyName: null,
895
+ beforeEnd: null,
882
896
  trace: traceName(name),
883
897
  t0: t,
884
898
  t1: null,
@@ -990,6 +1004,8 @@ const start = (name: string, options: StartOptions = {}) => {
990
1004
  key,
991
1005
  name,
992
1006
  kind: options.kind ?? 'marker',
1007
+ legacyName: options.legacyName ?? null,
1008
+ beforeEnd: options.beforeEnd ?? null,
993
1009
  trace: traceName(name),
994
1010
  t0: startT,
995
1011
  t1: null,
@@ -1043,6 +1059,7 @@ const end = (key: string, reason = 'end', at?: number) => {
1043
1059
  if (!window) {
1044
1060
  return;
1045
1061
  }
1062
+ window.beforeEnd?.();
1046
1063
  window.t1 = at ?? now();
1047
1064
  window.endReason = reason;
1048
1065
  openWindows.delete(key);
@@ -1086,8 +1103,9 @@ const endAll = (reason: string) => {
1086
1103
  }
1087
1104
  };
1088
1105
 
1089
- const rename = (key: string, name: string) => {
1106
+ const rename = (key: string, name: string, legacyName?: string | null) => {
1090
1107
  const window = openWindows.get(key);
1108
+ if (window && legacyName !== undefined) window.legacyName = legacyName;
1091
1109
  if (window && window.name !== name) {
1092
1110
  perfLog(`PERF_MARK ${window.name} renamed=${name} id=${window.id}`);
1093
1111
  window.name = name;
@@ -1135,30 +1153,43 @@ const walkCommit = (
1135
1153
  causes: CommitCauses,
1136
1154
  ) => {
1137
1155
  const stack: Fiber[] = [root.current];
1156
+ const owners: string[] = [OUTSIDE_APP];
1157
+ const parents: (string | null)[] = [null];
1138
1158
  const renders: Record<string, number> = {};
1139
1159
  let mounts = 0;
1140
1160
  let visited = 0;
1141
1161
  const ancestors = exported ? createAncestorPaths() : null;
1142
1162
  while (stack.length > 0) {
1143
1163
  const fiber = stack.pop() as Fiber;
1164
+ const inherited = owners.pop() as string;
1165
+ const inheritedParent = parents.pop() as string | null;
1166
+ let owner = inherited, parentOfOwner = inheritedParent;
1144
1167
  visited++;
1145
1168
  const mounted = fiber.alternate === null;
1146
1169
  if (mounted) {
1147
1170
  noteMounted(fiber);
1148
1171
  }
1149
1172
  noteHostFiber(fiber, causes);
1150
- if (isComponentTag(fiber.tag) && (fiber.flags & PERFORMED_WORK) !== 0) {
1173
+ const component = isComponentTag(fiber.tag);
1174
+ const own = component ? clientKeyOf(fiber) : null;
1175
+ if (own !== null) {
1176
+ owner = own;
1177
+ parentOfOwner = inherited === OUTSIDE_APP ? null : inherited;
1178
+ }
1179
+ if (component && (fiber.flags & PERFORMED_WORK) !== 0) {
1151
1180
  const name = componentName(fiber);
1152
1181
  renders[name] = (renders[name] ?? 0) + 1;
1153
1182
  if (mounted) {
1154
1183
  mounts++;
1155
1184
  }
1156
- if (profile || exported) {
1185
+ if ((profile && own !== null) || exported) {
1157
1186
  const cause = updateCause(fiber as ProfiledFiber);
1158
- if (profile) {
1159
- recordFiber(profile, name, fiber as ProfiledFiber, cause, fiberId(fiber));
1187
+ if (profile && own !== null) {
1188
+ recordFiber(profile, own, fiber as ProfiledFiber, cause, fiberId(fiber), parentOfOwner);
1160
1189
  }
1161
1190
  if (exported) {
1191
+ // Profiled exports assign the rendered id before fresh ancestor ids.
1192
+ if (profile && own === null) fiberId(fiber);
1162
1193
  exported.fibers.push(
1163
1194
  exportFiber(
1164
1195
  fiber,
@@ -1167,10 +1198,14 @@ const walkCommit = (
1167
1198
  totalMs(fiber as ProfiledFiber),
1168
1199
  selfMs(fiber as ProfiledFiber),
1169
1200
  (ancestors as (f: Fiber) => number[])(fiber),
1201
+ owner,
1170
1202
  ),
1171
1203
  );
1172
1204
  }
1173
1205
  }
1206
+ if (profile && own === null) {
1207
+ recordRolledUp(profile, inherited, inheritedParent, name, fiber as ProfiledFiber);
1208
+ }
1174
1209
  }
1175
1210
  // Children not cloned in this render are the previous commit's fibers and
1176
1211
  // carry stale flags, so a bailed-out subtree is skipped entirely.
@@ -1180,6 +1215,8 @@ const walkCommit = (
1180
1215
  }
1181
1216
  for (let child = fiber.child; child !== null; child = child.sibling) {
1182
1217
  stack.push(child);
1218
+ owners.push(owner);
1219
+ parents.push(parentOfOwner);
1183
1220
  }
1184
1221
  }
1185
1222
  return { renders, mounts, visited };
@@ -1217,7 +1254,7 @@ const onCommitFiberRoot = (rendererId: number, root: FiberRoot) => {
1217
1254
  // The figure `profile.renderMs` reads; null when the renderer does not time commits.
1218
1255
  renderMs = isProfilingFiber(profiled.current) ? totalMs(profiled.current) : null;
1219
1256
  if (settings.profile) {
1220
- profile = createCommitProfile(profiled, seq);
1257
+ profile = createCommitProfile(profiled, seq, t0);
1221
1258
  }
1222
1259
  // Only commits a window can take are exported (open now, or backfilled
1223
1260
  // into a window opened later with a backdated start).
@@ -1477,7 +1514,7 @@ const netForWindow = (window: MarkerWindow): NetWindow | null =>
1477
1514
  window.net ? net.windowClose(window.id) : null;
1478
1515
 
1479
1516
  const reactExportName = (window: MarkerWindow) =>
1480
- `react-${PROCESS_ID}-${window.id}-${window.name.replace(/[^\w.:+-]/g, '_')}.json`;
1517
+ `react-${PROCESS_ID}-${window.id}-${fileNamePart(window.name)}.json`;
1481
1518
 
1482
1519
  interface PendingExport {
1483
1520
  name: string;
@@ -1720,7 +1757,7 @@ const finalizeWindow = (window: MarkerWindow) => {
1720
1757
  // Sampler first: its records carry the display rate the frame budget needs.
1721
1758
  const sampler = samplerForWindow(window);
1722
1759
  const profile = window.profile
1723
- ? summarizeWindowProfile(window.profile, { budget: frameBudget })
1760
+ ? summarizeWindowProfile(window.profile, { budget: frameBudget, t0: window.t0 })
1724
1761
  : null;
1725
1762
  const perf = settings.po ? summarizeEntries(window) : null;
1726
1763
  noteBudgetBreak(
@@ -1734,6 +1771,7 @@ const finalizeWindow = (window: MarkerWindow) => {
1734
1771
  windowModel: WINDOW_MODEL,
1735
1772
  id: window.id,
1736
1773
  name: window.name,
1774
+ ...(window.legacyName !== null && window.legacyName !== window.name ? { legacyName: window.legacyName } : {}),
1737
1775
  kind: window.kind,
1738
1776
  t0: window.t0,
1739
1777
  t1: window.t1,
@@ -1799,7 +1837,7 @@ const writeWindowRecord = (window: MarkerWindow, summary: Record<string, unknown
1799
1837
  windowListener?.(`${Date.now()} KEEPSNAPPY window ${json}`);
1800
1838
  if (settings.file) {
1801
1839
  const path = getNative()?.writeText(
1802
- `window-${window.id}-${window.name.replace(/[^\w.:+-]/g, '_')}.json`,
1840
+ `window-${window.id}-${fileNamePart(window.name)}.json`,
1803
1841
  json,
1804
1842
  );
1805
1843
  perfLog(`window file id=${window.id} path=${path ?? 'unavailable'}`);
@@ -1839,13 +1877,14 @@ const externalSummary = (window: MarkerWindow, timed: boolean) => {
1839
1877
 
1840
1878
  const finalizeBackground = (window: MarkerWindow) => {
1841
1879
  const profile = window.profile
1842
- ? summarizeWindowProfile(window.profile, { budget: frameBudget })
1880
+ ? summarizeWindowProfile(window.profile, { budget: frameBudget, t0: window.t0 })
1843
1881
  : null;
1844
1882
  writeWindowRecord(window, {
1845
1883
  v: 1,
1846
1884
  windowModel: WINDOW_MODEL,
1847
1885
  id: window.id,
1848
1886
  name: window.name,
1887
+ ...(window.legacyName !== null && window.legacyName !== window.name ? { legacyName: window.legacyName } : {}),
1849
1888
  kind: window.kind,
1850
1889
  t0: window.t0,
1851
1890
  t1: window.t1,
@@ -1903,11 +1942,13 @@ const leakJsHeap = (mb: number) => {
1903
1942
  leakState.retainedMB += mb;
1904
1943
  };
1905
1944
 
1906
- export const isFaultTarget = (windowName: string) =>
1907
- settings.faultTarget
1945
+ export const isFaultTarget = (press: { name: string; legacyName: string; target: { testID: string | null; accessibilityLabel: string | null } }) => {
1946
+ const names = [press.name, press.legacyName, press.target.testID, press.target.accessibilityLabel];
1947
+ return settings.faultTarget
1908
1948
  .split(',')
1909
1949
  .map(part => part.trim())
1910
- .some(part => part !== '' && windowName.includes(part));
1950
+ .some(part => part !== '' && names.some(name => name?.includes(part)));
1951
+ };
1911
1952
 
1912
1953
  export const runOpenFaults = (where: string) => {
1913
1954
  const faults = settings.faults;
@@ -0,0 +1,10 @@
1
+ // The model a device reports to the API: what the OS calls the hardware, never the marketing name. expo-device's `modelId` is an
2
+ // iPhone's machine id ("iPhone17,1") and null on Android, where `modelName` is already Build.MODEL ("SM-S911B"); `modelName` on iOS
3
+ // is the marketing name, so the id comes first. A simulator keeps its name: there `modelId` is the host's `uname` machine ("arm64")
4
+ // and `modelName` is "Simulator iOS" (an emulator's Build.MODEL carries the word the API reads the device kind from).
5
+ export const deviceModelOf = (device: { isDevice?: boolean | null; modelId?: string | null; modelName?: string | null }): string | null => {
6
+ const modelId = /^(arm64|x86_64|i386)$/i.test(device.modelId ?? '') && device.isDevice === false ? null : device.modelId;
7
+ if (device.isDevice !== false) return modelId || device.modelName || null;
8
+ const model = device.modelName || modelId;
9
+ return model ? /(simulator|emulator|sdk_gphone|android sdk built for)/i.test(model) ? model : `${model} Simulator` : null;
10
+ };