rnxsim 0.1.378 → 0.1.380

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.
Files changed (47) hide show
  1. package/cli/commands/cpu-profile.ts +556 -50
  2. package/cli/commands/perf.ts +1 -0
  3. package/cli/commands/upload.ts +7 -1
  4. package/dist-lib/agent-daemon-client.cjs +1 -1
  5. package/dist-lib/agent-events.cjs +1 -1
  6. package/dist-lib/agent-identity.cjs +1 -1
  7. package/dist-lib/agent-sessions.cjs +1 -1
  8. package/dist-lib/attached-projects.cjs +1 -1
  9. package/dist-lib/auth/shared-session.cjs +1 -1
  10. package/dist-lib/backend-origin.cjs +1 -1
  11. package/dist-lib/beta.cjs +1 -1
  12. package/dist-lib/beta.mjs +1 -1
  13. package/dist-lib/bridge-constants.cjs +1 -1
  14. package/dist-lib/bridge-contract-input.cjs +1 -1
  15. package/dist-lib/bridge-contract-input.mjs +1 -1
  16. package/dist-lib/bridge-contract.cjs +1 -1
  17. package/dist-lib/bridge-contract.mjs +1 -1
  18. package/dist-lib/cli-constants.cjs +1 -1
  19. package/dist-lib/config.cjs +1 -1
  20. package/dist-lib/detox/index.cjs +1 -1
  21. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  22. package/dist-lib/home-paths.cjs +1 -1
  23. package/dist-lib/host/bridge-host.cjs +1 -1
  24. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  25. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  26. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  27. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  28. package/dist-lib/host/websocket-proxy.cjs +1 -1
  29. package/dist-lib/index.cjs +1 -1
  30. package/dist-lib/jump-to-source-babel.cjs +1 -1
  31. package/dist-lib/menu.cjs +1 -1
  32. package/dist-lib/menu.mjs +1 -1
  33. package/dist-lib/metro-production-bundle.cjs +1 -1
  34. package/dist-lib/metro-production-bundle.mjs +1 -1
  35. package/dist-lib/metro.cjs +1 -1
  36. package/dist-lib/profiles.cjs +1 -1
  37. package/dist-lib/public-brand.cjs +1 -1
  38. package/dist-lib/react-native-host-modules.cjs +1 -1
  39. package/dist-lib/react-native-host-modules.mjs +1 -1
  40. package/dist-lib/render-mode.cjs +1 -1
  41. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  42. package/dist-lib/sdk.cjs +1 -1
  43. package/dist-lib/sdk.mjs +1 -1
  44. package/dist-lib/skills.cjs +7 -7
  45. package/dist-lib/vite.cjs +1 -1
  46. package/package.json +1 -1
  47. package/skills/rnx-perf/SKILL.md +68 -2
@@ -1,10 +1,10 @@
1
- // rnx perf cpu — capture a sampled CPU trace from the tenant worker.
1
+ // rnx perf cpu — capture sampled CPU traces from the page and every worker.
2
2
  //
3
3
  // the JS Self-Profiler API (`new Profiler()`) cannot be constructed in a
4
4
  // dedicated-worker scope, and sootsim runs the guest app in the tenant worker,
5
5
  // so the old in-worker approach was unsupported (F28). this implementation
6
- // attaches Chromium's `Profiler` CDP domain to the worker target instead
7
- // which works for workers and returns the .cpuprofile shape directly.
6
+ // attaches Chromium's `Profiler` CDP domain to the page and worker targets
7
+ // which returns the .cpuprofile shape directly for every execution context.
8
8
  //
9
9
  // dedicated workers are not top-level CDP targets, so we connect to the page
10
10
  // target, `Target.setAutoAttach` to discover the worker child sessions, and
@@ -25,9 +25,12 @@
25
25
  // also drives the interaction itself lives at
26
26
  // scripts/debug/cdp-worker-cpu-profile.ts.
27
27
 
28
- import { mkdirSync, writeFileSync } from 'fs'
29
- import { dirname, resolve } from 'path'
28
+ import { createHash } from 'node:crypto'
29
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
30
+ import { dirname, resolve } from 'node:path'
30
31
  import { WebSocket } from 'ws'
32
+ import { getCliVersion } from '../../src/cli-version'
33
+ import { IS_STANDALONE } from '../standalone'
31
34
 
32
35
  interface ProfileOptions {
33
36
  port?: number
@@ -41,8 +44,15 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
41
44
  class Cdp {
42
45
  private ws: WebSocket
43
46
  private nextId = 1
44
- private pending = new Map<number, { res: (v: any) => void; rej: (e: any) => void }>()
45
- private listeners = new Set<(method: string, params: any) => void>()
47
+ private pending = new Map<
48
+ number,
49
+ {
50
+ res: (v: any) => void
51
+ rej: (e: any) => void
52
+ timeout: ReturnType<typeof setTimeout>
53
+ }
54
+ >()
55
+ private listeners = new Set<(method: string, params: any, sessionId?: string) => void>()
46
56
  private ready: Promise<void>
47
57
 
48
58
  constructor(wsUrl: string) {
@@ -54,12 +64,20 @@ class Cdp {
54
64
  this.ws.on('message', (data) => {
55
65
  const msg = JSON.parse(data.toString())
56
66
  if (msg.id && this.pending.has(msg.id)) {
57
- const { res, rej } = this.pending.get(msg.id)!
67
+ const { res, rej, timeout } = this.pending.get(msg.id)!
58
68
  this.pending.delete(msg.id)
69
+ clearTimeout(timeout)
59
70
  msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result)
60
71
  } else if (msg.method) {
61
- for (const l of this.listeners) l(msg.method, msg.params)
72
+ for (const l of this.listeners) l(msg.method, msg.params, msg.sessionId)
73
+ }
74
+ })
75
+ this.ws.on('close', () => {
76
+ for (const [id, request] of this.pending) {
77
+ clearTimeout(request.timeout)
78
+ request.rej(new Error(`CDP connection closed with request ${id} pending`))
62
79
  }
80
+ this.pending.clear()
63
81
  })
64
82
  }
65
83
 
@@ -67,7 +85,7 @@ class Cdp {
67
85
  return this.ready
68
86
  }
69
87
 
70
- on(l: (method: string, params: any) => void) {
88
+ on(l: (method: string, params: any, sessionId?: string) => void) {
71
89
  this.listeners.add(l)
72
90
  }
73
91
 
@@ -78,7 +96,11 @@ class Cdp {
78
96
  ): Promise<any> {
79
97
  const id = this.nextId++
80
98
  return new Promise((res, rej) => {
81
- this.pending.set(id, { res, rej })
99
+ const timeout = setTimeout(() => {
100
+ this.pending.delete(id)
101
+ rej(new Error(`CDP ${method} timed out after 5s`))
102
+ }, 5_000)
103
+ this.pending.set(id, { res, rej, timeout })
82
104
  this.ws.send(
83
105
  JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }),
84
106
  )
@@ -94,10 +116,135 @@ class Cdp {
94
116
 
95
117
  interface CdpTarget {
96
118
  type: string
119
+ title?: string
97
120
  url: string
98
121
  webSocketDebuggerUrl: string
99
122
  }
100
123
 
124
+ interface AttachedTarget {
125
+ attachedAtMs: number
126
+ detached: boolean
127
+ detachedAtMs?: number
128
+ error?: string
129
+ profile?: any
130
+ profileStartedAtMs?: number
131
+ profileStoppedAtMs?: number
132
+ role: string
133
+ sessionId?: string
134
+ started: boolean
135
+ title: string
136
+ type: string
137
+ url: string
138
+ }
139
+
140
+ export interface CpuProfileInteractionBarrier {
141
+ marker: string
142
+ reachedAtEpochMs: number
143
+ releasedBy: string
144
+ releasedAtEpochMs: number
145
+ }
146
+
147
+ export interface CpuProfileCoverage {
148
+ coverageMs: number
149
+ coverageRatio: number
150
+ startedOffsetMs: number | null
151
+ stoppedOffsetMs: number | null
152
+ }
153
+
154
+ export function cpuProfileCoverage(
155
+ profileStartedAtMs: number | undefined,
156
+ profileStoppedAtMs: number | undefined,
157
+ windowStartedAtMs: number,
158
+ windowEndedAtMs: number,
159
+ ): CpuProfileCoverage {
160
+ const requestedMs = Math.max(0, windowEndedAtMs - windowStartedAtMs)
161
+ if (
162
+ profileStartedAtMs === undefined ||
163
+ profileStoppedAtMs === undefined ||
164
+ requestedMs === 0
165
+ ) {
166
+ return {
167
+ coverageMs: 0,
168
+ coverageRatio: 0,
169
+ startedOffsetMs:
170
+ profileStartedAtMs === undefined ? null : profileStartedAtMs - windowStartedAtMs,
171
+ stoppedOffsetMs:
172
+ profileStoppedAtMs === undefined ? null : profileStoppedAtMs - windowStartedAtMs,
173
+ }
174
+ }
175
+ const coverageMs = Math.max(
176
+ 0,
177
+ Math.min(profileStoppedAtMs, windowEndedAtMs) -
178
+ Math.max(profileStartedAtMs, windowStartedAtMs),
179
+ )
180
+ return {
181
+ coverageMs,
182
+ coverageRatio: coverageMs / requestedMs,
183
+ startedOffsetMs: profileStartedAtMs - windowStartedAtMs,
184
+ stoppedOffsetMs: profileStoppedAtMs - windowStartedAtMs,
185
+ }
186
+ }
187
+
188
+ export function cpuProfileActionsWithinWindow(
189
+ actionEpochMs: readonly number[],
190
+ windowStartedAtEpochMs: number,
191
+ windowEndedAtEpochMs: number,
192
+ ): boolean {
193
+ return (
194
+ actionEpochMs.length > 0 &&
195
+ actionEpochMs.every(
196
+ (epochMs) =>
197
+ Number.isFinite(epochMs) &&
198
+ epochMs >= windowStartedAtEpochMs &&
199
+ epochMs <= windowEndedAtEpochMs,
200
+ )
201
+ )
202
+ }
203
+
204
+ export function cpuProfileInteractionBarrierValid(
205
+ barrier: CpuProfileInteractionBarrier | null,
206
+ windowStartedAtEpochMs: number,
207
+ windowEndedAtEpochMs: number,
208
+ ): boolean {
209
+ return (
210
+ barrier !== null &&
211
+ barrier.marker.length > 0 &&
212
+ barrier.releasedBy === 'profiler' &&
213
+ Number.isFinite(barrier.reachedAtEpochMs) &&
214
+ Number.isFinite(barrier.releasedAtEpochMs) &&
215
+ barrier.reachedAtEpochMs <= barrier.releasedAtEpochMs &&
216
+ barrier.releasedAtEpochMs >= windowStartedAtEpochMs &&
217
+ barrier.releasedAtEpochMs <= windowEndedAtEpochMs
218
+ )
219
+ }
220
+
221
+ function readInteractionBarrierState(
222
+ value: unknown,
223
+ ): CpuProfileInteractionBarrier | null {
224
+ if (!value || typeof value !== 'object') return null
225
+ const marker = Reflect.get(value, 'marker')
226
+ const reachedAtEpochMs = Reflect.get(value, 'reachedAtEpochMs')
227
+ const releasedBy = Reflect.get(value, 'releasedBy')
228
+ const releasedAtEpochMs = Reflect.get(value, 'releasedAtEpochMs')
229
+ if (
230
+ typeof marker !== 'string' ||
231
+ typeof reachedAtEpochMs !== 'number' ||
232
+ typeof releasedBy !== 'string' ||
233
+ typeof releasedAtEpochMs !== 'number'
234
+ ) {
235
+ return null
236
+ }
237
+ return { marker, reachedAtEpochMs, releasedBy, releasedAtEpochMs }
238
+ }
239
+
240
+ function readActionEpochs(value: unknown): number[] {
241
+ if (!Array.isArray(value)) return []
242
+ return value.filter(
243
+ (epochMs): epochMs is number =>
244
+ typeof epochMs === 'number' && Number.isFinite(epochMs),
245
+ )
246
+ }
247
+
101
248
  export async function runCpuProfile(
102
249
  args: string[],
103
250
  opts: ProfileOptions,
@@ -108,6 +255,21 @@ export async function runCpuProfile(
108
255
  return 1
109
256
  }
110
257
  const sampleInterval = Number(valueOf(args, '--sample-interval') ?? '0.1') // ms
258
+ const interactionBarrierEnabled = args.includes('--interaction-barrier')
259
+ const interactionBarrierTimeoutSeconds = Number(
260
+ valueOf(args, '--interaction-barrier-timeout') ?? '90',
261
+ )
262
+ if (
263
+ !Number.isFinite(interactionBarrierTimeoutSeconds) ||
264
+ interactionBarrierTimeoutSeconds <= 0
265
+ ) {
266
+ console.error(' --interaction-barrier-timeout must be positive (seconds)')
267
+ return 1
268
+ }
269
+ const interactionBarrierLeaseMs = Math.min(
270
+ 2_147_000_000,
271
+ Math.ceil((interactionBarrierTimeoutSeconds + duration + 5) * 1000),
272
+ )
111
273
  const cdpPort = Number(
112
274
  valueOf(args, '--cdp-port') ?? process.env.RNX_CDP_PORT ?? '9222',
113
275
  )
@@ -141,65 +303,396 @@ export async function runCpuProfile(
141
303
 
142
304
  const cdp = new Cdp(page.webSocketDebuggerUrl)
143
305
  await cdp.waitOpen()
306
+ let interactionBarrierInstalled = false
307
+ const evaluatePage = async (expression: string): Promise<unknown> => {
308
+ const response = await cdp.send('Runtime.evaluate', {
309
+ expression,
310
+ returnByValue: true,
311
+ })
312
+ if (response?.exceptionDetails) {
313
+ throw new Error(
314
+ `page evaluation failed: ${response.exceptionDetails.text ?? 'unknown error'}`,
315
+ )
316
+ }
317
+ return response?.result?.value
318
+ }
319
+ const releaseInteractionBarrier = (releasedBy: 'cleanup' | 'profiler') =>
320
+ evaluatePage(`(() => {
321
+ const release = Reflect.get(globalThis, '__sootsimReleaseCpuProfileBarrier')
322
+ return typeof release === 'function'
323
+ ? Reflect.apply(release, globalThis, [${JSON.stringify(releasedBy)}])
324
+ : null
325
+ })()`)
144
326
  try {
145
- // discover worker child sessions via auto-attach.
146
- const workers = new Map<string, string>() // sessionId -> url
327
+ if (interactionBarrierEnabled) {
328
+ const installed = await evaluatePage(`(() => {
329
+ const state = {
330
+ marker: null,
331
+ reachedAtEpochMs: null,
332
+ releasedBy: null,
333
+ releasedAtEpochMs: null,
334
+ }
335
+ let releaseGate = () => {}
336
+ const gate = new Promise((resolve) => {
337
+ releaseGate = resolve
338
+ })
339
+ Reflect.set(globalThis, '__sootsimCpuProfileBarrierState', state)
340
+ Reflect.set(globalThis, '__sootsimCpuProfileBarrier', async (marker) => {
341
+ if (state.marker === null) {
342
+ state.marker = String(marker)
343
+ state.reachedAtEpochMs = Date.now()
344
+ }
345
+ await gate
346
+ })
347
+ let lease = null
348
+ const releaseBarrier = (releasedBy) => {
349
+ if (state.releasedAtEpochMs === null) {
350
+ state.releasedAtEpochMs = Date.now()
351
+ state.releasedBy = String(releasedBy)
352
+ if (lease !== null) clearTimeout(lease)
353
+ releaseGate()
354
+ }
355
+ return { ...state }
356
+ }
357
+ lease = setTimeout(
358
+ () => releaseBarrier('lease'),
359
+ ${interactionBarrierLeaseMs},
360
+ )
361
+ Reflect.set(globalThis, '__sootsimReleaseCpuProfileBarrier', releaseBarrier)
362
+ return true
363
+ })()`)
364
+ if (installed !== true) {
365
+ console.error(' failed to install the interaction profile barrier')
366
+ return 1
367
+ }
368
+ interactionBarrierInstalled = true
369
+ }
370
+
371
+ // discover every child worker. keep detached entries in the manifest: a
372
+ // worker can exit during the capture, and deleting it used to make the
373
+ // command either mislabel the busiest survivor as "tenant" or lose the
374
+ // entire run when Profiler.stop hit the detached session first.
375
+ const workers = new Map<string, AttachedTarget>()
376
+ let captureStarted = false
377
+ let captureWindowClosed = false
378
+ const startPromises: Array<Promise<void>> = []
379
+ const discoveryPromises: Array<Promise<void>> = []
380
+ let attachmentGeneration = 0
381
+ let awaitedDiscoveryCount = 0
382
+ let awaitedStartCount = 0
383
+ const enableRecursiveAutoAttach = (sessionId?: string) =>
384
+ cdp.send(
385
+ 'Target.setAutoAttach',
386
+ {
387
+ autoAttach: true,
388
+ waitForDebuggerOnStart: false,
389
+ flatten: true,
390
+ },
391
+ sessionId,
392
+ )
393
+ const startTarget = async (target: AttachedTarget) => {
394
+ try {
395
+ await cdp.send('Profiler.enable', {}, target.sessionId)
396
+ await cdp.send(
397
+ 'Profiler.setSamplingInterval',
398
+ { interval: sampleInterval * 1000 },
399
+ target.sessionId,
400
+ )
401
+ await cdp.send('Profiler.start', {}, target.sessionId)
402
+ target.started = true
403
+ target.profileStartedAtMs = performance.now()
404
+ } catch (error) {
405
+ target.error = error instanceof Error ? error.message : String(error)
406
+ }
407
+ }
408
+ const waitForStableTargetSetup = async () => {
409
+ while (true) {
410
+ const observedGeneration = attachmentGeneration
411
+ if (awaitedDiscoveryCount < discoveryPromises.length) {
412
+ const pendingDiscoveries = discoveryPromises.slice(awaitedDiscoveryCount)
413
+ awaitedDiscoveryCount = discoveryPromises.length
414
+ await Promise.all(pendingDiscoveries)
415
+ }
416
+ if (awaitedStartCount < startPromises.length) {
417
+ const pendingStarts = startPromises.slice(awaitedStartCount)
418
+ awaitedStartCount = startPromises.length
419
+ await Promise.all(pendingStarts)
420
+ }
421
+ // recursive auto-attach notifications can arrive just after the CDP
422
+ // command resolves. require a quiet generation before releasing work.
423
+ await sleep(50)
424
+ if (
425
+ observedGeneration === attachmentGeneration &&
426
+ awaitedDiscoveryCount === discoveryPromises.length &&
427
+ awaitedStartCount === startPromises.length
428
+ ) {
429
+ return
430
+ }
431
+ }
432
+ }
147
433
  cdp.on((method, params) => {
148
434
  if (method === 'Target.attachedToTarget' && params.targetInfo?.type === 'worker') {
149
- workers.set(params.sessionId, params.targetInfo.url)
435
+ if (captureWindowClosed) return
436
+ const sessionId = params.sessionId
437
+ const targetInfo = params.targetInfo
438
+ const target: AttachedTarget = {
439
+ attachedAtMs: performance.now(),
440
+ detached: false,
441
+ role: cpuProfileTargetRole(targetInfo.url, targetInfo.title),
442
+ sessionId,
443
+ started: false,
444
+ title: targetInfo.title ?? '',
445
+ type: targetInfo.type,
446
+ url: targetInfo.url,
447
+ }
448
+ workers.set(sessionId, target)
449
+ attachmentGeneration += 1
450
+ discoveryPromises.push(
451
+ enableRecursiveAutoAttach(sessionId).catch((error: unknown) => {
452
+ target.error = `recursive auto-attach failed: ${error instanceof Error ? error.message : String(error)}`
453
+ }),
454
+ )
455
+ if (captureStarted) startPromises.push(startTarget(target))
456
+ }
457
+ if (method === 'Target.detachedFromTarget') {
458
+ const target = workers.get(params.sessionId)
459
+ if (target) {
460
+ target.detached = true
461
+ target.detachedAtMs = performance.now()
462
+ }
150
463
  }
151
- if (method === 'Target.detachedFromTarget') workers.delete(params.sessionId)
152
- })
153
- await cdp.send('Target.setAutoAttach', {
154
- autoAttach: true,
155
- waitForDebuggerOnStart: false,
156
- flatten: true,
157
464
  })
465
+ await enableRecursiveAutoAttach()
158
466
  // auto-attach events arrive asynchronously after the call resolves.
159
467
  await sleep(500)
160
- if (workers.size === 0) {
161
- console.error(' no worker targets attached — is the app loaded in this tab?')
468
+ await waitForStableTargetSetup()
469
+ const pageTarget: AttachedTarget = {
470
+ attachedAtMs: performance.now(),
471
+ detached: false,
472
+ role: 'page',
473
+ started: false,
474
+ title: page.title ?? '',
475
+ type: 'page',
476
+ url: page.url,
477
+ }
478
+ if (interactionBarrierEnabled) {
479
+ console.log(' waiting for the workload interaction barrier…')
480
+ const deadline = performance.now() + interactionBarrierTimeoutSeconds * 1000
481
+ let reached = false
482
+ while (performance.now() < deadline) {
483
+ const value = await evaluatePage(`(() => {
484
+ const state = Reflect.get(globalThis, '__sootsimCpuProfileBarrierState')
485
+ if (!state || typeof state !== 'object') return null
486
+ return {
487
+ marker: Reflect.get(state, 'marker'),
488
+ reachedAtEpochMs: Reflect.get(state, 'reachedAtEpochMs'),
489
+ }
490
+ })()`)
491
+ if (
492
+ value &&
493
+ typeof value === 'object' &&
494
+ typeof Reflect.get(value, 'marker') === 'string' &&
495
+ typeof Reflect.get(value, 'reachedAtEpochMs') === 'number'
496
+ ) {
497
+ reached = true
498
+ break
499
+ }
500
+ await sleep(25)
501
+ }
502
+ if (!reached) {
503
+ console.error(
504
+ ` workload interaction barrier was not reached within ${interactionBarrierTimeoutSeconds}s`,
505
+ )
506
+ return 1
507
+ }
508
+ }
509
+ captureStarted = true
510
+ startPromises.push(startTarget(pageTarget))
511
+ for (const target of workers.values()) startPromises.push(startTarget(target))
512
+ await waitForStableTargetSetup()
513
+ const startedCount = [pageTarget, ...workers.values()].filter(
514
+ (target) => target.started,
515
+ ).length
516
+ if (startedCount === 0) {
517
+ console.error(' no CDP targets accepted Profiler.start')
162
518
  return 1
163
519
  }
164
-
165
- for (const sid of workers.keys()) {
166
- await cdp.send('Profiler.enable', {}, sid)
167
- await cdp.send(
168
- 'Profiler.setSamplingInterval',
169
- { interval: sampleInterval * 1000 },
170
- sid,
171
- ) // µs
172
- await cdp.send('Profiler.start', {}, sid)
520
+ const captureWindowStartedAtMs = performance.now()
521
+ const captureWindowStartedAtEpochMs = Date.now()
522
+ const captureWindowEndedAtMs = captureWindowStartedAtMs + duration * 1000
523
+ const captureWindowEndedAtEpochMs = captureWindowStartedAtEpochMs + duration * 1000
524
+ const interactionBarrier = interactionBarrierEnabled
525
+ ? readInteractionBarrierState(await releaseInteractionBarrier('profiler'))
526
+ : null
527
+ if (opts.verbose) {
528
+ for (const target of [pageTarget, ...workers.values()]) {
529
+ console.log(
530
+ ` target ${target.role} ${target.type} ${target.title || '(untitled)'} ${target.url}`,
531
+ )
532
+ }
173
533
  }
174
534
  console.log(
175
- ` recording ${duration}s across ${workers.size} worker(s) — interact now…`,
535
+ ` recording ${duration}s across ${startedCount} target(s) — interact now…`,
176
536
  )
177
- await sleep(duration * 1000)
178
-
179
- const results: Array<{ url: string; profile: any }> = []
180
- for (const [sid, url] of workers) {
181
- const { profile } = await cdp.send('Profiler.stop', {}, sid)
182
- results.push({ url, profile })
183
- }
184
- results.sort((a, b) => b.profile.samples.length - a.profile.samples.length)
537
+ await sleep(Math.max(0, captureWindowEndedAtMs - performance.now()))
538
+ captureStarted = false
539
+ captureWindowClosed = true
540
+ await Promise.allSettled(startPromises)
541
+ const results = [pageTarget, ...workers.values()]
542
+ await Promise.all(
543
+ results.map(async (target) => {
544
+ if (!target.started) return
545
+ try {
546
+ const { profile } = await cdp.send('Profiler.stop', {}, target.sessionId)
547
+ target.profile = profile
548
+ target.profileStoppedAtMs = performance.now()
549
+ } catch (error) {
550
+ target.error = error instanceof Error ? error.message : String(error)
551
+ target.profileStoppedAtMs = target.detachedAtMs
552
+ }
553
+ await cdp.send('Profiler.disable', {}, target.sessionId).catch(() => {})
554
+ }),
555
+ )
556
+ // querying the page while five high-frequency profilers are still active
557
+ // can itself time out and lengthen the requested sampling interval. stop
558
+ // first, then read the already-recorded action timestamps.
559
+ const workloadActionEpochMs = interactionBarrierEnabled
560
+ ? readActionEpochs(
561
+ await evaluatePage(`(() => {
562
+ const partial = Reflect.get(globalThis, '__sootsimInteractionPartialResult')
563
+ const captures = partial && typeof partial === 'object'
564
+ ? Reflect.get(partial, 'captures')
565
+ : null
566
+ if (!Array.isArray(captures)) return []
567
+ return captures.map((capture) => {
568
+ if (!capture || typeof capture !== 'object') return null
569
+ const note = Reflect.get(capture, 'note')
570
+ return note && typeof note === 'object'
571
+ ? Reflect.get(note, 'actionEpochMs')
572
+ : null
573
+ })
574
+ })()`),
575
+ )
576
+ : []
185
577
 
186
578
  mkdirSync(dirname(outputPath), { recursive: true })
187
- // save the busiest worker (the tenant during interaction) to the main path;
188
- // others get a suffix so a multi-worker capture isn't lost.
189
- results.forEach((r, i) => {
190
- const path =
191
- i === 0 ? outputPath : outputPath.replace(/(\.[^.]+)?$/, `.worker${i}$1`)
192
- writeFileSync(path, JSON.stringify(r.profile))
193
- console.log(` ${shortUrl(r.url)}: ${r.profile.samples.length} samples → ${path}`)
194
- if (opts.verbose && r.profile.samples.length > 0) {
195
- for (const fn of topSelfTime(r.profile, 12)) {
579
+ const roleCounts = new Map<string, number>()
580
+ const manifest = results.map((target) => {
581
+ const coverage = cpuProfileCoverage(
582
+ target.profileStartedAtMs,
583
+ target.profileStoppedAtMs,
584
+ captureWindowStartedAtMs,
585
+ captureWindowEndedAtMs,
586
+ )
587
+ const priorCount = roleCounts.get(target.role) ?? 0
588
+ roleCounts.set(target.role, priorCount + 1)
589
+ const role = priorCount === 0 ? target.role : `${target.role}-${priorCount + 1}`
590
+ const profilePath = target.profile
591
+ ? outputPath.replace(/(\.[^.]+)?$/, `.${role}$1`)
592
+ : null
593
+ if (profilePath) {
594
+ writeFileSync(profilePath, JSON.stringify(target.profile))
595
+ console.log(
596
+ ` ${role} ${shortUrl(target.url)}: ${target.profile.samples.length} samples → ${profilePath}`,
597
+ )
598
+ } else {
599
+ console.error(
600
+ ` ${role} ${shortUrl(target.url)}: no profile${target.error ? ` — ${target.error}` : ''}`,
601
+ )
602
+ }
603
+ if (opts.verbose && target.profile?.samples.length > 0) {
604
+ for (const fn of topSelfTime(target.profile, 12)) {
196
605
  console.log(` ${fn.pct.toFixed(1).padStart(5)}% ${fn.name} ${fn.url}`)
197
606
  }
198
607
  }
608
+ return {
609
+ role,
610
+ type: target.type,
611
+ title: target.title,
612
+ url: target.url,
613
+ detached: target.detached,
614
+ attachedOffsetMs: target.attachedAtMs - captureWindowStartedAtMs,
615
+ ...coverage,
616
+ samples: target.profile?.samples.length ?? 0,
617
+ profilePath,
618
+ error: target.error ?? null,
619
+ }
199
620
  })
621
+ const requiredRoles = ['page', 'shell', 'compositor', 'tenant']
622
+ const capturedRoles = new Set(
623
+ manifest
624
+ .filter((target) => target.profilePath !== null)
625
+ .map((target) => target.role),
626
+ )
627
+ const missingRoles = requiredRoles.filter((role) => !capturedRoles.has(role))
628
+ const interactionBarrierComplete =
629
+ !interactionBarrierEnabled ||
630
+ (cpuProfileInteractionBarrierValid(
631
+ interactionBarrier,
632
+ captureWindowStartedAtEpochMs,
633
+ captureWindowEndedAtEpochMs,
634
+ ) &&
635
+ cpuProfileActionsWithinWindow(
636
+ workloadActionEpochMs,
637
+ captureWindowStartedAtEpochMs,
638
+ captureWindowEndedAtEpochMs,
639
+ ))
640
+ const complete =
641
+ missingRoles.length === 0 &&
642
+ interactionBarrierComplete &&
643
+ manifest.length > 0 &&
644
+ manifest.every(
645
+ (target) =>
646
+ target.profilePath !== null &&
647
+ target.error === null &&
648
+ target.coverageRatio >= 0.99,
649
+ )
650
+ const manifestPath = outputPath.replace(/(\.[^.]+)?$/, '.manifest.json')
651
+ const executableSha256 = IS_STANDALONE
652
+ ? createHash('sha256').update(readFileSync(process.execPath)).digest('hex')
653
+ : null
654
+ writeFileSync(
655
+ manifestPath,
656
+ JSON.stringify(
657
+ {
658
+ cli: {
659
+ version: getCliVersion(),
660
+ standalone: IS_STANDALONE,
661
+ executablePath: process.execPath,
662
+ executableSha256,
663
+ argv: process.argv.slice(1),
664
+ },
665
+ page: page.url,
666
+ complete,
667
+ missingRoles,
668
+ requestedDurationMs: duration * 1000,
669
+ captureWindowStartedAt: new Date(captureWindowStartedAtEpochMs).toISOString(),
670
+ captureWindowEndedAt: new Date(captureWindowEndedAtEpochMs).toISOString(),
671
+ interactionBarrier,
672
+ workloadActionEpochMs,
673
+ manifest,
674
+ },
675
+ null,
676
+ 2,
677
+ ),
678
+ )
679
+ console.log(` manifest → ${manifestPath}`)
680
+ if (!complete) {
681
+ console.error(
682
+ ` incomplete attribution capture${missingRoles.length ? ` — missing ${missingRoles.join(', ')}` : ''}`,
683
+ )
684
+ if (!interactionBarrierComplete) {
685
+ console.error(
686
+ ' workload actions did not fall inside the interaction profile window',
687
+ )
688
+ }
689
+ }
200
690
  console.log(' open in chrome devtools → Performance → Load profile to inspect.')
201
- return 0
691
+ return complete ? 0 : 1
202
692
  } finally {
693
+ if (interactionBarrierInstalled) {
694
+ await releaseInteractionBarrier('cleanup').catch(() => {})
695
+ }
203
696
  cdp.close()
204
697
  }
205
698
  }
@@ -242,6 +735,19 @@ function shortUrl(url: string): string {
242
735
  .slice(-50)
243
736
  }
244
737
 
738
+ export function cpuProfileTargetRole(url: string, title = ''): string {
739
+ const identity = `${title} ${url}`.toLowerCase()
740
+ if (identity.includes('compositor-worker') || identity.includes('sootsim-compositor')) {
741
+ return 'compositor'
742
+ }
743
+ if (identity.includes('shell-worker')) return 'shell'
744
+ if (identity.includes('named-worklet')) return 'worklet'
745
+ if (identity.includes('render-worker') || identity.includes('tenant-worker')) {
746
+ return 'tenant'
747
+ }
748
+ return 'worker'
749
+ }
750
+
245
751
  function topSelfTime(profile: any, topN: number) {
246
752
  const total = profile.samples.length || 1
247
753
  const byFn = new Map<string, { self: number; name: string; url: string }>()
@@ -38,6 +38,7 @@ usage:
38
38
 
39
39
  examples:
40
40
  rnx perf cpu --duration 5 --output /tmp/trace.cpuprofile
41
+ # writes page + every worker profile and a role-labelled manifest
41
42
  rnx perf react summary --limit 10
42
43
  rnx perf shell start
43
44
  rnx perf shell stop --json
@@ -528,7 +528,11 @@ export async function runUpload(
528
528
  const [mainManifest, workerManifest] = await Promise.all([
529
529
  evalInBridge<RecordedEntry[]>(
530
530
  bridge,
531
- `(window.__sootsimPreviewRecorder?.list?.(${JSON.stringify(bundleOrigin)}) || [])`,
531
+ `(async () => {
532
+ const rec = window.__sootsimPreviewRecorder
533
+ await rec?.flush?.()
534
+ return rec?.list?.(${JSON.stringify(bundleOrigin)}) || []
535
+ })()`,
532
536
  ),
533
537
  evalInBridge<RecordedEntry[]>(
534
538
  bridge,
@@ -560,6 +564,7 @@ export async function runUpload(
560
564
  `(async () => {
561
565
  const rec = window.__sootsimPreviewRecorder
562
566
  const workerList = window.__sootsimListWorkerFetchRecorder
567
+ await rec?.flush?.()
563
568
  return {
564
569
  main: rec?.list ? rec.list() : [],
565
570
  worker: typeof workerList === 'function' ? await workerList() : [],
@@ -604,6 +609,7 @@ export async function runUpload(
604
609
  const rec = window.__sootsimPreviewRecorder
605
610
  const workerDump = window.__sootsimDumpWorkerFetchRecorder
606
611
  const bundleUrl = ${JSON.stringify(snapshot.bundleUrl)}
612
+ await rec?.flush?.()
607
613
  const keep = (r) => {
608
614
  try {
609
615
  const u = new URL(r.url)
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/beta.ts
4
4
  var IS_BETA = true;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract-input.ts
4
4
  var BridgeInputError = class extends Error {
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var SEMANTIC_READY_CONTENT_NODE_FLOOR = 100;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/host/fetch-proxy-overrides.ts
4
4
  var FETCH_PROXY_BROWSER_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/menu.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/menu.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/public-brand.ts
4
4
  var name = "rnx";
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/metro-production-bundle.ts
4
4
  var RNXSIM_METRO_PRODUCTION_BUNDLE_OPTION = "rnxsim";
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/react-native-host-modules.ts
4
4
  var REACT_NATIVE_PASSTHROUGH_SPECIFIERS = [
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
package/dist-lib/sdk.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/sdk.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  var __defProp = Object.defineProperty;
3
3
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
4
4
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1787,8 +1787,8 @@ var init_registry = __esm({
1787
1787
  coverageSource: "estimated",
1788
1788
  capabilityCoverage: CAPABILITY_INVENTORY_PACKAGE_COVERAGE["react-native"],
1789
1789
  note: "React Native 0.86 root surface with useful core rendering, input, scrolling, animation, list, platform, and native-module behavior",
1790
- working: "all 99 React Native 0.86.2 public runtime root export names; flex and absolute layout, text shaping and wrapping, image loading and resize modes, touch and responder routing, controlled text input and keyboard presentation, vertical and horizontal scrolling with paging/snapping/sticky and maintained content, windowed/inverted/horizontal lists, common Animated graphs and composition, alerts and action sheets, live platform/dimension/appearance state, and explicit native-module lookup",
1791
- missing: "core LayoutAnimation create/update/delete geometry, arbitrary app-specific NativeModules and complete core module methods, exact automatic scroll insets and nested-scroll edge behavior, advanced TextInput selection/autofill, uncommon platform lifecycle and accessibility events, complete UIManager/system/dev-tool semantics, LogBox and Systrace behavior, and native virtual-collection visibility modes"
1790
+ working: "all 99 React Native 0.86.2 public runtime root export names; flex and absolute layout, text shaping and wrapping, image loading and resize modes, touch and responder routing, controlled text input and keyboard presentation, vertical and horizontal scrolling with paging/snapping/sticky and maintained content, windowed/inverted/horizontal lists, common Animated graphs and composition, core LayoutAnimation create/update/delete geometry, alerts and action sheets, live platform/dimension/appearance state, and explicit native-module lookup",
1791
+ missing: "arbitrary app-specific NativeModules and complete core module methods, exact automatic scroll insets and nested-scroll edge behavior, advanced TextInput selection/autofill, uncommon platform lifecycle and accessibility events, complete UIManager/system/dev-tool semantics, LogBox and Systrace behavior, and native virtual-collection visibility modes"
1792
1792
  }
1793
1793
  ]
1794
1794
  },
@@ -1843,8 +1843,8 @@ var init_registry = __esm({
1843
1843
  coverageSource: "estimated",
1844
1844
  capabilityCoverage: CAPABILITY_INVENTORY_PACKAGE_COVERAGE["react-native-screens"],
1845
1845
  note: "screen container, stack, header config, search bar, navigation props",
1846
- working: "Screen, ScreenContainer, ScreenStack, enableFreeze/freezeEnabled with delayed React freeze, Android modal/pageSheet push fallback, Android statusBarHidden/statusBarStyle/navigationBarHidden traits, push/pop slide, zoom/fade/fade_from_bottom transitions, edge swipe-back, parallax behind-screen, large title collapse, header (back button, inline/large title, custom left/center/right/search bar subviews, native header bar buttons/menus/badges, crossfade), formSheet custom initial detents/corner radius/grabber/undimmed range and stacked sheets, useHeaderHeight, useTransitionProgress, FullWindowOverlay, SearchBar, Tabs.Host/Screen, lifecycle callbacks (onAppear/onDisappear/onWillAppear/onWillDisappear), onDismissed",
1847
- missing: "replaceAnimation differentiation, interactive drag between formSheet detents, integrated search bar placements"
1846
+ working: "Screen, ScreenContainer, ScreenStack, enableFreeze/freezeEnabled with delayed React freeze, Android modal/pageSheet push fallback, Android statusBarHidden/statusBarStyle/navigationBarHidden traits, push/pop slide, replaceAnimation push/pop direction, zoom/fade/fade_from_bottom transitions, edge swipe-back, parallax behind-screen, large title collapse, header (back button, inline/large title, custom left/center/right/search bar subviews, native header bar buttons/menus/badges, crossfade), formSheet custom initial detents/corner radius/grabber/undimmed range and stacked sheets, useHeaderHeight, useTransitionProgress, FullWindowOverlay, SearchBar, Tabs.Host/Screen, lifecycle callbacks (onAppear/onDisappear/onWillAppear/onWillDisappear), onDismissed",
1847
+ missing: "interactive drag between formSheet detents, integrated search bar placements"
1848
1848
  }
1849
1849
  ]
1850
1850
  },
@@ -1990,8 +1990,8 @@ var init_registry = __esm({
1990
1990
  coverageSource: "estimated",
1991
1991
  capabilityCoverage: CAPABILITY_INVENTORY_PACKAGE_COVERAGE["react-native-keyboard-controller"],
1992
1992
  note: "native bindings stubbed (KeyboardControllerNative, KeyboardEvents, FocusedInputEvents, WindowDimensionsEvents, KeyboardControllerView + sibling native views); upstream pure-JS components, hooks, animated module, and KeyboardAvoidingView resolve from node_modules unchanged",
1993
- working: "every upstream JS export \u2014 KeyboardProvider, KeyboardAvoidingView, KeyboardStickyView, KeyboardAwareScrollView, KeyboardToolbar, KeyboardGestureArea, useReanimatedKeyboardAnimation, useKeyboardAnimation, useKeyboardHandler, useGenericKeyboardHandler, useKeyboardController, useKeyboardState, useKeyboardContext, useReanimatedFocusedInput, useFocusedInputHandler, useResizeMode, useWindowDimensions, KeyboardEvents, FocusedInputEvents, WindowDimensionsEvents resize events, KeyboardController (dismiss/setFocusTo/isVisible/state/preload/setInputMode/setDefaultMode), KeyboardControllerView, OverKeyboardView, KeyboardBackgroundView, KeyboardExtender, ClippingScrollView, KeyboardToolbarGroupView, AndroidSoftInputModes \u2014 all run upstream code through reanimated worklets",
1994
- missing: "preload is a noop; KeyboardGestureArea, OverKeyboardView, KeyboardBackgroundView, and KeyboardExtender are passthrough views without their native platform effects"
1993
+ working: "every upstream JS export runs through reanimated worklets; Android KeyboardGestureArea drives the existing keyboard owner through drag progress, velocity/distance settling, cancellation, focus retention, and cleanup; supported exports include KeyboardProvider, KeyboardAvoidingView, KeyboardStickyView, KeyboardAwareScrollView, KeyboardToolbar, KeyboardGestureArea, useReanimatedKeyboardAnimation, useKeyboardAnimation, useKeyboardHandler, useGenericKeyboardHandler, useKeyboardController, useKeyboardState, useKeyboardContext, useReanimatedFocusedInput, useFocusedInputHandler, useResizeMode, useWindowDimensions, KeyboardEvents, FocusedInputEvents, WindowDimensionsEvents resize events, KeyboardController (dismiss/setFocusTo/isVisible/state/preload/setInputMode/setDefaultMode), KeyboardControllerView, OverKeyboardView, KeyboardBackgroundView, KeyboardExtender, ClippingScrollView, KeyboardToolbarGroupView, and AndroidSoftInputModes",
1994
+ missing: "preload is a noop; KeyboardGestureArea lacks its iOS textInputNativeID/offset effect; OverKeyboardView, KeyboardBackgroundView, and KeyboardExtender are passthrough views without their native platform effects"
1995
1995
  }
1996
1996
  ]
1997
1997
  },
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.378 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.380 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rnxsim",
3
- "version": "0.1.378",
3
+ "version": "0.1.380",
4
4
  "description": "rnx CLI, Vite and Metro plugins, and agent skills for driving React Native apps in the browser.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -170,6 +170,43 @@ the report and how to read it:
170
170
  three runs minimum; single-run p95 is noise. a fresh `perf start` between
171
171
  runs clears the buffer.
172
172
 
173
+ ### browser-site interaction matrices
174
+
175
+ for a browser-level regression, use three fresh documents per browser engine.
176
+ within each document, capture cold then warm versions of the same interaction;
177
+ do not call two independent fresh documents "cold versus warm." wait for the
178
+ home surface's first compositor paint before starting—the compositor-ready flag
179
+ only proves worker bootstrap and can precede the first visible layout.
180
+
181
+ during a measured transition, use a fixed observation window. do not poll the
182
+ shell state, test tree, or perf bridge while the animation is running; verify
183
+ the positive control once after profiling stops. query a targeted id or text on
184
+ large virtualized apps, never the full tree, because enumeration can dominate
185
+ or terminate the browser process. for a remotely populated list, require the
186
+ same scroll-container identity and content extent for a quiet window before the
187
+ gesture; "a row exists" can still be true while the app replaces the tree.
188
+ keep pointer down, moves, release, and the fixed settle window inside one
189
+ uninterrupted profile. stopping or querying between drag and release changes
190
+ velocity, cache state, and worker scheduling.
191
+
192
+ the minimum browser matrix is home drag plus snapback, a cold/warm built-in app
193
+ open, and a cold/warm app-switcher open. add one quiet heavy-app screen, one
194
+ navigation transition, and one long-list scroll when those surfaces are in
195
+ scope. use GPU-backed Chrome for Chromium claims. use installed WebKit in a
196
+ visible nonpersistent WKWebView for Safari-engine claims; Playwright WebKit is
197
+ not the installed Safari engine.
198
+
199
+ every receipt must carry the exact page and frame URLs, browser user agent,
200
+ harness-source identity, capture time, engine build identity and generation,
201
+ runtime bundle receipt, system memory, runtime errors, and any partial failure.
202
+ partial artifacts remain inadmissible until every requested run number for that
203
+ scenario exists with one harness, engine, seed, and viewport identity. browser
204
+ comparisons use the same css viewport and backing scale; finite but different
205
+ device pixel ratios are not comparable.
206
+ the host-clock `displayFrames`/`timerFrames` and shell-link
207
+ `displayPumps`/`timerPumps` counters prove whether the visible-rAF fallback
208
+ engaged. preserve failed receipts instead of retrying them out of the data set.
209
+
173
210
  ### confirm which build you measured, every time
174
211
 
175
212
  Against a dev-source sim, the FIRST reload after an engine edit serves the
@@ -196,11 +233,31 @@ rnx open 8089 --new --driver playwright --cdp-port 9222 # cpu-profile a driveabl
196
233
  rnx debug enable layout,render && rnx debug recent layout 40
197
234
  ```
198
235
 
236
+ `rnx perf cpu` writes one profile for the page and one for every attached
237
+ worker, plus a manifest that labels shell, compositor, tenant, worklet, and
238
+ unknown workers. exit zero requires page, shell, compositor, and tenant plus
239
+ every other discovered target, with each covering at least 99% of the requested
240
+ window. inspect start/stop offsets as well as sample counts; a late-attached
241
+ near-empty profile is incomplete. a profile of only the tenant—or a helper
242
+ that relabels the busiest worker as tenant—cannot distinguish CPU saturation
243
+ from sparse browser scheduling.
244
+
245
+ for an automated interaction, pass `--interaction-barrier` and start the cpu
246
+ command during the runner's pre-workload attachment delay, and give the runner
247
+ a post-workload delay long enough to remain open through profiler stop. the
248
+ pre-workload delay alone is not synchronization: it can end the profile while
249
+ the workload is still preparing. barrier mode pauses the workload before its
250
+ first measured capture until every discovered target is profiling, then rejects
251
+ the manifest unless a recorded action timestamp falls inside the capture
252
+ interval. the barrier also has a bounded page-side lease so an interrupted
253
+ profiler cannot strand the workload; lease expiry releases the runner but
254
+ invalidates the profile.
255
+
199
256
  ### the cadence block: frames that never happened
200
257
 
201
258
  work-per-painted-frame cannot see a frame that was never delivered — host
202
- rAF starved, shell worker descheduled, vsync message stuck behind a busy
203
- queue. the report shows low avg and zero jank while the user sees stutter.
259
+ rAF starved, shell worker descheduled, or the compositor's shared-frame wait
260
+ resumed late. the report shows low avg and zero jank while the user sees stutter.
204
261
  the `cadence` section of `perf shell stop` measures the delivery side and
205
262
  attributes a missing frame to its hop:
206
263
 
@@ -214,6 +271,14 @@ attributes a missing frame to its hop:
214
271
  - `idle breaks` — gaps >250ms, excluded from all three: demand-gated
215
272
  quiesce is legitimate, not starvation.
216
273
 
274
+ the compositor `compositorHostFrameClock` separates display/timer ticks published into
275
+ its shared latest-frame cell from ticks consumed during the acknowledged worker
276
+ profile window. published timer ticks prove the renderer fallback won; sparse consumed ticks
277
+ with low production time identifies worker scheduling starvation. many consumed
278
+ ticks with sparse paints can be legitimate dirty-gating; correlate the frame
279
+ series before treating it as a regression. shell-owned captures expose the
280
+ corresponding `note.frameLink.sharedFrameClock` counters.
281
+
217
282
  compare baseline vs contended captures of the same scripted window; also
218
283
  compare **painted-frame counts**, the crudest and most robust signal.
219
284
 
@@ -276,6 +341,7 @@ which tier *should* absorb your cost and prove whether it does.
276
341
  | damage rects (`damage-rect.ts`) | repaint clips to changed region (opaque-backdrop gated) | worst-frame render bounded during small updates |
277
342
  | raster tier | stable rows become GPU textures; scroll skips recording | compositor block: promotions > 0 once warm, blits ≈ one per visible row (uniswap token list: ~13.7/frame) |
278
343
  | flood guard, pointer coalescing, vsync hysteresis | message-storm and pump-thrash protection | msgs/s sane during gestures and at rest |
344
+ | resilient renderer-main frame clock | visible rAF holes do not stall pointer coalescing or worker vsync | compositor `published` / `consumed` display and timer frames; shell `displayPumps` / `timerPumps` |
279
345
 
280
346
  there is no whole-screen scroll blit. one existed for a few hours in June
281
347
  2026 and was reverted (`6706f3a014`): it shifted a surface RECTANGLE, so a