fix(desktop): de-duplicate @radix-ui packages to stop pointer-events being stranded on <body> (#11792)
Some checks failed
CI / Build Rust Project on Windows (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check GDK Release Configuration (push) Has been cancelled
CI / Check GDK API Reference is Up-to-Date (push) Has been cancelled
MCP Conformance / Build Conformance Binaries (push) Has been cancelled
Create Minor Release PR / check-version-bump-pr (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Canary / build-cli-linux (push) Has been cancelled
Canary / bundle-macos-arm64 (push) Has been cancelled
Canary / bundle-macos-x64 (push) Has been cancelled
Canary / bundle-windows (push) Has been cancelled
Canary / bundle-windows-cuda (push) Has been cancelled
Create Minor Release PR / release (push) Has been cancelled
Canary / Release (push) Has been cancelled
CI / goose ACP npm packages (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Check goose-sdk UniFFI (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build and Test TLS Backend (native-tls) (push) Has been cancelled
CI / Build and Test TLS Backend (rustls-tls) (push) Has been cancelled
CI / Build and Test Roaming Feature (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / Test Desktop Updater Install (ubuntu-latest) (push) Has been cancelled
CI / Test Desktop Updater Install (windows-latest) (push) Has been cancelled
MCP Conformance / Conformance 2025-11-25 / 0.1.16 (push) Has been cancelled
MCP Conformance / Conformance 2025-11-25 / 0.2.0-alpha.10 (push) Has been cancelled
MCP Conformance / Conformance 2026-07-28 / 0.2.0-alpha.10 (push) Has been cancelled
CI / Test Kotlin GDK package (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled

Co-authored-by: Alexis Rohou <a.rohou@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Douwe Osinga <douwe.osinga@gmail.com>
This commit is contained in:
Alexis Rohou 2026-09-19 18:10:56 +00:00 • committed by GitHub
parent ba8ba0cadb
commit 2090ad1c65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 182 additions and 1478 deletions

View file

@ -55,15 +55,19 @@
"@modelcontextprotocol/ext-apps": "^1.7.5",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-portal": "^1.1.17",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/themes": "^3.3.0",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-form": "1.33.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -0,0 +1,95 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Guards against a second copy of any @radix-ui package entering the tree.
*
* Radix primitives keep cross-cutting state in MODULE-LEVEL variables, not in
* React state. The worst offender is DismissableLayer, which owns the
* `pointer-events: none` that goes on <body> while a modal is open, plus the
* Sets tracking which layers are currently open, plus the saved "original"
* body value to restore on close — all module globals.
*
* Two copies of that module means two independent bookkeepers writing to one
* shared document.body, each blind to the other. Copy A opens a layer and sets
* body to `none`; copy B then opens one, sees its OWN layer Set is empty, and
* saves the value it currently reads — `none` — as "the original". From then on
* copy B restores `none` on every close. The style is stranded, every click in
* the app is silently swallowed, and nothing short of a reload recovers it: the
* window looks completely normal, the renderer is idle, and the JS console
* still works, so it does not present as a hang.
*
* That is what #11762 reported. `@radix-ui/themes` (declared but never
* imported) pulled in the `radix-ui` umbrella package, whose older pinned
* primitives got hoisted to the top level, where the app's *undeclared*
* imports of react-dropdown-menu and react-tooltip resolved to them — while
* the declared react-dialog used the modern tree. Two dismissable-layer
* copies, both bundled into the renderer.
*
* Radix pins its internal deps to EXACT versions, so any dependency that
* bundles its own primitives reintroduces this instantly and invisibly. This
* test reads the lockfile rather than node_modules so it fails in CI on the
* commit that introduces the skew, not months later on someone's frozen UI.
*/
function findLockfile(): string {
let dir = process.cwd();
for (let i = 0; i < 6; i++) {
const candidate = join(dir, 'pnpm-lock.yaml');
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`pnpm-lock.yaml not found at or above ${process.cwd()}`);
}
function radixVersionsByPackage(): Map<string, Set<string>> {
const lockfile = readFileSync(findLockfile(), 'utf8');
const versions = new Map<string, Set<string>>();
for (const [, name, version] of lockfile.matchAll(/@radix-ui\/([a-z0-9-]+)@(\d+\.\d+\.\d+)/g)) {
const seen = versions.get(name) ?? new Set<string>();
seen.add(version);
versions.set(name, seen);
}
return versions;
}
describe('@radix-ui dependency deduplication', () => {
it('resolves every @radix-ui package to exactly one version', () => {
const duplicated = [...radixVersionsByPackage()]
.filter(([, versions]) => versions.size > 1)
.map(([name, versions]) => `@radix-ui/${name}: ${[...versions].sort().join(', ')}`)
.sort();
// Reported as a list so a failure names every offender at once, and points
// at the fix: add a pnpm override in ui/pnpm-workspace.yaml, or declare the
// package directly in desktop/package.json so it stops being satisfied by
// whatever version some other dependency happened to hoist.
expect(duplicated).toEqual([]);
});
it('resolves react-dismissable-layer to a single version at or above 1.1.19', () => {
const versions = radixVersionsByPackage().get('react-dismissable-layer');
expect(versions, 'react-dismissable-layer missing from the lockfile').toBeDefined();
expect([...versions!]).toHaveLength(1);
// 1.1.11 and earlier delete the layer from the tracking Set in a *different*
// useEffect than the one restoring the body style, and gate the restore on
// `size === 1` read before that delete lands. 1.1.19 made the two atomic in
// a single cleanup, which is what actually fixes the stranding.
const [major, minor, patch] = [...versions!][0].split('.').map(Number);
expect(major * 1_000_000 + minor * 1_000 + patch).toBeGreaterThanOrEqual(1_001_019);
});
it('does not pull in the radix-ui umbrella package, which bundles its own primitives', () => {
const lockfile = readFileSync(findLockfile(), 'utf8');
// A bare `radix-ui@x.y.z` key (as opposed to the scoped @radix-ui/* ones)
// is the umbrella re-export. It pins a full set of primitives at its own
// exact versions, so adding it back guarantees a duplicate tree.
expect(lockfile).not.toMatch(/^ {2}radix-ui@\d/m);
});
});

1547
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,18 @@ supportedArchitectures:
overrides:
react: ^19.2.8
react-dom: ^19.2.8
# Radix pins this to an EXACT version inside every primitive, so a single
# dependency shipping its own primitives is enough to install a second copy —
# and DismissableLayer keeps `originalBodyPointerEvents` and its open-layer
# Sets in MODULE-LEVEL state. Two copies means two bookkeepers writing the
# same document.body while blind to each other: one saves `none` as the
# "original" value and then restores it on every close, stranding
# pointer-events:none on <body>. Every click in the app is then silently
# discarded, the window looks completely normal, and only a reload recovers
# it. See #11762. 1.1.19 also fixes a delete/restore ordering bug still
# present in 1.1.11.
# Enforced by desktop/src/test/radixSingleVersion.test.ts.
'@radix-ui/react-dismissable-layer': 1.1.19
allowBuilds:
'@modelcontextprotocol/ext-apps': true
electron: true