Duplicate `@radix-ui/react-dismissable-layer` copies can strand `pointer-events: none` on `<body>`, permanently killing all mouse input
- Linguagem predominante
- Rust
- Estrelas
- 54.2k
- Forks
- 6.2k
- Merge médio
- 3d 2h
- PRs com merge (30d)
- 262
Descrição
**Describe the bug**
The desktop app can reach a state where it stops responding to mouse clicks **entirely** —
sidebar, chat, buttons, everything — while looking completely normal. It does not present as a
hang: the window renders fine, the renderer sits idle at ~0% CPU, and the DevTools console is
fully responsive. Only reloading the renderer recovers it.
**This symptom is already reported twice, and the root cause has not been identified in either
thread:**
- **#11538** — *"macOS UI becomes unresponsive: chat cannot scroll and sidebar clicks stop working
until reload"*. Closed as completed on 2026-08-26, two seconds after #11583 ("improve long chat
rendering performance") merged via a `Fixes #11538` link. #11583 is markdown throttling,
memoization and batched history rendering — a **UI-thread-blocking** theory. That cannot explain
what we measured: the renderer was **idle at ~0.0–1.2% CPU, sleeping, heap 72 MB of a 3.76 GB
limit**, with a fully responsive DevTools console. Nothing was blocked or starved. Worth
reopening, or at least re-testing, since a perf fix does not touch this failure mode.
- **#11528** — *"When requesting permission to run, the allow/deny box sometimes becomes
unclickable"* (open). The reporter's follow-up is a description of this bug from the outside:
*"The side bar was also affected — nothing there was clickable. Only the chat window would still
work for input. Behavior was like a different modal box preventing input, but there was none."*
That is precisely a stranded `DismissableLayer`: a layer that is gone but whose
`pointer-events: none` is still on ``, with a focused composer still taking keystrokes.
Their report also matches ours in coinciding with a **tool-permission prompt** — ours appeared
alongside a "Read Image source" permission dialog, which is a Radix modal.
The cause is that `pointer-events: none` is left stranded on ``:
```js
getComputedStyle(document.body).pointerEvents // 'none', with no modal open
document.body.style.pointerEvents = 'auto' // instantly restores clicking
```
Radix's `DismissableLayer` sets that style while a layer with `disableOutsidePointerEvents` is
open, and restores the previous value when the last one closes. Its bookkeeping — the saved
"original" value and the `Set`s of open layers — is **module-level state**:
```js
// @radix-ui/react-dismissable-layer/dist/index.mjs
var originalBodyPointerEvents;
var DismissableLayerContext = React.createContext({
layers: new Set(), layersWithOutsidePointerEventsDisabled: new Set(), branches: new Set(),
});
```
**`main` installs `@radix-ui/react-dismissable-layer` at two versions.** `ui/pnpm-lock.yaml` on
`5345d0517` contains both `1.1.11` and `1.1.19`, and there is no `resolve.dedupe` in any
`ui/desktop/vite.*.mts`, so the bundler resolves them by path and ships them as **separate
modules**. With `nodeLinker: hoisted` the nested copies are real directories, not symlinks into a
shared store, so each path is its own module instance.
What matters is which version each *rendered* component resolves to. From the lockfile:
| Component (imported in `ui/desktop/src`) | resolves to | writes body `pointer-events`? |
|---|---|---|
| `@radix-ui/react-dialog@1.1.23` (declared) | dismissable-layer **1.1.19** | yes — `disableOutsidePointerEvents: context.open` |
| `@radix-ui/react-dropdown-menu@2.1.16` → `react-menu@2.1.16` (**undeclared**, hoisted) | dismissable-layer **1.1.11** | yes — `disableOutsidePointerEvents: context.open` |
| `@radix-ui/react-tooltip` (undeclared, hoisted) | dismissable-layer 1.1.11 | **no** — hardcoded `disableOutsidePointerEvents: false` |
So the app renders two modal layer types whose `DismissableLayer` code lives in two different
module instances, each with its own `originalBodyPointerEvents` and its own layer `Set`s, both
writing one shared `document.body`.
The interleaving that strands the style:
1. A DropdownMenu opens. Copy 1.1.11 sees its own `layersWithOutsidePointerEventsDisabled` is
empty, saves the true original (`''`), and sets `body` to `none`.
2. A Dialog opens on top. Copy 1.1.19 sees **its own** `Set` is empty, so it saves what it
currently reads — `'none'` — as "the original".
3. Both close. Whichever copy restores last writes `'none'`.
The bad value **latches**: from then on every close re-applies the lock, for the life of the
renderer. The roles are symmetric — either copy can be the one that captures `'none'`, depending
on which layer opens first.
**Why two Radix trees coexist.** `ui/desktop/package.json` declares `@radix-ui/themes`, which is
**not imported anywhere in the repo** (the only hits across `ui/` are `package.json` and the
lockfile). It depends on `radix-ui@1.4.3`, the umbrella package, which pins its own older
primitives. With `nodeLinker: hoisted` those get hoisted to the top level — which is exactly where
the app's **undeclared** imports resolve, while the declared `@radix-ui/react-dialog@^1.1.23` uses
the modern tree. Both end up bundled into the renderer.
Five Radix packages are imported by `ui/desktop/src` but never declared in `package.json`,
satisfied only by that hoisting: `react-dropdown-menu`, `react-tooltip`, `react-switch`,
`react-collapsible`, `react-portal`. (Conversely, `@radix-ui/react-select` and
`@radix-ui/react-popover` are declared but imported nowhere — the app's own `ui/Select.tsx` wraps
the unrelated `react-select` library, not the Radix primitive.)
In total **36 `@radix-ui` packages are installed at two different versions each** — 63 distinct
package names, 36 of them duplicated — including `react-dialog` (1.1.15 and 1.1.23),
`react-focus-scope`, `react-focus-guards` and `react-portal`, several of which also carry
module-level state.
Separately, `1.1.11` has its own ordering bug that `1.1.19` fixes. In 1.1.11 the restore is gated
on `layersWithOutsidePointerEventsDisabled.size === 1` inside one `useEffect`, while
`layers.delete(node)` / `layersWithOutsidePointerEventsDisabled.delete(node)` happen in a
*different* `useEffect` — so the size is read before that delete lands. 1.1.19 moves both into a
single cleanup: it deletes first, then restores if `size === 0`.
---
**To Reproduce**
Because it depends on which layers overlap, it surfaces during normal use rather than from a fixed
script. The dependency state that makes it possible is deterministic and checkable:
1. `cd ui && pnpm install`
2. Confirm multiple copies are installed:
```bash
find node_modules -path "*react-dismissable-layer/package.json" -not -path "*/dist/*" \
| while read f; do node -p "require('./$f').version"; done | sort -u
# prints 1.1.11 AND 1.1.19
```
3. Launch the desktop app.
4. Open a DropdownMenu, and with it open trigger a Dialog (or the reverse order); close both.
Repeat during normal use. These are the two modal layer types the app actually renders, and
they resolve to different copies. Tooltips are irrelevant here — Radix hardcodes
`disableOutsidePointerEvents: false` for them, so they never touch the style.
5. In DevTools run `getComputedStyle(document.body).pointerEvents`. Once it reads `'none'` with
nothing open, every click in the app is silently discarded until reload.
We hit this during ordinary use rather than from these steps, so treat step 4 as the mechanism
made explicit, not a guaranteed trigger.
---
**Expected behavior**
Exactly one copy of `@radix-ui/react-dismissable-layer` should be bundled, so its module-level
bookkeeping stays coherent and `pointer-events: none` is always removed from `` when the last
modal closes. A UI bug in one modal should never be able to disable mouse input for the entire
application.
---
**Screenshots**
N/A — the failure is invisible by nature: the UI renders normally and looks completely healthy
while discarding every click.
---
**Please provide the following information**
- **OS & Arch:** macOS 14.8.9 arm64 (not platform-specific — it follows from the dependency graph)
- **Interface:** UI
- **Version:** 1.48.0; dependency state verified on `main` at `5345d0517`, and still unchanged at
`7ba30b129` — no `@radix-ui` dependency, lockfile or `resolve.dedupe` change has landed since
- **Extensions enabled:** N/A — unrelated to extensions
- **Provider & Model:** N/A — unrelated to the provider
---
**Additional context**
What fixed it for us:
1. **Remove the unused `@radix-ui/themes` dependency.** It is imported nowhere and is the sole
source of the duplicate tree.
2. **Declare the five phantom imports** (`react-dropdown-menu`, `react-tooltip`, `react-switch`,
`react-collapsible`, `react-portal`) at current versions, so they stop resolving to whatever
gets hoisted. This is required alongside step 1, since removing `themes` is what was providing
them.
3. **Add a pnpm override** as a guard:
```yaml
# ui/pnpm-workspace.yaml
overrides:
'@radix-ui/react-dismissable-layer': 1.1.19
```
Result: all 36 duplicated `@radix-ui` packages collapse to one version each (verified against the
resulting lockfile: zero `@radix-ui` package at more than one version), the `radix-ui` umbrella
disappears from the tree, exactly one `dismissable-layer` is installed on disk, and the lockfile
loses ~1,400 lines net (1,547 deletions, 140 insertions). `tsc --noEmit` and the full desktop suite
(987 tests) pass against the new tree with no source changes needed beyond the dependency
declarations.
Guia de contribuição
Avaliação
Esta issue ainda não foi avaliada.