Skip to content

fix(builder): contain portalled overlays inside the shell's inert boundary - #892

Merged
mobeenabdullah merged 3 commits into
mainfrom
fix/shell-contains-portalled-overlays
Aug 17, 2026
Merged

mobeenabdullah merged 3 commits into
mainfrom
fix/shell-contains-portalled-overlays

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Closes the finding Codex raised on #884 and I deferred there. It was live on main, and it is the third round of the same shape, so it is fixed as a boundary rather than as a third patch.

The defect

The shell sets hidden and inert on its own subtree when it has too little width. Both are attributes on an element and reach descendants only. A portalled overlay is not a descendant of anything inside the shell — SelectContent, DropdownMenu, ContextMenu and every other portalling component resolve their container from PortalProvider, which the admin points at one root near the document body. So an open dropdown in the inspector stayed fully visible and clickable on top of the notice saying the editor was unavailable.

Reproducible on main today by narrowing the window with a Select open.

Why not the suggested fix

The review asked for the controls to consume useShellIsActive. That is 7 <Select sites across 3 files, and the eighth control is written by someone who never read the rule. AGENTS.md prefers a boundary the system cannot cross to a check that looks for crossings, so the container is redirected instead and every present and future overlay inherits it. React context reaches through portals, which is what makes this possible without touching a single call site.

packages/ui needed no change at all: usePortalContainer returns container ?? undefined, so a nested provider simply shadows. Verified that nothing keys on there being one portal root — nextly-admin-portal-root has exactly one occurrence repo-wide, its own definition.

Where the host sits is the whole design

Three placements, two of them wrong for reasons that are silent:

  • Inside a region clips it. The panel, the canvas and the inspector are each overflow-auto, so an overlay opened near an edge is cut off. Worse than the bug being fixed, because it is visual rather than interactive.
  • Inside the shell root clips it too. That root is overflow-hidden.
  • A sibling of the shell root, which is what this does: inside the element carrying hidden/inert, outside every box that clips.

Taken out of flow, because the wrapper it sits in is display: contents while visible and a real child box would become a phantom flex or grid item in the shell's layout parent.

The guard, and what it is standing in for

Clipping only becomes possible when an ancestor is a containing block for fixed positioning. There is none in the shell today, which means a test that opens an overlay and asserts it rendered cannot distinguish the correct placement from a clipped one — measured, not assumed: with the host moved inside the inspector, the browser test still passes.

So the placement is guarded by asserting the CONDITION instead: no ancestor of the host carries a transform, filter, contain, will-change or the rest. The same properties create a stacking context, which would additionally make every z-index inside the host local and unable to compete with admin-level overlays. A page builder gets canvas zoom eventually; that guard fails on the day it lands rather than leaving overlays quietly mispositioned.

Testing

Unit, packages/builder/src/builder-shell.test.tsx (4 new), each stub-verified:

  • the host is inside the element carrying hidden/inert — moving it outside fails this and nothing else
  • the host is in none of the three scrollable regions — moving it into the inspector fails this and nothing else, with zero type errors
  • no ancestor traps fixed positioning
  • a positive control that the guard walked real ancestors, so an empty result cannot come from a host with no parents

Browser, e2e/tests/shell/shell.spec.ts (4 new). 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:

  • the content's closest() resolves to the shell's host, so it landed there rather than at the body
  • it renders with a real box at a scrolled panel edge, annotated with what it cannot distinguish
  • its background resolves to a painted colour, so tokens reach a nested container by inheritance
  • opened, then the CONTAINER narrowed: the notice appears and the overlay is hidden with it

One thing found while writing those: a Radix Select, while open, aria-hiddens the rest of the page, so a test cannot read the inspector's box at the same time as the dropdown's. My first version did and timed out.

Not covered, stated rather than implied

Containment hides a modal overlay but leaves it MOUNTED, so a focus-trapping overlay could in principle run its trap inside a display: none subtree. I have not driven that case, and it is the one place where being told — the existing useShellIsActive, which MediaControl already consumes — is stronger than being contained. The two are complementary rather than alternatives, and the remaining work is to decide which overlays need telling as well.

Verification

  • packages/builder: 374/374, typecheck, lint clean
  • packages/plugin-page-builder: 828/828, both tsconfig programs
  • apps/playground: typecheck and lint clean
  • e2e/tests/shell/shell.spec.ts: 24/24 in Chromium
  • npx sherif: 0 errors
  • changeset generated from the workspace and checked with scripts/release/check-changesets.mjs

…ndary

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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@nextly-bot review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mobeenabdullah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 18ea10d0-1c3e-4393-a0f8-bf45767cf93e

📥 Commits

Reviewing files that changed from the base of the PR and between 0489453 and 0c279f5.

⛔ Files ignored due to path filters (1)
  • .changeset/shell-contains-overlays.md is excluded by !.changeset/**
📒 Files selected for processing (4)
  • apps/playground/src/app/builder-shell/harness.tsx
  • e2e/tests/shell/shell.spec.ts
  • packages/builder/src/builder-shell.test.tsx
  • packages/builder/src/builder-shell.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mobeenabdullah
mobeenabdullah merged commit 71ed357 into main Aug 17, 2026
10 checks passed
@github-actions github-actions Bot added the type: docs Documentation only label Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant