Skip to content

fix(web-shell): prevent React measure detail OOM - #7596

Merged
ytahdn merged 1 commit into
QwenLM:mainfrom
chiga0:codex/fix-web-shell-react-measure-oom
Jul 23, 2026
Merged

fix(web-shell): prevent React measure detail OOM#7596
ytahdn merged 1 commit into
QwenLM:mainfrom
chiga0:codex/fix-web-shell-react-measure-oom

Conversation

@ytahdn

@ytahdn ytahdn commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This change prevents React development instrumentation from structured-cloning and retaining Web Shell component property details while preserving component timing measurements. Non-React performance measurements and their details continue to pass through unchanged. Regression coverage verifies both behaviors.

Why it's needed

In local daemon development, React records component render metadata through the browser Performance API. Large transcript properties can be cloned and retained for hundreds of component measures, causing renderer heap growth and intermittent Chrome Aw, Snap! crashes with error code 5. Catching only clone exceptions does not address successful clones or allocation failures.

Reviewer Test Plan

How to verify

Start Web Shell with npm run dev:daemon, open a transcript, and confirm the page remains responsive while React component performance entries contain null detail. Create a custom non-React performance measure and confirm its detail remains intact. The focused regression tests should confirm both forwarding behaviors.

Evidence (Before & After)

Before: React component measures retained structured-cloned transcript property details, and local Chrome renderer crash dumps showed the Web Shell development origin during intermittent crashes.

After: In an isolated daemon development run, the Web Shell root rendered successfully and remained stable for 20 seconds with no page crash, console error, page error, or failed request. All 803 observed React component measures had null detail, while a custom measure preserved its detail.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local npm run dev:daemon with an isolated Chrome profile, plus focused unit tests, full build, typecheck, and Web Shell lint.

Risk & Scope

  • Main risk or tradeoff: React component performance entries retain timing but no longer expose development-only component property details.
  • Not validated / out of scope: Windows and Linux browser behavior; unrelated renderer memory consumers.
  • Breaking changes / migration notes: None.

Linked Issues

No linked issue.

中文说明

本 PR 做了什么

此变更阻止 React 开发模式的性能检测逻辑对 Web Shell 组件属性详情执行结构化克隆并长期保留,同时保留组件计时数据。非 React 的性能测量及其详情仍按原样传递。回归测试覆盖了这两种行为。

为什么需要此变更

在本地 daemon 开发模式中,React 会通过浏览器 Performance API 记录组件渲染元数据。大型会话记录属性可能被数百个组件测量条目反复克隆并保留,导致渲染进程堆内存持续增长,并偶发出现 Chrome Aw, Snap!、错误码 5 的页面崩溃。仅捕获克隆异常无法处理成功完成的克隆或内存分配失败。

Reviewer Test Plan

如何验证

通过 npm run dev:daemon 启动 Web Shell,打开一个会话记录,确认页面保持响应,并且 React 组件性能条目的 detailnull。创建一个自定义的非 React 性能测量,确认其详情仍被保留。专项回归测试应同时验证这两种转发行为。

证据(修改前与修改后)

修改前:React 组件测量会保留经过结构化克隆的会话属性详情,本地 Chrome 渲染进程崩溃转储显示偶发崩溃时加载了 Web Shell 开发地址。

修改后:在隔离的 daemon 开发运行中,Web Shell 根节点成功渲染并稳定运行 20 秒,没有页面崩溃、控制台错误、页面错误或请求失败。观察到的 803 个 React 组件测量条目的详情全部为 null,同时自定义测量仍保留其详情。

测试平台

操作系统 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

本地 npm run dev:daemon、隔离的 Chrome 配置,以及专项单元测试、完整构建、类型检查和 Web Shell lint。

风险与范围

  • 主要风险或权衡:React 组件性能条目仍保留计时数据,但不再公开仅供开发使用的组件属性详情。
  • 未验证或不在范围内:Windows 和 Linux 浏览器行为;其他无关的渲染进程内存消耗因素。
  • 破坏性变更或迁移说明:无。

关联 Issue

无关联 Issue。

@ytahdn

ytahdn commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

E2E test report

  • Environment: macOS, npm run dev:daemon, isolated Chrome profile
  • Result: Web Shell returned HTTP 200, rendered one [data-web-shell-root], and remained stable for 20 seconds
  • Browser signals: no page crash, console error, page error, or failed request
  • Performance entries: 803/803 React component measures had detail: null
  • Compatibility check: a custom non-React performance measure preserved its original detail
  • Automated verification: focused regression tests passed (2/2); full build, typecheck, and Web Shell lint passed

Windows and Linux browser behavior were not tested.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: Observed bug with evidence. The PR describes Chrome Aw, Snap! crashes (error code 5) during local daemon development, caused by React 19 dev-mode logComponentRender() structured-cloning large transcript props into hundreds of performance measure entries. Before/after evidence is provided (crash dumps before, 20-second stable run with 803 null-detail measures after). No linked issue, but the reproduction is described concretely.

Direction: Aligned. This is a dev-mode memory stability fix for Web Shell — clearly within scope. The existing try/catch approach only handles clone failures after they happen; successful clones still retain memory and cause heap growth. Stripping the detail proactively is the right layer to fix this.

Size: Not applicable — packages/web-shell/client/ is not a core module path. 88 total lines across 2 files (15 production lines changed in index.html, 62 test lines added).

Approach: The scope feels right — one targeted change and one focused test. The devtools.track === 'Components ⚛' check correctly identifies React's component render measures without affecting custom or non-React performance entries. No unrelated changes or scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 已观测到的 bug,有证据。PR 描述了本地 daemon 开发中 Chrome Aw, Snap! 崩溃(错误码 5),原因是 React 19 开发模式的 logComponentRender() 将大型会话属性结构化克隆到数百个性能测量条目中。提供了修改前后证据(修改前有崩溃转储,修改后稳定运行 20 秒、803 个 null-detail 测量)。无关联 issue,但复现描述具体。

方向: 对齐。这是 Web Shell 开发模式的内存稳定性修复,明确在范围内。现有的 try/catch 只处理克隆失败后的情况;成功的克隆仍会保留内存导致堆增长。主动剥离 detail 是正确的修复层级。

规模: 不适用——packages/web-shell/client/ 不是核心模块路径。2 个文件共 88 行(index.html 中 15 行生产代码变更,62 行测试代码新增)。

方案: 范围合理——一个针对性修改和一个聚焦测试。devtools.track === 'Components ⚛' 检查正确识别 React 组件渲染测量,不影响自定义或非 React 性能条目。无无关改动或范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 13443fa08344a6cf3911ed1ca209be252fd44cfa · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: I'd monkey-patch performance.measure() to detect React component render measures via detail.devtools.track === 'Components ⚛', strip the detail before forwarding to the native call, and let non-React measures pass through unchanged. One focused test covering both paths.

Comparison: The PR matches this approach exactly. Clean implementation, no surprises.

Findings: No critical blockers or convention violations.

  • The devtools.track === 'Components ⚛' check correctly identifies React 19's logComponentRender() measures without affecting custom or non-React performance entries.
  • The guard handles all performance.measure() overloads safely — string arguments (start/end marks) fail the typeof options === 'object' check and pass through untouched.
  • Object.assign({}, options, { detail: null }) preserves start/end timing while nulling the problematic detail. The shallow copy avoids mutating the caller's options object.
  • The old try/catch only caught DataCloneError after the clone already happened (and missed successful clones that still retained memory). The new approach prevents the clone entirely — strictly better.
  • Test coverage is solid: extracts the inline script from index.html, evaluates it with a mock performance, and verifies both the React-detail-stripping and non-React-passthrough paths.

Real-Scenario Testing

tmux is not available in this CI environment, and this is a browser-specific fix (React Performance API in Chrome dev mode) — CLI tmux testing wouldn't add meaningful signal beyond what the unit tests and daemon smoke test provide. Here's what I verified:

Unit tests (2/2 pass):

 ✓ index-html.test.ts (2 tests) 4ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Duration  244ms

Build + typecheck: Both pass cleanly.

Daemon smoke test — started npm run dev:daemon, curled the Vite dev server, confirmed the patched script is served:

$ curl -s http://localhost:5173/ | grep -B2 -A 15 'performance.measure'

    <!--
      Prevent React 19 dev-mode OOM crashes. React records component prop
      details through performance.measure(), which structured-clones and retains
      those details. Strip the detail before large transcripts reach the native
      call while preserving the component timing and non-React measures.
    -->
    <script>
      !(function () {
        if (typeof performance === 'undefined' || !performance.measure) return;
        var m = performance.measure.bind(performance);
        performance.measure = function () {
          var options = arguments[1];
          var devtools =
            options &&
            typeof options === 'object' &&
            options.detail &&
            options.detail.devtools;
          if (devtools && devtools.track === 'Components ⚛') {
            var args = Array.prototype.slice.call(arguments);
            args[1] = Object.assign({}, options, { detail: null });
            return m.apply(this, args);
          }
          return m.apply(this, arguments);
        };
      })();
    </script>

The patched guard is live in the served HTML. The author's before/after evidence (803 React measures with null detail, 20-second stable run, no crashes) is consistent with what this code does.

中文说明

代码审查

独立方案: 我会 monkey-patch performance.measure(),通过 detail.devtools.track === 'Components ⚛' 检测 React 组件渲染测量,在转发给原生调用前剥离 detail,非 React 测量原样传递。一个聚焦测试覆盖两条路径。

对比: PR 与此方案完全一致。实现干净,没有意外。

发现: 无关键阻塞项或规范违反。

  • devtools.track === 'Components ⚛' 检查正确识别 React 19 的 logComponentRender() 测量,不影响自定义或非 React 性能条目。
  • 守卫安全处理所有 performance.measure() 重载——字符串参数(start/end mark)不通过 typeof options === 'object' 检查,原样传递。
  • Object.assign({}, options, { detail: null }) 保留 start/end 计时,同时将有问题的 detail 置空。浅拷贝避免修改调用方的 options 对象。
  • 旧的 try/catch 只在克隆已发生后捕获 DataCloneError(且遗漏了成功克隆仍保留内存的情况)。新方案完全阻止克隆——严格更优。
  • 测试覆盖扎实:从 index.html 提取内联脚本,用 mock performance 执行,验证 React-detail 剥离和非 React 透传两条路径。

真实场景测试

此 CI 环境无 tmux,且这是浏览器特定修复(Chrome 开发模式下的 React Performance API)——CLI tmux 测试不会比单元测试和 daemon 冒烟测试提供更多有意义的信号。以下是我验证的内容:

单元测试 2/2 通过,构建和类型检查均通过,daemon 冒烟测试确认修补后的脚本正确提供。作者的修改前后证据(803 个 null-detail React 测量、20 秒稳定运行、无崩溃)与此代码的行为一致。

Qwen Code · qwen3.8-max-preview

Reviewed at 13443fa08344a6cf3911ed1ca209be252fd44cfa · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — Clean across every stage; would merge without hesitation.

This is exactly what a good fix looks like. Small, focused, solves a real observed problem (Chrome renderer OOM from React dev-mode structured-cloning transcript props into hundreds of performance measures). The old try/catch was a band-aid that only caught clone failures after the fact — successful clones still retained memory. Stripping the detail proactively at the performance.measure() boundary is the right fix, and the Components ⚛ track check correctly scopes it to React's instrumentation without touching anything else.

Two files, 15 production lines, solid test covering both paths. No scope creep, no over-engineering. The author's before/after evidence (803 null-detail measures, stable 20-second run) matches what the code does. If I had to maintain this in six months, I'd thank the author.

中文说明

置信度:5/5 — 每个阶段都很干净,毫不犹豫可以合并。

这是一个优秀修复的典范。小而聚焦,解决了一个真实观测到的问题(Chrome 渲染进程因 React 开发模式将大型会话属性结构化克隆到数百个性能测量条目而 OOM)。旧的 try/catch 只是事后补救——成功的克隆仍会保留内存。在 performance.measure() 边界主动剥离 detail 是正确的修复方式,Components ⚛ track 检查正确地将其限定在 React 检测范围内,不影响其他内容。

两个文件,15 行生产代码,扎实的测试覆盖两条路径。无范围蔓延,无过度工程。作者的修改前后证据(803 个 null-detail 测量、稳定 20 秒运行)与代码行为一致。

Qwen Code · qwen3.8-max-preview

Reviewed at 13443fa08344a6cf3911ed1ca209be252fd44cfa · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 13443fa. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

No screenshot changes against the PR base.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

if (!script) throw new Error('Performance measure guard not found');

const performance = { measure };
Function('performance', 'DOMException', script)(performance, DOMException);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The DOMException parameter is no longer used by the new inline script — it was needed by the old try/catch approach that checked e instanceof DOMException, but the new proactive guard never references it. A future maintainer seeing DOMException injected here may incorrectly assume the script handles DataCloneError, risking regression of this fix. — Concrete cost: misleading test harness that suggests the script should handle clone errors.

Suggested change
Function('performance', 'DOMException', script)(performance, DOMException);
Function('performance', script)(performance);

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Review — fix(web-shell): prevent React measure detail OOM

Verdict: the fix is correct and the premise checks out. Two hardening suggestions before merge, no blockers.

I verified this in real Chromium against the real react-dom@19.2.4 development bundle (esbuild-bundled app that re-renders 5 components with large, changing props 300×), A/B-ing the inline guard from main vs. this PR vs. no guard at all.

guard Components ⚛ measures retaining detail detail retained in the user-timing buffer total measures
none 1105 55.7 MB 1156
main (try/catch) 1155 58.2 MB 1217
this PR 0 0.01 MB 1241

Two things this confirms:

  1. The PR's premise is right, and stronger than the description claims. On main the guard is a complete no-op for this failure mode — retention is identical to having no guard. The structured clone succeeds; it just never gets released, so catch (DataCloneError) never fires. This is exactly the "does not address successful clones" argument in the description, now with numbers.
  2. Timing is genuinely preserved. Measure count is unchanged (1241 vs. 1217); only detail is nulled. Lane-track measures (trackGroup: 'Scheduler ⚛') still keep their properties and cost ~10 KB across ~50 entries — correctly left alone.

I also confirmed the string literal is exactly Components + U+269B with no variation selector, matching COMPONENTS_TRACK = "Components ⚛" in react-dom-client.development.js; that <meta charset="UTF-8"> sits at line 4 so the emoji survives; that the daemon CSP uses 'unsafe-inline' rather than a script hash (packages/cli/src/serve/web-shell-static.ts:28), so editing the inline script can't get it blocked; and that the classic <script> still wins the race against the deferred module script.


1. The exact-string match has no fallback behind it — a React rename silently disables the guard

devtools.track === 'Components ⚛' is an exact match on a React-internal constant, and this PR simultaneously deletes the try/catch. That leaves a single point of failure with nothing behind it.

Demonstrated: I patched COMPONENTS_TRACK in the bundle to "Components ⚛️" (a plausible React-side change — adding the emoji variation selector) and re-ran:

pr-drift     measures=1146  retained=55.20MB   <- guard is a silent no-op, back to main's behaviour

Prefix-matching plus keeping the old catch as a backstop survives both. I ran this exact version through the browser matrix and through your two new unit tests:

performance.measure = function () {
  var options = arguments[1];
  var devtools =
    options &&
    typeof options === 'object' &&
    options.detail &&
    options.detail.devtools;
  // React's COMPONENTS_TRACK is 'Components ⚛'. Match by prefix so a
  // React-side rename degrades to "still stripped" rather than "silently off".
  var track = devtools && devtools.track;
  if (typeof track === 'string' && track.indexOf('Components') === 0) {
    var args = Array.prototype.slice.call(arguments);
    args[1] = Object.assign({}, options, { detail: null });
    return m.apply(this, args);
  }
  try {
    return m.apply(this, arguments);
  } catch (e) {
    if (e instanceof DOMException && e.name === 'DataCloneError') return;
    throw e;
  }
};
hard         retained=0.01MB  nonClonableMeasure=swallowed
hard-drift   retained=0.01MB  nonClonableMeasure=swallowed
index-html.test.ts  2 passed   <- your existing tests still pass unchanged

2. DataCloneError now propagates to the caller

Removing the catch is an observable behaviour change:

performance.measure('x', { start: 1, end: 2, detail: { fn: function () {} } });
// main: swallowed, returns undefined
// this PR: throws DOMException: DataCloneError

To be fair: I don't think this is reachable from React any more. Once the Components track is stripped, the only React measures still carrying detail are the lane tracks, whose properties are short strings (["Component name", …], ["Error", message]) built by addValueToProperties. So this is defense-in-depth, not a live bug — but it's ~6 lines, it was added for a reason, and under the drift scenario above it's the only thing left standing.

3. Test coverage gaps

The two new tests cover the stripped path and the plain non-React path. Not covered:

  • The detail.devtools present but non-Components track branch — i.e. lane/Scheduler measures. That's the only branch that still forwards detail untouched, and nothing pins it.

  • measure(name) and the legacy measure(name, startMark, endMark) form. typeof options === 'object' is what keeps the 3-arg form working (a string startMark must fall through); a refactor could drop it without failing CI.

  • A pin on React's constant. This is the highest-value addition given finding 1: a react-dom bump that renames COMPONENTS_TRACK would silently disable the guard with fully green CI. This version passes today inside packages/web-shell:

    it('matches react-dom’s COMPONENTS_TRACK constant', () => {
      const require = createRequire(import.meta.url);
      const src = readFileSync(
        join(dirname(require.resolve('react-dom')), 'cjs/react-dom-client.development.js'),
        'utf8',
      );
      const raw = src.match(/COMPONENTS_TRACK = "((?:[^"\\]|\\.)*)"/)?.[1];
      expect(raw).toBeDefined();
      expect(JSON.parse(`"${raw}"`)).toBe('Components ⚛');
    });
  • expect(options.detail.devtools.properties).toHaveLength(1) is doing real work and deserves its own named test plus a comment. React reuses a single module-level reusableComponentOptions / reusableComponentDevToolDetails object for every component measure (react-dom-client.development.js:25533-25543), so mutating the caller's options in place — rather than Object.assign-copying, as this PR correctly does — would corrupt every subsequent entry. Right now that invariant reads like an incidental assertion.

Nits

  • installMeasureGuard still threads DOMException into Function('performance', 'DOMException', script), but the patched script no longer references it. Dead today; becomes live again if the catch is restored.
  • expect(measure).toHaveBeenCalledWith('custom-measure', options) is deep-equality, so it passes even if the guard had copied the object. If the intent is "forwarded untouched", expect(measure.mock.calls[0]?.[1]).toBe(options) is the assertion that actually says that.
  • index-html.test.ts sits next to the existing index.test.tsx, which tests index.tsx. index.html.test.ts would read less ambiguously.
  • Optional: stripping unconditionally removes the "Changed Props" panel for every component in the Chrome performance profiler, not just the expensive ones. A devtools.properties.length > N threshold would keep that DX for small components. Defensible either way — in my run the average was ~50 KB per component measure, so unconditional is a reasonable call; just noting the DX cost lands on all dev profiling, not only large transcripts.

Checks run

  • vitest run index-html.test.ts at PR head — 2 passed.
  • Full packages/web-shell unit suite at PR head: 8 failing files, all of which reproduce identically on main (pre-existing, unrelated).
  • eslint packages/web-shell/client/index-html.test.ts — clean.
  • Prettier reports index.html as unformatted, but that is pre-existing on main (the deliberately minified theme-init script); this PR doesn't make it worse.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Clean proactive fix — strips React component detail before it reaches the native measure call, preventing structured-clone OOM on large transcripts. Original options object is not mutated. Test cleverly extracts and executes the inline script. No issues found.

— qwen3.8-max-preview via Qwen Code /review

@ytahdn
ytahdn added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit abc9666 Jul 23, 2026
73 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants