@keepsnappy/react-native 0.1.0-alpha.14 → 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 +12 -0
- package/js/babel-transformer.js +24 -6
- package/js/component-contract.cjs +14 -0
- package/js/component-stamps.js +192 -0
- package/js/sdk/bytes.ts +20 -0
- package/js/sdk/core.ts +55 -14
- package/js/sdk/device-model.ts +10 -0
- package/js/sdk/fiber.ts +282 -28
- package/js/sdk/interactions.ts +23 -20
- package/js/sdk/place.ts +25 -0
- package/js/sdk/profile-export.ts +15 -0
- package/js/sdk/profile.ts +272 -18
- package/js/sdk/push-install.ts +2 -1
- package/js/sdk/push.ts +3 -19
- package/js/sdk/screens.ts +83 -4
- package/metro.js +11 -1
- package/package.json +1 -1
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
|
package/js/babel-transformer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Wraps the app's Babel transformer (set by withKeepSnappy)
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
args = { ...args, src: inlineEnv(src) };
|
|
65
70
|
}
|
|
66
|
-
|
|
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 };
|
package/js/sdk/bytes.ts
ADDED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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 = (
|
|
1907
|
-
|
|
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 !== '' &&
|
|
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
|
+
};
|