Skip to content

feat: let consumers supply the portal container - #118

Merged
Shrinks99 merged 3 commits into
mainfrom
feat/portal-container
Jul 28, 2026
Merged

feat: let consumers supply the portal container#118
Shrinks99 merged 3 commits into
mainfrom
feat/portal-container

Conversation

@makew0rld

Copy link
Copy Markdown
Contributor

Problem

Every portalled surface — tooltip, popover, select, dialog, alert-dialog, sheet, drawer, dropdown-menu — resolves its container with:

document.getElementById('equality-theme-provider-root-portal')

That only works when exactly one ThemeProvider exists in the main document. Two cases where it doesn't:

  • Inside a shadow root. document.getElementById can't see into a shadow tree, so the lookup returns null, Radix falls back to document.body, and every portalled surface renders outside the shadow boundary — where the design system's styles don't reach. They come out completely unstyled.
  • Two theme roots on one page. The id resolves to whichever comes first in document order, so the second root's portals mount inside the first one's container and inherit its theme.

Both are blocking us in @eqtylab/explorer, which is being turned into an embeddable widget: it needs to render inside a shadow root so a host page's CSS can't reach in, and needs more than one instance per page.

Change

Resolve the container through React context instead of a document lookup.

  • usePortalContainer() reads the nearest provider, falling back to the original document.getElementById lookup when none is mounted.
  • ThemeProvider supplies the element it already renders, and takes an optional portalContainer prop to override it.
  • PortalContainerProvider is exported for consumers who need to supply a container without rendering a ThemeProvider — necessary because ThemeProvider's root carries a document-unique id, so it can't be used more than once per page either.
// shadow DOM, or several instances on a page
<PortalContainerProvider container={myContainerElement}>
  <App />
</PortalContainerProvider>

// or via ThemeProvider
<ThemeProvider portalContainer={myContainerElement}></ThemeProvider>

Compatibility

Backwards compatible. With no provider mounted the hook behaves exactly as before, so existing consumers are unaffected whether or not they render a ThemeProvider. getThemeProviderRoot remains exported, now marked @deprecated.

ThemeProvider holds its portal element in state rather than a ref, because portalled children read the container during render and need the re-render once the element exists.

Verification

  • pnpm lint — clean (one pre-existing warning in demo/avatar.tsx, untouched)
  • pnpm build — succeeds; PortalContainerProvider, usePortalContainer and PORTAL_ROOT_ID present in dist/index.d.ts
  • Built dist/index.js shows all 8 portals on container: usePortalContainer() and no remaining getElementById call in that path

PortalContainerProvider lives in its own file to satisfy react-refresh/only-export-components, which fires when a hook and a component share a module.

🤖 Generated with Claude Code

Every portalled surface — tooltip, popover, select, dialog, sheet, drawer,
dropdown menu — resolved its container with
`document.getElementById('equality-theme-provider-root-portal')`. That works
only when exactly one ThemeProvider exists in the main document, and fails in
two cases we hit in practice:

- Inside a shadow root, `document.getElementById` cannot see the container, so
  Radix falls back to `document.body` and every portalled surface renders
  outside the shadow boundary — where the design system's styles don't reach,
  so they come out completely unstyled.
- With two theme roots on one page, the id resolves to whichever comes first in
  document order, so the second root's portals mount inside the first one's
  container and inherit its theme.

Resolve the container through React context instead. `ThemeProvider` supplies
the element it already renders, and accepts a `portalContainer` prop to
override it. `PortalContainerProvider` is exported for consumers who need to
supply a container without rendering a ThemeProvider at all.

Backwards compatible: with no provider mounted, `usePortalContainer` falls back
to the original document lookup, so existing consumers are unaffected.
`getThemeProviderRoot` stays exported and is marked deprecated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@makew0rld
makew0rld requested a review from Shrinks99 July 28, 2026 21:46
@makew0rld

Copy link
Copy Markdown
Contributor Author

Sorry for Claude slop. Needed for high quality implementation of VCO-45 using the shadow DOM in a web component.

@Shrinks99 Shrinks99 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed docs updates but I made those.

@Shrinks99
Shrinks99 merged commit dc2eeb9 into main Jul 28, 2026
1 check passed
@Shrinks99
Shrinks99 deleted the feat/portal-container branch July 28, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants