Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nexus-code",
"productName": "NexusCode",
"version": "0.7.0",
"version": "0.7.1",
"description": "Multi-workspace VSCode-style editor for macOS. Monaco editor + terminal in one window.",
"license": "MIT",
"private": true,
Expand Down
56 changes: 55 additions & 1 deletion src/renderer/state/operations/browser-suspend-auto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@
* [data-radix-popper-content-wrapper] — Popover, Tooltip popper containers
* Watching these four covers every overlay primitive Radix ships.
*
* FALSE POSITIVES — MONACO WIDGETS
* --------------------------------
* The role-based half of the selector also matches Monaco's own editor
* widgets: the Find/Replace widget is `.editor-widget.find-widget[role="dialog"]`,
* the context menu is `[role="menu"]`, the suggest widget likewise. Monaco
* creates these lazily (first Cmd+F etc.) and then leaves them in the DOM
* PERMANENTLY in a hidden state (`aria-hidden="true"`) rather than unmounting.
* They live inside `.monaco-editor` and never occlude the browser
* WebContentsView — they are part of the editor pane, not a portal over it.
*
* Without filtering, the first Find in any editor leaves a permanent
* `[role="dialog"]` node behind, so `check()` reads `hasOverlay === true`
* forever, holds the suspend claim, and the browser tab in that workspace
* stays blank until a manual `resumeAll` or restart. `isOccludingOverlay()`
* excludes Monaco widgets (whole `.monaco-editor` subtree + the `editor-widget`
* class, in case overflow widgets are mounted outside it) and any
* `aria-hidden="true"` node, leaving only genuine on-screen portal overlays.
*
* COALESCING
* ----------
* MutationObserver can fire many times per render — the check is scheduled
Expand All @@ -45,6 +63,31 @@ import { useBrowserSuspendStore } from "../stores/browser-suspend";
const OVERLAY_SELECTOR =
'[role="dialog"],[role="alertdialog"],[role="menu"],[data-radix-popper-content-wrapper]';

// Monaco editor widgets (find/replace, context menu, suggest) carry overlay
// roles but live inside the editor pane and persist in the DOM while hidden.
// Excluding the editor subtree (and the widget class, in case overflow widgets
// are mounted at the body) keeps them from registering as page overlays.
const MONACO_WIDGET_SELECTOR = ".monaco-editor,.editor-widget";

/**
* True when `el` is a portal overlay that actually occludes the browser view.
*
* Excludes (a) Monaco editor widgets — same overlay roles, but part of the
* editor pane, not a portal over the browser, and left in the DOM permanently
* once created; and (b) `aria-hidden="true"` nodes — a closed/inert overlay
* does not occlude anything (Radix marks BACKGROUND content aria-hidden, never
* the live overlay content, so this never drops a real overlay).
*/
export function isOccludingOverlay(el: Element): boolean {
if (el.matches(MONACO_WIDGET_SELECTOR) || el.closest(MONACO_WIDGET_SELECTOR) !== null) {
return false;
}
if (el.getAttribute("aria-hidden") === "true") {
return false;
}
return true;
}

let installed = false;

/**
Expand All @@ -69,7 +112,9 @@ export function initBrowserOverlayAutoSuspend(): void {

function check(): void {
pending = false;
const hasOverlay = document.querySelector(OVERLAY_SELECTOR) !== null;
const hasOverlay = Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some(
isOccludingOverlay,
);
if (hasOverlay && release === null) {
// Entering overlay state — claim with snapshot capture so the modal
// renders above a still frame of the page rather than a blank area.
Expand All @@ -91,6 +136,15 @@ export function initBrowserOverlayAutoSuspend(): void {
const observer = new MutationObserver(schedule);
observer.observe(document.body, { childList: true, subtree: true });

// Re-reconcile on focus / tab-visibility regain. A MutationObserver only
// fires on DOM changes, so a suspend state that desynced while the window
// was backgrounded (e.g. the OS dropped a `dragend`, or the display slept
// mid-overlay) would otherwise stay stuck until the next mutation. Coming
// back to the window forces a fresh check, restoring the live view when no
// overlay is actually present.
window.addEventListener("focus", schedule);
document.addEventListener("visibilitychange", schedule);

// Initial sync — covers the (unlikely but harmless) case where an overlay
// is already present at install time, e.g. an HMR reload while a modal is
// open in development.
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/renderer/state/operations/browser-suspend-auto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Regression tests for `isOccludingOverlay` — the predicate that decides
* whether a DOM node matching the overlay-role selector actually occludes the
* browser WebContentsView (and therefore warrants suspending it).
*
* THE BUG THIS GUARDS
* -------------------
* Monaco's Find/Replace widget is `.editor-widget.find-widget[role="dialog"]`.
* Monaco creates it lazily (first Cmd+F) and then leaves it in the DOM forever
* in a hidden state. The auto-suspend selector matches `[role="dialog"]`, so
* before the fix the first Find in any editor left a permanent "overlay" node
* behind → the browser stayed suspended (blank) until a manual resumeAll or a
* restart, per workspace. `isOccludingOverlay` must reject Monaco widgets and
* hidden nodes while still accepting genuine Radix portal overlays.
*
* No real DOM here — this project runs bun:test without jsdom/happy-dom, so we
* hand-build element stubs that implement only the three methods the predicate
* touches (`matches`, `closest`, `getAttribute`), matching the convention in
* keybindings/context-keys.test.ts.
*/

import { describe, expect, test } from "bun:test";

import { isOccludingOverlay } from "../../../../../src/renderer/state/operations/browser-suspend-auto";

interface FakeSpec {
/** el.matches(MONACO_WIDGET_SELECTOR) — the node itself is a Monaco widget. */
isMonacoWidget?: boolean;
/** el.closest(MONACO_WIDGET_SELECTOR) — the node sits inside the editor pane. */
insideMonaco?: boolean;
/** el.getAttribute("aria-hidden") === "true". */
ariaHidden?: boolean;
}

// The predicate only ever passes the Monaco-widget selector to matches/closest,
// so the stub can resolve those calls from the spec booleans directly.
function fakeEl(spec: FakeSpec): Element {
return {
matches: () => Boolean(spec.isMonacoWidget),
closest: () => (spec.insideMonaco || spec.isMonacoWidget ? ({} as Element) : null),
getAttribute: (name: string) =>
name === "aria-hidden" && spec.ariaHidden ? "true" : null,
} as unknown as Element;
}

describe("isOccludingOverlay", () => {
test("rejects Monaco's closed Find/Replace widget (the regression)", () => {
// .editor-widget.find-widget[role=dialog][aria-hidden] inside .monaco-editor
expect(isOccludingOverlay(fakeEl({ insideMonaco: true, ariaHidden: true }))).toBe(false);
});

test("rejects an open Monaco widget (find visible, role=dialog, not hidden)", () => {
expect(isOccludingOverlay(fakeEl({ insideMonaco: true, ariaHidden: false }))).toBe(false);
});

test("rejects a Monaco overflow widget mounted outside .monaco-editor (matched by class)", () => {
// fixedOverflowWidgets mounts at the body — caught by the `.editor-widget` self-match.
expect(isOccludingOverlay(fakeEl({ isMonacoWidget: true, insideMonaco: false }))).toBe(false);
});

test("rejects any aria-hidden overlay node", () => {
expect(isOccludingOverlay(fakeEl({ ariaHidden: true }))).toBe(false);
});

test("accepts a genuine, visible Radix overlay (dialog / menu / popper)", () => {
expect(isOccludingOverlay(fakeEl({}))).toBe(true);
});
});