fix!: default overlay portal target to a document.body root - #2808
Merged
Merged
Conversation
…ancestor Asserts that no ancestor between an opened Select listbox and document.body establishes a new CSS containing block (position/transform/filter/contain), mirroring react-aria's own containing-block check. Currently red — the fix (defaulting overlay portals to a document.body-appended container) lands in a follow-up commit.
Select, ComboBox, Menu, and DatePicker all accept a per-instance portalContainer override for their Popover; MultiSelectComboBox was missing it, forcing it to always use the theme default.
react-aria's calculatePosition resolves an overlay's containing block by walking up the DOM; when a host page sets position/transform/filter/contain on any ancestor between the SDK root and the viewport, overlays collapse (max-height: 0) instead of measuring available space correctly. Portalling in-place into the SDK's own root article inherited whatever containing block the host page happened to introduce. ThemeProvider now creates a dedicated themed root and appends it directly to document.body (lifecycle-managed, removed on unmount), and uses that as the default portal container for every overlay instead of the in-place article. An explicit portalContainer prop still overrides this. BREAKING CHANGE: the default portal container for SDK overlays (Select, ComboBox, Menu, DatePicker, MultiSelectComboBox) is no longer the SDK's root article element. Consumers relying on overlays rendering in-place inside the SDK's DOM subtree (e.g. via CSS descendant selectors scoped to the SDK root) should pass an explicit portalContainer to GustoProvider.
The default portal root and the SDK's in-place root both carried the
same bare `GSDK` class, with nothing distinguishing them. A host page
writing a global `.GSDK { overflow: hidden }` or `.GSDK { position:
relative }` override — plausible, since `.GSDK` is undocumented and
looks like a generic top-level hook — would now also reach the portal
root, reintroducing this branch's exact containing-block bug via
partner CSS instead of host-page CSS.
Give the portal root an additional `GSDK-portal-root` class (matching
its existing data-testid) so it can be excluded from a `.GSDK`
override, and ship a defensive rule that resets position/overflow/
transform/filter/contain on the two-class selector — its specificity
beats a plain `.GSDK` override without needing `!important`.
…OM requirement
Nothing previously explained that overlay components portal outside
the SDK's in-place root, that the portal root shares the plain `GSDK`
class, or that a shadow-DOM host must pass an explicit portalContainer
(document.body sits outside every shadow boundary, so the SDK's
default portal root would otherwise escape shadow-scoped styling).
Also refreshes six portalContainer TSDoc comments that described the
old in-place default ("overrides the default SDK root container from
context") to describe the new document.body-appended default.
…tations portalContainer isn't just internal plumbing for the SDK's own default components — the SDK's own screens (e.g. fields rendered inside an SDK-managed modal) pass a live value into whatever component occupies the Select/ComboBox/Menu/DatePicker/MultiSelectComboBox slot, default or partner-supplied. A custom implementation that ignores it falls back to its own library's default portal target and may render outside the modal it's meant to appear in. Nothing in the component-adapter guide previously said so.
mariechatfield
marked this pull request as ready for review
September 19, 2026 00:01
serikjensen
reviewed
Sep 21, 2026
serikjensen
left a comment
Member
There was a problem hiding this comment.
I worry a little bit about this approach and if it has the potential to cause regressions for partner builds. You might consider going this direction instead and just disabling relative positioning in sdk app when comments are not visible https://gustohq.atlassian.net/browse/SDK-1323
If we do go this direction, i think we need to
- Thoroughly thoroughly test all overlaying elements across sdk app of various component types and with scroll positions
- Give adopting partners a heads up about this change so they can be paying attention to the types of errors it might introduce
| const defaultPortalRoot = document.createElement('div') | ||
| defaultPortalRoot.className = 'GSDK GSDK-portal-root' | ||
| defaultPortalRoot.setAttribute('data-testid', 'GSDK-portal-root') | ||
| document.body.appendChild(defaultPortalRoot) |
Member
There was a problem hiding this comment.
i worry a little bit about the implications for partner implementations and incidentally causing regressions. This seems to make assumptions about how the document body and descendent SDK rendered DOM will behave with respect to each other. I've already proven that relative positioning in a container can mess with it
serikjensen
approved these changes
Sep 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
React-aria's
Popoverpositions itself withposition: absolute. CSS's containing-block rule for an absolutely-positioned element is: the nearest ancestor withposition != static, or the viewport if there is none — and that's a real walk up the actual DOM, through every ancestor, not just the ones inside this SDK's own component tree.position: relativeis an extremely ordinary thing to set on a layout wrapper — scroll containers, sticky headers, grid systems (Bootstrap's.col-*classes set it by default) all reach for it. We've hit this exact mechanism more than once now, including in our own SDK Dev App harness (main.main-content, set for the design-comments tool).Any such ancestor between wherever a partner mounts
GustoProvideranddocument.bodyputs react-aria's available-space math on a different code path — one that computes the trigger's offset relative to that ancestor instead of the viewport. This doesn't collapse every popover under a positioned ancestor identically: the resulting error scales with the specific geometry at the moment the popover opens (trigger position, container size, scroll offset), so some placements come out fine or nearly so while others land atmax-height: 0px. It isn't flaky, though — for a given layout, scroll position, and trigger, the outcome is deterministic and reproducible, not random.The first few rows in this table are always fine, and the last few rows show the popover beautifully... if there isn't any

position: relativeabove the parent:But with the

position: relativebreaking the link between the viewport and the ancestor, a table with enough rows will eventually math out to an "empty" menu:We don't own the ancestor markup that triggers this — it's the host page's own layout, not ours to change. So there's no CSS property we can set on our own root to fix it from the inside.
What changed
The industry-standard fix for this exact class of bug: portal overlay content to
document.bodyinstead of rendering in place. This isn't unusual — it's what React Aria's ownPortalProviderdocs recommend ("portal to the root of the entire application... outside any possible overflow or stacking contexts"), and what Floating UI and Radix Primitives both default to for the same reason.ThemeProvidernow creates a themed portal root and appends it directly todocument.body(lifecycle-managed, removed on unmount) instead of using the SDK's in-place root article as the default portal target. An explicitportalContainerprop still overrides this, unchanged.MultiSelectComboBoxgains aportalContainerprop for parity withSelect/ComboBox/Menu/DatePicker, which already had it.This does mean we break containment — here's what we did about it
Escaping into
document.bodyisn't free, so this includes explicit safety nets, not just the mechanism change:GSDKclass (required so overlay content still inherits SDK theming), but the portal root additionally carriesGSDK-portal-root. Without this, a host page writing a plausible global.GSDK { overflow: hidden }(undocumented, generic-looking class name) would silently clip every overlay again — this time via partner CSS instead of host-page CSS..GSDK.GSDK-portal-rootships a rule resettingposition/overflow/transform/filter/containto safe values. Two classes beat a plain.GSDKoverride on specificity, so the dangerous case is neutralized by default (verified live in Chrome, not just asserted) without reaching for!important.GustoProviderinside a shadow root without an explicitportalContainer, the new default portal root — adocument.bodychild — sits outside that shadow boundary, breaking style encapsulation. This is now called out explicitly as required, not optional, in both theportalContainerTSDoc and the theming guide.portalContaineris part of the public prop contract forSelect/ComboBox/Menu/DatePicker/MultiSelectComboBox— the SDK's own screens pass a live value at specific call sites (fields inside SDK-managed modals) regardless of whether the default or a partner-supplied component occupies that slot. Documented in the component-adapter guide, since nothing previously said a custom implementation should honor it.document.bodyestablishes a CSS containing block — this mirrors react-aria's ownisContainingBlockcheck, holds in both jsdom and real Chrome, and survives future changes to the portal root's exact shape.Select/ComboBox/Menu/DatePicker/MultiSelectComboBoxrender site inside an SDK-managed modal already passes an explicitportalContainer(or renders insideDialog, which auto-scopes theming regardless of the global default) — zero at-risk usages found.Verification
tsc --noEmitclean, eslint clean,npm run buildclean,npm run docs:build/docs:lintclean.max-height: 126px, not0px) inside the new body-level portal root.GSDK.GSDK-portal-rootdefensive rule actually wins over a conflicting.GSDKoverride.Test plan
npm run test -- --runnpm run buildnpx tsc --noEmitnpm run docs:lint/npm run docs:build