The Editor Froze. The CPU Was Idle.

August 31, 2026 (3d ago)

This one is recent — July 2026, with @radix-ui/react-dropdown-menu 2.1.16. Library internals evolve; the mechanism below was accurate for that version.

Same AI video editor as my Safari story, different kind of nightmare.

The bug report: "Adding a sound effect freezes the editor."

You add a sound effect from the toolbar picker, and the whole page stops responding. No clicks work — not the canvas, not the timeline, not the menus. The only way out is a reload.

The trap

"The editor freezes" pattern-matches instantly to a diagnosis: render loop. Some state update triggering itself, React re-rendering forever, main thread pinned. I've fixed those before. So I opened the profiler expecting to find a flame chart on fire.

Instead:

The app wasn't busy. It was ignoring us. And that distinction matters enormously, because everything you'd normally do for a "freeze" — profile the main thread, hunt re-render cascades, bisect state updates — is aimed at the wrong failure class.

Two different failure classes hide behind the word 'freeze': a busy main thread (CPU pinned, rAF stops) versus input interception (CPU idle, rAF fine, clicks hit nothing).

The probe that cracked it

If the main thread is fine but clicks do nothing, the question becomes: where are the clicks going?

One line in the console answered it:

document.elementFromPoint(innerWidth / 2, innerHeight / 2)
// → <html>

The center of the screen — which visually showed the video canvas — hit-tested to the root element. Something was making the entire page transparent to pointer events. And there it was:

document.body.style.pointerEvents
// → "none"

An inline pointer-events: none on <body>, with no overlay, no dialog, no dropdown left in the DOM to justify it. An orphaned lock.

The mechanism

Radix UI's modal dropdowns (like many portal-based UI libraries) do something reasonable while open: they set pointer-events: none on document.body so clicks outside the menu can't reach the page, and they remove it during the menu's close sequence.

The key word is sequence. The lock is released by the closing lifecycle. Skip the lifecycle, keep the lock.

Here's what our click did, in order, all synchronously:

  1. User picks a sound effect from the picker (two nested dropdowns open at this point).
  2. The handler appends the new object and selects it — a synchronous state update.
  3. Selection switching makes the editor toolbar change modes: the component tree containing the picker unmounts.
  4. The dropdowns are destroyed while open. Radix's close sequence never runs.
  5. The setDropdownOpen(false) call in our handler runs a tick later — on a component that no longer exists.
  6. pointer-events: none stays on the body forever. Every click hits nothing. "Freeze."

One click, six steps: picking a sound effect synchronously updates selection, the toolbar switches modes and unmounts the still-open dropdowns, Radix's cleanup never runs, and the body pointer-events lock is left behind.

The cruel part: the freeze happened after the feature worked. The sound effect was added successfully. The state was correct. The UI just became untouchable as a side effect of succeeding.

The fix

The diff, in the end, was two attributes:

<DropdownMenu
  modal={false}
  open={dropdownOpen}
  onOpenChange={setDropdownOpen}
>

modal={false} tells Radix not to take the body lock at all — no lock, nothing to leak. I considered two alternatives:

modal={false} instead eliminates the class of bug: these menus didn't actually need modality (they're toolbar pickers, not confirmation dialogs), so giving up the focus trap was a price of approximately zero. When a bug comes from violating a library's lifecycle contract, the most robust fix is often to stop depending on that contract entirely.

I also left a comment on both dropdowns explaining why modal={false} is load-bearing.

Regression-proofing a phantom freeze

Here's a subtle testing lesson this bug taught me: our existing responsiveness probes could not detect this failure at all. A watchdog that checks "is requestAnimationFrame still firing?" says everything is fine — because it is. The main thread is healthy.

To catch this class you have to test what the user experiences — hit-testing and real clicks:

// after adding the sound effect:
expect(await page.evaluate(() => document.body.style.pointerEvents)).not.toBe('none')
expect(await page.evaluate(() =>
  document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.tagName
)).not.toBe('HTML')
await canvas.click({ timeout: 2000 })   // an actual click must land

I wrote a Playwright repro script with exactly those assertions, verified it failed on the pre-fix code and passed on the fix, and kept it as the regression check.

What I took away

  1. "Frozen" is not a diagnosis — it's two different diseases. Busy main thread and input interception present identically to a user and require opposite investigations. Check CPU and rAF first; they tell you which disease you have in ten seconds.
  2. Libraries with global side effects have lifecycle contracts. Portals, scroll locks, body locks, focus traps — anything that touches document.body expects to clean up on close. Unmounting such a component while it's open violates a contract you never knew you signed. In React, any synchronous state update inside a click handler can be the thing that unmounts it.
  3. When clicks die mysteriously, inspect document.body.style before anything else. It's a one-second check and it catches the whole leaked-lock family: pointer-events, overflow: hidden scroll locks, orphaned inert attributes.
  4. Diff size and understanding are unrelated. Sixteen lines — but the value isn't the diff. It's knowing why those sixteen lines are load-bearing: the comment explaining the mechanism and the regression probe that keeps it fixed. A two-token fix with no explanation is one well-meaning refactor away from being "cleaned up" back into the bug.