From 71e00f754143947182a9b4796f613e775efb6b45 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Mon, 17 Aug 2026 12:27:46 +0500 Subject: [PATCH 1/2] fix(builder): contain portalled overlays inside the shell's inert boundary An overlay leaves the DOM position it was opened from, so the shell's `hidden` and `inert` could never reach it: an open Select in the inspector stayed visible and clickable on top of the notice saying the editor was unavailable. Fixed by redirecting where those overlays portal to rather than by teaching each control to close itself. The shell publishes a host inside the subtree that carries `hidden`/`inert`, and `usePortalContainer` already resolves through context, which reaches through portals. Every portalling component inherits the boundary, including ones added later, which a rule written on seven call sites would not cover. Where the host sits is the design. Inside a region clips it, because the panel, the canvas and the inspector are each `overflow-auto`. Inside the shell root clips it too, since that root is `overflow-hidden`. It is a sibling of the shell root: within the element carrying the attributes, and outside every box that clips. Taken out of flow so it contributes no layout to the wrapper it sits beside, which is `display: contents` while visible and would otherwise gain a phantom child box. Clipping only becomes possible once an ancestor is a containing block for fixed positioning, and none exists today, so a test asserting the overlay renders cannot distinguish a correct placement from a clipped one. The unit guard asserts the condition instead: no ancestor of the host carries a transform, filter, contain or will-change. The day canvas zoom adds one, that fails rather than overlays silently mispositioning. The harness gains its one interactive slot, a Select at the bottom of the scrolling inspector, because where an overlay lands is not decidable from markup and jsdom computes no styles. --- .../src/app/builder-shell/harness.tsx | 39 +++++- e2e/tests/shell/shell.spec.ts | 119 ++++++++++++++++++ packages/builder/src/builder-shell.test.tsx | 103 +++++++++++++++ packages/builder/src/builder-shell.tsx | 79 +++++++++--- 4 files changed, 324 insertions(+), 16 deletions(-) diff --git a/apps/playground/src/app/builder-shell/harness.tsx b/apps/playground/src/app/builder-shell/harness.tsx index 48940ca4c9..ace04a3fe3 100644 --- a/apps/playground/src/app/builder-shell/harness.tsx +++ b/apps/playground/src/app/builder-shell/harness.tsx @@ -4,6 +4,13 @@ import { type LeftPanel } from "@nextlyhq/builder"; // The shell comes from its own entry: that is the one carrying `"use client"`, // which is why the root barrel can stay callable from a Server Component. import { BuilderShell } from "@nextlyhq/builder/shell"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@nextlyhq/ui"; // The design system's sheet FIRST, then the editor's, which supplements it. // `@nextlyhq/builder/styles.css` deliberately ships neither the `--nx-*` tokens // nor the base reset, because the host already loads a sheet that owns both and @@ -56,7 +63,37 @@ export function BuilderShellHarness({ }} topBar={Top bar slot} breadcrumb={Breadcrumb slot} - inspector={
Inspector slot
} + inspector={ +
+ Inspector slot + {/* + * The ONE interactive thing in this harness, and it earns the + * exception the module docblock makes for inert slots. + * + * Where a portalled overlay LANDS is not decidable without one: it + * leaves the DOM position it was opened from, so the question is + * which container receives it and whether that container is inside + * the subtree the shell hides. A marker cannot answer that, and + * jsdom cannot either, since it computes no styles and would report + * a clipped dropdown and a visible one identically. + * + * Deliberately at the BOTTOM of the inspector: the inspector + * scrolls, so an overlay opened here is where clipping shows. One + * opened mid-panel looks correct whether or not the container + * escaped the scroll box. + */} +
+ +
+ } renderPanel={(panel: LeftPanel) => (
{panel} slot diff --git a/e2e/tests/shell/shell.spec.ts b/e2e/tests/shell/shell.spec.ts index 71d98c6e1e..d012e9830f 100644 --- a/e2e/tests/shell/shell.spec.ts +++ b/e2e/tests/shell/shell.spec.ts @@ -295,6 +295,125 @@ test.describe("a narrow CONTAINER inside a wide window", () => { }); }); +test.describe("overlays opened inside the shell", () => { + test.use({ viewport: { width: 1440, height: 900 } }); + + const HOST = '[data-slot="builder-overlay-host"]'; + + test("land in the shell's own host rather than the document body", async ({ + page, + }) => { + // The containment property. An overlay leaves the DOM position it was + // opened from, so nothing about the region that owns it can reach the + // content — which is why an open dropdown used to survive the shell being + // hidden. Asserted as ANCESTRY rather than by looking for the element, + // since finding it says nothing about where it went. + const shell = createShellDriver(page); + await shell.goto(); + + await page.getByTestId("overlay-trigger").click(); + const content = page.getByTestId("overlay-content"); + await expect(content).toBeVisible(); + + expect( + await content.evaluate((node, host) => node.closest(host) !== null, HOST) + ).toBe(true); + }); + + test("render with a real box when opened at a scrolled panel edge", async ({ + page, + }) => { + // The trigger sits at the BOTTOM of a scrolling inspector, which is where a + // container placed inside that scroll box would cut the dropdown off. + // + // WHAT THIS DOES NOT COVER, measured rather than assumed. Every overlay + // positions itself `fixed`, and `overflow` only clips a fixed descendant + // when an ancestor is a containing block for it — a `transform`, `filter`, + // `contain` or `will-change`. There is none anywhere in the shell today, so + // a container placed inside the inspector would NOT clip either, and this + // test would pass on that placement as readily as on the correct one. It + // cannot currently distinguish them. + // + // What actually guards the placement is the unit assertion that no ancestor + // of the host carries one of those properties + // (`builder-shell.test.tsx`), which is the condition that would make + // clipping possible. This test is the smoke check beside it: the overlay + // opens, is painted, and has a box — which is worth having, and is not the + // clipping guard. + const shell = createShellDriver(page); + await shell.goto(); + + const trigger = page.getByTestId("overlay-trigger"); + await trigger.scrollIntoViewIfNeeded(); + await trigger.click(); + + const content = page.getByTestId("overlay-content"); + await expect(content).toBeVisible(); + + const box = await content.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) return; + expect(box.height).toBeGreaterThan(20); + expect(box.width).toBeGreaterThan(20); + }); + + test("resolve the design-system tokens from inside that host", async ({ + page, + }) => { + // The admin re-applies `nextly-admin` on its own portal root because that + // class selects the token values. This host inherits instead, being nested + // inside the shell, and inheritance is a different path from re-declaring: + // an overlay whose background resolves to nothing renders as unstyled text + // over the canvas, which is legible enough to pass a screenshot glance. + const shell = createShellDriver(page); + await shell.goto(); + + await page.getByTestId("overlay-trigger").click(); + const content = page.getByTestId("overlay-content"); + await expect(content).toBeVisible(); + + // Read through the BROWSER's computed value, never the custom property: + // asking for `--nx-builder-surface` returns the declared token stream + // whether or not anything defines it, which is how an unstyled overlay + // passes a check that looks like this one. + // + // Not routed through the driver's `isUnpainted`, deliberately. That + // predicate takes a whole `ShellRenderMeasurement` and reads the alpha from + // a separator its own measurement produces; supplying that here would mean + // re-deriving the alpha beside it, which is the duplication the driver + // exists to prevent. The narrower question this test needs — did the + // background resolve to anything at all — is answerable without it. + const background = await content.evaluate( + node => getComputedStyle(node).backgroundColor + ); + expect(background.trim()).not.toBe(""); + expect(background.trim()).not.toBe("transparent"); + expect(background).not.toMatch(/(rgba?\([^)]*[,/]\s*0(\.0+)?\s*\))/); + }); + + test("disappear with the shell when its container is too narrow", async ({ + page, + }) => { + // The defect this whole change exists for, end to end. Opened while the + // shell is usable, then the CONTAINER is narrowed underneath it: the + // dropdown must go with the editor rather than floating over the notice + // that says the editor is unavailable. + const shell = createShellDriver(page); + await shell.goto(); + + await page.getByTestId("overlay-trigger").click(); + await expect(page.getByTestId("overlay-content")).toBeVisible(); + + await page.getByTestId("shell-container").evaluate((node, width) => { + node.style.width = `${width}px`; + }, MIN_SHELL_WIDTH - 100); + + await expect(page.getByText(/needs more width/i)).toBeVisible(); + await expect(page.getByTestId("overlay-content")).toBeHidden(); + void shell; + }); +}); + test.describe("the readiness diagnostic itself", () => { test.use({ viewport: { width: 1440, height: 900 } }); diff --git a/packages/builder/src/builder-shell.test.tsx b/packages/builder/src/builder-shell.test.tsx index ac9c5db535..cddf8899a1 100644 --- a/packages/builder/src/builder-shell.test.tsx +++ b/packages/builder/src/builder-shell.test.tsx @@ -400,6 +400,109 @@ describe("preferences", () => { }); }); +describe("where overlays inside the shell portal to", () => { + /** The host the shell publishes for portalled overlay content. */ + function overlayHost(): HTMLElement { + const host = document.querySelector( + '[data-slot="builder-overlay-host"]' + ); + if (host === null) throw new Error("the shell rendered no overlay host"); + return host; + } + + it("puts the host inside the subtree that goes inert", () => { + // The whole point. `hidden` and `inert` are attributes on an element and + // reach descendants only, so an overlay portalled to `document.body` could + // never be covered by them — it stayed visible and clickable on top of the + // notice saying the editor was unavailable. Containment is what makes that + // impossible rather than something each control has to remember. + stubContainerFits(false); + render( + +

canvas

+
+ ); + + const inert = overlayHost().closest("[inert]"); + expect(inert).not.toBeNull(); + // And the same element is the one taken out of layout, so portalled + // content inherits `display: none` rather than merely losing pointers. + expect(inert?.hasAttribute("hidden")).toBe(true); + }); + + it("keeps the host out of every scrollable region", () => { + // The failure a naive placement produces, and it is worse than the one + // being fixed because it is silent: the panel, the canvas and the + // inspector are each `overflow-auto`, so an overlay opened near a panel + // edge would be CLIPPED rather than merely misplaced. Asserted + // structurally because jsdom computes no styles — what it can decide is + // that the host is not a descendant of any of them. + renderShell({ renderPanel: panel =>

{panel} panel

}); + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + + const host = overlayHost(); + for (const region of [ + screen.getByRole("region", { name: "Canvas" }), + screen.getByRole("complementary", { name: "Inspector" }), + screen.getByRole("navigation", { name: "Editor panels" }), + ]) { + expect(region.contains(host)).toBe(false); + } + }); + + it("keeps the host out of any ancestor that would trap fixed positioning", () => { + // A guard rather than a comment, because the invariant is fragile by + // construction and its violation is silent. + // + // Every overlay positions itself `fixed`. A `transform`, `filter`, + // `backdrop-filter`, `perspective`, `contain` or `will-change` on an + // ancestor makes that ancestor the containing block instead of the + // viewport, so a centred dialog would centre on the canvas and a dropdown + // would land somewhere unrelated. The same properties create a stacking + // context, which additionally makes every z-index inside the host local + // and unable to compete with admin-level overlays. + // + // A page builder acquires canvas zoom eventually, and the day a `scale-` + // lands on an ancestor of this host is the day overlays quietly + // mispositioned. This fails then rather than in a bug report. + const TRAPS = + /(^|[\s:])(scale|rotate|translate|skew|transform|filter|blur|backdrop|perspective|isolate|contain|will-change|opacity)-/; + + renderShell({ renderPanel: panel =>

{panel} panel

}); + + const offenders: string[] = []; + for ( + let node: HTMLElement | null = overlayHost().parentElement; + node !== null; + node = node.parentElement + ) { + const classes = node.className; + if (typeof classes === "string" && TRAPS.test(classes)) { + offenders.push(`${node.tagName.toLowerCase()}: ${classes}`); + } + } + + expect(offenders).toEqual([]); + }); + + it("walked real ancestors, so the guard above is not vacuous", () => { + // The positive control. A host with no parents, or a selector that found + // nothing, would satisfy the empty-offenders check perfectly. + renderShell(); + + let depth = 0; + for ( + let node: HTMLElement | null = overlayHost().parentElement; + node !== null; + node = node.parentElement + ) { + depth += 1; + } + + expect(depth).toBeGreaterThan(2); + }); +}); + describe("the server render and the first client render agree", () => { it("emits the defaults even when the store has preferences", () => { // The hydration property, and it needs a real server render to state. diff --git a/packages/builder/src/builder-shell.tsx b/packages/builder/src/builder-shell.tsx index bbed6809e6..007fe4cbe8 100644 --- a/packages/builder/src/builder-shell.tsx +++ b/packages/builder/src/builder-shell.tsx @@ -1,6 +1,7 @@ "use client"; import { + PortalProvider, ResizableHandle, ResizablePanel, ResizablePanelGroup, @@ -969,6 +970,14 @@ export function BuilderShell({ store, ...props }: BuilderShellProps) { const resolvedStore = store ?? fallbackStore.current; const [preferences, update, loadCount] = usePreferences(resolvedStore); const [measureShell, shellFits] = useFitsFullShell(); + /* + * Where overlays inside this shell portal to. State rather than a ref, + * because `PortalProvider` has to RE-RENDER once the node exists; a ref + * mutation would leave the provider holding `null` for the life of the mount. + */ + const [overlayHost, setOverlayHost] = React.useState( + null + ); return ( @@ -1077,23 +1086,63 @@ export function BuilderShell({ store, ...props }: BuilderShellProps) { className={shellFits ? "contents" : undefined} > {/* Published as context as well as applied as attributes, because `hidden` and `inert` - only reach what renders INSIDE this wrapper. Slot content that portals to the body - escapes both, and needs to be told rather than contained. */} + only reach what renders INSIDE this wrapper. Slot content that reads this answers for + itself; content that PORTALS is contained by `overlayHost` below instead. */} - + {/* + * `null` until the host mounts, which `usePortalContainer` reads + * as "use the default". That is one commit of the old behaviour + * rather than a crash, and no overlay can be open that early. + */} + + +
From f41d727897b6a8557da35f86b39b0f61e4e66866 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Mon, 17 Aug 2026 12:29:48 +0500 Subject: [PATCH 2/2] chore: add changeset for overlay containment --- .changeset/shell-contains-overlays.md | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/shell-contains-overlays.md diff --git a/.changeset/shell-contains-overlays.md b/.changeset/shell-contains-overlays.md new file mode 100644 index 0000000000..0b567d479e --- /dev/null +++ b/.changeset/shell-contains-overlays.md @@ -0,0 +1,28 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/module-specifiers": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +"create-nextly-app": patch +"nextly": patch +--- + +Dropdowns and pickers opened inside the page editor no longer stay on screen when the editor hides itself. If the editor did not have enough width it would show a notice explaining that, while an open dropdown floated on top of the notice and could still be clicked.