[select] Permanent main-thread freeze (infinite render loop) on open/close under CPU load
- 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
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