Skip to content

Fix freeze from useBoxMetrics layout listener recursion#966

Open
msfeldstein wants to merge 3 commits into
vadimdemedes:masterfrom
msfeldstein:fix-layout-listener-recursion
Open

Fix freeze from useBoxMetrics layout listener recursion#966
msfeldstein wants to merge 3 commits into
vadimdemedes:masterfrom
msfeldstein:fix-layout-listener-recursion

Conversation

@msfeldstein

@msfeldstein msfeldstein commented Jun 1, 2026

Copy link
Copy Markdown

Summary

  • We have been seeing freezes when you have a component that uses the results of useBoxMetrics to adjust layout. We fixed them at the app level, but it shouldn't be so easy to have a react component completely freeze the app
  • Defer layout listener callbacks out of React's resetAfterCommit stack so layout listeners (e.g. useBoxMetrics) can't synchronously schedule React state while React is still committing.
  • Coalesce pending listeners and skip any listener removed before the deferred flush.
  • Add a regression test plus a runnable example.

Problem

emitLayoutListeners runs from Ink's resetAfterCommit host callback. It invokes listeners synchronously, so a listener that schedules state (useBoxMetrics calls setMetrics/setHasMeasured) updates React mid-commit. When the rendered layout depends on the measured layout, each commit re-measures and re-commits, recursing until React trips its nested-update guard and throws Maximum update depth exceeded, freezing the CLI.

React commit
  -> resetAfterCommit(root)
  -> emitLayoutListeners(root)
       before: listener() immediately -> hook setState during commit -> nested update -> commit -> ... (recurses past React's limit)
       after:  queue listener for microtask -> commit finishes -> listener runs if still subscribed

Fix

Buffer listeners into a pending set and flush them once in a queueMicrotask after the commit unwinds. The flush re-checks subscription so a listener removed in the window between commit and flush (e.g. an unmounting component) does not fire.

Test plan

  • npx ava test/dom-layout-listeners.tsx — new regression test renders a layout-feedback box and asserts render does not throw. Verified it fails without the fix (Maximum update depth exceeded) and passes with it.
  • npx ava test/use-box-metrics.tsx
  • npx tsc --noEmit
  • npx prettier --check
  • Manual repro: node --import=tsx examples/layout-listener-recursion/index.ts crashes without the fix, converges with it.

Made with Cursor

msfeldstein and others added 3 commits June 1, 2026 10:38
emitLayoutListeners runs from React's resetAfterCommit host callback.
Invoking listeners synchronously lets hooks (e.g. useBoxMetrics) schedule
React state while React is still committing, which can recurse until React
trips its nested update guard and freezes the CLI.

Coalesce pending listeners and flush them in a microtask after the commit
finishes, skipping any listener removed before the flush.

Co-authored-by: Cursor <cursoragent@cursor.com>
A useBoxMetrics-driven box whose rendered height depends on its measured
height creates a layout feedback loop. Without deferring layout listener
callbacks, this recurses synchronously inside React's commit and crashes
with "Maximum update depth exceeded"; with the fix it converges.

Co-authored-by: Cursor <cursoragent@cursor.com>
The previous "runs after commit stack" test only pinned the microtask
mechanism, not the property we care about. Replace it with an integration
test that renders a layout-feedback component and asserts render does not
throw "Maximum update depth exceeded" — this fails without the fix and is
agnostic to how the deferral is implemented. Keep the removed-listener test,
which guards the stale-listener edge case the deferral introduces.

Co-authored-by: Cursor <cursoragent@cursor.com>
@msfeldstein msfeldstein force-pushed the fix-layout-listener-recursion branch from 5dd5884 to 6ac2c9e Compare June 1, 2026 17:39
@sindresorhus

Copy link
Copy Markdown
Collaborator

I don't think this is fixed yet.

  • The existing useBoxMetrics sibling-content behavior regresses for me on this branch. npx ava test/use-box-metrics.tsx -m "updates when sibling content changes" fails consistently on the PR branch and passes on master. Since useBoxMetrics is supposed to update when sibling/content changes, deferring every emitLayoutListeners call looks like it can delay or miss a supported update. Maybe only the commit-time path should be deferred, or the hook needs another way to keep this update synchronous enough for the existing contract.
  • The new regression test is not catching the warning. Running node --import=tsx examples/layout-listener-recursion/index.ts still prints Maximum update depth exceeded, even though the new AVA test passes. So this avoids the hard crash, but it still hits React's nested-update guard. The test should fail on that warning, otherwise teh repro can regress silently.

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.

2 participants