Skip to content

Commit 65b3150

Browse files
committed
fix: bound sibling identity work per React commit
1 parent 5e26672 commit 65b3150

5 files changed

Lines changed: 305 additions & 0 deletions

File tree

.changeset/calm-cameras-count.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"genie-react": patch
3+
---
4+
5+
Reduce synchronous render-analysis overhead for wide component lists by reusing bounded sibling identity scans within each commit. Preserve key uniqueness, reorder handling, deadlines, and incomplete coverage reporting.

docs/runtime-overhead.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Runtime instrumentation overhead
2+
3+
Genie observes React commits synchronously. Active render analysis adds work to the app's main thread, even when no agent is querying it. Pausing a profile stops detailed analysis while keeping the hook and liveness bookkeeping installed. Profiling is not a zero-overhead measurement of an uninstrumented app.
4+
5+
## Verified wide-list regression
6+
7+
The instance identity collector previously recovered sibling position and key uniqueness by scanning the same sibling chain several times for every rendered component. An updating keyed list therefore incurred quadratic sibling reads. A deterministic regression fixture of 100 rows observed 29,900 sibling reads before the fix.
8+
9+
Sibling positions and key counts are now indexed once per commit work budget. The index is discarded with that budget, so later reorders and duplicate keys are re-evaluated. Incomplete scans cannot prove key uniqueness. Existing operation limits, deadlines, Fiber limits, and conservative coverage reporting still apply; reports outside a commit continue to inspect live structure.
10+
11+
## Reproduce in Chromium
12+
13+
```sh
14+
pnpm install --frozen-lockfile
15+
pnpm exec playwright install chromium
16+
node scripts/benchmark-runtime-overhead.mjs > overhead.json
17+
```
18+
19+
The script starts an isolated Vite fixture and closes its browser/server afterward. It uses the installed workspace React development build and the current Genie source. Five hundred keyed sibling components update synchronously, each producing one span. Every mode receives 20 warmup updates and 60 measured updates on each of three fresh pages, with mode order varied between rounds. The script verifies row count and rendered values and records raw samples, coverage omissions, collection mode, budget settings, and Chromium version.
20+
21+
Modes are **disabled** (no Genie hook), **active** (Genie's hook and default render analysis), and **paused** (hook installed, detailed render collection paused). This isolates the commit instrumentation; it does not include connected hub traffic, plugin collectors, source transforms, or report queries. Durations cover synchronous React rendering and commit instrumentation, not paint or end-to-end input latency. Do not treat this synthetic workload as the unidentified reporter's app or as a production/native benchmark.
22+
23+
A local run on September 5, 2026, using Chromium 149.0.7827.55 produced:
24+
25+
| Mode | Before median / p95 | Fixed median / p95 |
26+
| --- | --- | --- |
27+
| Genie disabled | 1.8 / 2.3 ms | 1.8 / 2.1 ms |
28+
| Active analysis | 8.7 / 9.5 ms | 3.9 / 4.7 ms |
29+
| Paused analysis | 2.0 / 2.2 ms | 1.9 / 2.0 ms |
30+
31+
Active median update time decreased about 55% in this fixture, while remaining above the uninstrumented baseline. Skipped Fiber analyses across each page's 80 updates decreased from 34,269 to 20,080. Both runs used the same default configuration. Before the fix, operation exhaustion raised the adaptive scale to 4 (a 1,000-Fiber allowance), yet only about 73 Fibers per commit could be analyzed. Afterward, scale stayed at 1 and 250 Fibers per commit were analyzed. The remaining omissions reflect that default 250-Fiber limit; these numbers measure bounded partial collection, not analysis of all 500 rows. This fix does not trade reduced evidence coverage for speed. Timing results vary by machine, browser, thermal state, and workload; the unit test enforces the linear sibling-read property rather than a flaky millisecond threshold.
32+
33+
## Regression risks and evidence
34+
35+
| Risk | Protected behavior | Validation |
36+
| --- | --- | --- |
37+
| Repeated sibling scans | All row positions and unique keys resolve with linear sibling reads | Deterministic 100-row test failed before fix and passes afterward |
38+
| Stale index across commits | Reorders change position, duplicate keys remove keyed identity, physical mount stays stable | Successive-budget reorder/key-change test |
39+
| Scan truncation | An unseen sibling cannot be assumed to have a different key | 2,001-row bound and exhausted-operation tests |
40+
| Deadline bypass on cache hit | Cached evidence does not bypass the shared time budget | Controlled monotonic-clock test |
41+
42+
The reporter's exact environment remains unverified because no app, workload, or trace was supplied. This reproduction establishes a concrete Genie overhead defect and validates its improvement; it does not establish that all reported slowdowns share this cause.

packages/genie-react/src/collectors/react/instance-identity.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type Fiber, getFiberId } from 'bippy'
22
import { beforeEach, describe, expect, it } from 'vitest'
3+
import { createCommitWorkBudget } from './commit-budget'
34
import {
45
beginInstanceObservation,
56
clearInstanceIdentityForTests,
@@ -262,3 +263,96 @@ describe('React component instance identity', () => {
262263
expect(last?.mountGenerationEvidence).toBe('unknown')
263264
})
264265
})
266+
267+
describe('commit-scoped sibling identity work', () => {
268+
const work = () => createCommitWorkBudget({ operationLimit: 1_000_000, now: () => 0 })
269+
270+
it('identifies a wide keyed list with linear app-owned sibling reads', () => {
271+
const parent = component('Rows')
272+
const rows = Array.from({ length: 100 }, (_, index) => component('Row', String(index)))
273+
attach(parent, rows)
274+
let siblingReads = 0
275+
rows.forEach((row, index) => {
276+
Object.defineProperty(row, 'sibling', {
277+
get() {
278+
siblingReads += 1
279+
return rows[index + 1] ?? null
280+
},
281+
})
282+
})
283+
const budget = work()
284+
285+
rows.forEach((row, index) => {
286+
expect(noteInstanceRender(row, 'mount', 1, 1, budget)).toMatchObject({
287+
siblingIndex: index,
288+
key: String(index),
289+
logicalIdentityEvidence: 'keyed',
290+
})
291+
})
292+
293+
// Recovering every position and key must not re-read the list for every row.
294+
expect(siblingReads).toBeLessThanOrEqual(rows.length * 2)
295+
})
296+
297+
it('recomputes positions and key uniqueness after the next commit reorders siblings', () => {
298+
const parent = component('Rows')
299+
const a = component('Row', 'a')
300+
const b = component('Row', 'b')
301+
attach(parent, [a, b])
302+
const mounted = noteInstanceRender(a, 'mount', 1, 1, work())
303+
304+
attach(parent, [b, a])
305+
const reordered = noteInstanceRender(a, 'update', 2, 2, work())
306+
expect(reordered).toMatchObject({ siblingIndex: 1, logicalIdentityEvidence: 'keyed' })
307+
expect(reordered.mountId).toBe(mounted.mountId)
308+
expect(reordered.logicalPath).toBe(mounted.logicalPath)
309+
310+
;(b as { key: string }).key = 'a'
311+
const duplicated = noteInstanceRender(a, 'update', 3, 3, work())
312+
expect(duplicated).toMatchObject({ siblingIndex: 1, logicalIdentityEvidence: 'positional' })
313+
expect(duplicated.logicalPath).toContain('Row[index=1]')
314+
})
315+
316+
it('does not let a cached sibling scan bypass an expired commit deadline', () => {
317+
const parent = component('Rows')
318+
const a = component('Row', 'a')
319+
const b = component('Row', 'b')
320+
attach(parent, [a, b])
321+
let now = 0
322+
const budget = createCommitWorkBudget({ now: () => now, timeLimitMs: 1 })
323+
expect(noteInstanceRender(a, 'mount', 1, 1, budget).logicalIdentityEvidence).toBe('keyed')
324+
325+
now = 1
326+
expect(noteInstanceRender(b, 'mount', 1, 1, budget)).toMatchObject({
327+
siblingIndex: null,
328+
logicalIdentityEvidence: 'unknown',
329+
})
330+
})
331+
332+
it('does not infer key uniqueness when the shared operation budget interrupts the scan', () => {
333+
const parent = component('Rows')
334+
const rows = Array.from({ length: 100 }, (_, index) => component('Row', String(index)))
335+
attach(parent, rows)
336+
const budget = createCommitWorkBudget({ now: () => 0, operationLimit: 10 })
337+
338+
expect(
339+
noteInstanceRender(rows[0] as Fiber, 'mount', 1, 1, budget).logicalIdentityEvidence,
340+
).toBe('unknown')
341+
expect(
342+
noteInstanceRender(rows[1] as Fiber, 'mount', 1, 1, budget).logicalIdentityEvidence,
343+
).toBe('unknown')
344+
})
345+
346+
it('does not infer key uniqueness from a bounded prefix of a larger sibling list', () => {
347+
const parent = component('Rows')
348+
const rows = Array.from({ length: 2_001 }, (_, index) => component('Row', String(index)))
349+
attach(parent, rows)
350+
const budget = work()
351+
352+
const first = noteInstanceRender(rows[0] as Fiber, 'mount', 1, 1, budget)
353+
const last = noteInstanceRender(rows[2_000] as Fiber, 'mount', 1, 1, budget)
354+
355+
expect(first).toMatchObject({ siblingIndex: 0, logicalIdentityEvidence: 'positional' })
356+
expect(last).toMatchObject({ siblingIndex: null, logicalIdentityEvidence: 'unknown' })
357+
})
358+
})

packages/genie-react/src/collectors/react/instance-identity.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,49 @@ function nearestKeyedComposite(fiber: Fiber | null, budget?: CommitWorkBudget):
416416
return null
417417
}
418418

419+
interface SiblingIdentityScan {
420+
indices: Map<Fiber, number>
421+
keyCounts: Map<string, number>
422+
complete: boolean
423+
}
424+
425+
// A work budget belongs to one synchronous commit; sibling order can change on the next one.
426+
const siblingScans = new WeakMap<CommitWorkBudget, WeakMap<Fiber, SiblingIdentityScan>>()
427+
428+
function commitSiblingScan(first: Fiber, budget: CommitWorkBudget): SiblingIdentityScan {
429+
let scans = siblingScans.get(budget)
430+
if (!scans) {
431+
scans = new WeakMap()
432+
siblingScans.set(budget, scans)
433+
}
434+
const cached = scans.get(first)
435+
if (cached) return cached
436+
437+
const scan: SiblingIdentityScan = { indices: new Map(), keyCounts: new Map(), complete: false }
438+
let current: Fiber | null = first
439+
let index = 0
440+
while (current && index < SIBLING_SCAN_LIMIT) {
441+
if (!consumeCommitWork(budget, 'instance-siblings')) break
442+
scan.indices.set(current, index)
443+
if (current.alternate) scan.indices.set(current.alternate, index)
444+
if (typeof current.key === 'string') {
445+
scan.keyCounts.set(current.key, (scan.keyCounts.get(current.key) ?? 0) + 1)
446+
}
447+
current = current.sibling
448+
index += 1
449+
}
450+
scan.complete = current === null
451+
scans.set(first, scan)
452+
return scan
453+
}
454+
419455
function indexAmongSiblings(fiber: Fiber, budget?: CommitWorkBudget): number | null {
420456
const first = fiber.return?.child
421457
if (!first) return fiber.return ? null : 0
458+
if (budget) {
459+
if (!consumeCommitWork(budget, 'instance-siblings')) return null
460+
return commitSiblingScan(first, budget).indices.get(fiber) ?? null
461+
}
422462
let current: Fiber | null = first
423463
let index = 0
424464
while (current && index < SIBLING_SCAN_LIMIT) {
@@ -434,6 +474,11 @@ function indexAmongSiblings(fiber: Fiber, budget?: CommitWorkBudget): number | n
434474
function isUniqueSiblingKey(fiber: Fiber, key: string, budget?: CommitWorkBudget): boolean {
435475
const first = fiber.return?.child
436476
if (!first) return false
477+
if (budget) {
478+
if (!consumeCommitWork(budget, 'instance-siblings')) return false
479+
const scan = commitSiblingScan(first, budget)
480+
return scan.complete && scan.keyCounts.get(key) === 1
481+
}
437482
let current: Fiber | null = first
438483
let matches = 0
439484
let scanned = 0
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env node
2+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3+
import { createRequire } from 'node:module'
4+
import { dirname, join, resolve } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { chromium } from 'playwright'
7+
8+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
9+
const runtimeRequire = createRequire(join(root, 'packages/genie-react/package.json'))
10+
const demoRequire = createRequire(join(root, 'apps/vite-demo/package.json'))
11+
const { createServer } = await import(runtimeRequire.resolve('vite'))
12+
const fixture = await mkdtemp(join(root, '.runtime-benchmark-'))
13+
let server
14+
let browser
15+
try {
16+
await writeFile(
17+
join(fixture, 'index.html'),
18+
'<div id="root"></div><script type="module" src="/main.js"></script>',
19+
)
20+
await writeFile(
21+
join(fixture, 'main.js'),
22+
`
23+
const mode = new URLSearchParams(location.search).get('mode');
24+
let tracker;
25+
if (mode !== 'disabled') {
26+
await import('/@fs/${root}/packages/genie-react/src/collectors/react/hook.ts');
27+
tracker = await import('/@fs/${root}/packages/genie-react/src/collectors/react/render-tracker.ts');
28+
}
29+
const React = await import('react');
30+
const { createRoot } = await import('react-dom/client');
31+
const { flushSync } = await import('react-dom');
32+
const count = 500;
33+
function Row({ value, index }) { return React.createElement('span', null, index + ':' + value); }
34+
function App({ value }) { return React.createElement('div', null, Array.from({ length: count }, (_, index) => React.createElement(Row, { key: index, index, value }))); }
35+
const root = createRoot(document.getElementById('root'));
36+
let value = 0;
37+
const update = () => flushSync(() => root.render(React.createElement(App, { value: ++value })));
38+
update();
39+
if (mode === 'paused') tracker.stopRenderTracking();
40+
window.runBenchmark = () => {
41+
tracker?.clearRenders();
42+
for (let i = 0; i < 20; i++) update();
43+
const samples = [];
44+
for (let i = 0; i < 60; i++) { const start = performance.now(); update(); samples.push(performance.now() - start); }
45+
return { samples, text: document.querySelector('span').textContent, expectedText: '0:' + value, rows: document.querySelectorAll('span').length, count, budget: tracker?.getRenderObservationConfig(), skippedFibers: tracker?.getSkippedCommitFiberCount(), commits: tracker?.getCommitCount() };
46+
};
47+
`,
48+
)
49+
server = await createServer({
50+
configFile: false,
51+
root: fixture,
52+
logLevel: 'error',
53+
resolve: {
54+
alias: [
55+
{ find: /^react$/, replacement: runtimeRequire.resolve('react') },
56+
{ find: /^react-dom\/client$/, replacement: demoRequire.resolve('react-dom/client') },
57+
{ find: /^react-dom$/, replacement: demoRequire.resolve('react-dom') },
58+
],
59+
},
60+
server: { host: '127.0.0.1', port: 0, fs: { allow: [root] } },
61+
})
62+
await server.listen()
63+
const address = server.httpServer.address()
64+
if (!address || typeof address === 'string') throw new Error('Missing Vite port')
65+
browser = await chromium.launch({ headless: true })
66+
const results = []
67+
// Alternate order to reduce one-directional warmup/thermal bias. Each run has a fresh document.
68+
for (const modes of [
69+
['disabled', 'active', 'paused'],
70+
['paused', 'active', 'disabled'],
71+
['active', 'disabled', 'paused'],
72+
]) {
73+
for (const mode of modes) {
74+
const page = await browser.newPage()
75+
const pageErrors = []
76+
page.on('pageerror', (error) => pageErrors.push(error))
77+
await page.goto(`http://127.0.0.1:${address.port}/?mode=${mode}`)
78+
await page.waitForFunction(() => typeof window.runBenchmark === 'function')
79+
const result = await page.evaluate(() => window.runBenchmark())
80+
if (pageErrors.length > 0) throw pageErrors[0]
81+
if (result.text !== result.expectedText || result.rows !== result.count)
82+
throw new Error('Fixture rendered incorrectly')
83+
results.push({ mode, ...result })
84+
await page.close()
85+
}
86+
}
87+
const percentile = (values, fraction) =>
88+
[...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1]
89+
const summary = ['disabled', 'active', 'paused'].map((mode) => {
90+
const runs = results.filter((result) => result.mode === mode)
91+
const samples = runs.flatMap((run) => run.samples)
92+
return {
93+
mode,
94+
medianMs: percentile(samples, 0.5),
95+
p95Ms: percentile(samples, 0.95),
96+
runs: runs.map(({ samples, ...run }) => ({ ...run, medianMs: percentile(samples, 0.5) })),
97+
}
98+
})
99+
console.log(
100+
JSON.stringify(
101+
{
102+
fixture:
103+
'React development build, 500 keyed sibling rows; 20 warmup + 60 synchronous updates × 3 fresh documents per mode',
104+
browser: browser.version(),
105+
react: runtimeRequire('react/package.json').version,
106+
platform: process.platform,
107+
architecture: process.arch,
108+
summary,
109+
results,
110+
},
111+
null,
112+
2,
113+
),
114+
)
115+
} finally {
116+
await browser?.close()
117+
await server?.close()
118+
await rm(fixture, { recursive: true, force: true })
119+
}

0 commit comments

Comments
 (0)