mui / mui/base-ui

[select] Permanent main-thread freeze (infinite render loop) on open/close under CPU load

Open
#5,358 4 comments 1 reaction 0 assignees View on GitHub
component: select
Dominant language
TypeScript
Stars
10.9k
Forks
543
Avg merge
1d 20h
Merged PRs (30d)
101

Description

# Bug report

## Current behavior

Repeatedly opening and closing a `Select` (trigger click, then `Escape`) under CPU load can put the page's main thread into a **permanent busy loop**: the renderer pegs at 100% CPU, `Runtime.evaluate` over CDP never returns, input events are never processed, and the tab never recovers. It is timing-dependent — under load it happens within a handful of open/close cycles; on an idle machine it may take many more or not happen at all.

Paused via CDP `Debugger.pause`, the loop is inside React's synchronous render scheduler:

```
at beginWork
at runWithFiberInDEV
at performUnitOfWork
at workLoopSync
at renderRootSync
at performWorkOnRoot
at performWorkOnRootViaSchedulerTask
at performWorkUntilDeadline <- MessageChannel scheduler task, never yields
```

and on other pauses:

```
at performSyncWorkOnRoot
at flushSyncWorkAcrossRoots_impl
at processRootScheduleInMicrotask <- sync work flushed from a microtask that never drains
```

Across repeated pauses, `renderWithHooksAgain` fires on arbitrary components **that contain no setState calls at all** (plain host-element wrappers), and the *entire* tree re-renders from the root. This is the signature of external-store tearing/restart retries: a `useSyncExternalStore` snapshot keeps changing between reads (or the store keeps being mutated during the commit), so React restarts the synchronous render from the root on every pass, forever. A native `sample` of the hung renderer confirms the spin is in JIT-compiled JavaScript, not in native layout/focus code.

## Expected behavior

Closing a `Select` always settles; no render loop, page stays responsive regardless of system load.

## Reproducible example

Component (canonical structure; also reproduces with `ScrollUpArrow`/`ScrollDownArrow`, open/close CSS animations, `alignItemWithTrigger` on or off, `side="top"` or `"bottom"`, with or without `StrictMode`):

```tsx
import { Select } from '@base-ui/react/select';
import { useState } from 'react';

export function App() {
const [v, setV] = useState('a');
return (


nv && setV(nv)}>

{v}





Alpha
Beta
Gamma






);
}
```

Loop driver (Playwright, but any real-input driver should do — the key ingredients are **real click + Escape** and **CPU saturation**):

repro script

```js
// node repro.mjs — needs: playwright chromium + parallel CPU burners (e.g. `yes > /dev/null` × cores)
import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 2 });

await page.goto('http://localhost:5199/'); // serves the App above
const trigger = page.getByLabel('Theme');
await trigger.waitFor();

const WATCHDOG = 15_000;
const race = (p) =>
Promise.race([p.then(() => 'ok'), new Promise((r) => setTimeout(() => r('FROZEN'), WATCHDOG))]);

for (let i = 0; i < 200; i++) {
if ((await race(trigger.click({ timeout: WATCHDOG }))) !== 'ok') { console.log('FROZEN at open, iter', i); break; }
if ((await race(page.keyboard.press('Escape'))) !== 'ok') { console.log('FROZEN at escape, iter', i); break; }
if ((await race(page.getByRole('listbox').waitFor({ state: 'hidden', timeout: WATCHDOG }))) !== 'ok') {
console.log('FROZEN at close, iter', i); // <- freezes here: renderer at 100% CPU, never recovers
break;
}
}
await browser.close();
```

With 3 parallel loop instances and 8 CPU burners on a 12-core machine, all 3 instances froze within 63 iterations (freeze points: iter 1, 17, 63). Without burners and throttling it is much rarer, which is why it looks like a load/timing race in the open/focus/positioning path.

Verified exclusions (freeze still reproduces with each of these removed): any application providers/state (router, data fetching, i18n, theme) — reproduces with the bare component above alone on an empty route; CSS open/close animations; `ScrollUpArrow`/`ScrollDownArrow`; `alignItemWithTrigger`; `side` geometry; React `StrictMode` (it roughly doubles the hit rate but is not required).

## Affected builds

Both a Vite **dev** server (unminified, HMR) and a **production** bundle (minified, no dev tooling) of the same app reproduce it — this is not a dev-mode-only issue.

## Base UI version

`@base-ui/react` **1.6.0** (latest on npm at time of writing)

## Environment

- React: 19.2.x (also reproduces with `use-sync-external-store` shim path unused, i.e. raw React 19 `useSyncExternalStore`)
- Browser: Chrome headless shell 14x (Playwright 1.61, chromium r1228/1232), also observed in headed Chrome
- OS: macOS 15 (arm64)
- CPU load required to hit reliably (CDP `Emulation.setCPUThrottlingRate` 2x + saturated cores)

## Possibly related

- #3789 (`[select] Popover growth feedback loop`) — earlier scroll/positioning feedback-loop class in the same component family.
- #5183 (`[select] Focus is not returned to the trigger after selecting an item`) — same focus/close area of the code.

Contributor guide

Open the contributing guide

Research direction

Start with the canonical Select example and run repro.mjs under CPU saturation to confirm the freeze. Trace the Select open/close, focus, positioning, and external-store paths described in the report; done means repeated real click and Escape cycles settle without a permanent render loop and the page remains responsive under load.

Written by the indexing model from the issue text.

Assessment

Tech stack
playwright, react, typescript
Domain
frontend, testing
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.