aaif-goose / aaif-goose/goose

Duplicate `@radix-ui/react-dismissable-layer` copies can strand `pointer-events: none` on `<body>`, permanently killing all mouse input

Aberta
#11,762 3 comentários 0 reações 1 responsável Reivindicada por @alexhancock Ver no GitHub
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

Abrir o guia de contribuição

Avaliação

Esta issue ainda não foi avaliada.

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.