[WIP/RFC] feat(portal): PortalContainerContext for federated portal isolation#4694
[WIP/RFC] feat(portal): PortalContainerContext for federated portal isolation#4694JChan106 wants to merge 2 commits into
Conversation
…IEW-1156) Federated consumers that scope their CSS under a root element need portaled UI (Tooltip, Flyout, Portal/Modal) to render inside that root rather than document.body, so scoped styles reach it. Today the portal target is resolved per-instance with no global override. Add PortalContainerContext. Portal, Tooltip, and Flyout consume it to resolve their portal target as: explicit prop > context container > document.body. Behavior is unchanged when no provider is present (backward compatible). A consumer wraps its subtree in one provider pointing at its own root. For Tooltip and Flyout, the context element is set as BOTH react-tether's renderElementTo (initial mount) AND Tether's bodyElement (positioning re-parent target) — otherwise Tether's positioning engine moves the element back to document.body on every reposition.
WalkthroughAdds ChangesPortal container integration
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Provider as PortalContainerContext.Provider
participant Portal
participant Flyout
participant Tooltip
participant Container as DOM container
Provider->>Portal: provide default container
Provider->>Flyout: provide default container
Provider->>Tooltip: provide default container
Portal->>Container: mount portal content
Flyout->>Container: configure tether mounting and positioning
Tooltip->>Container: configure tether mounting and positioning
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/components/portal/Portal.tsx (1)
12-49: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winHandle context and prop updates to prevent stale container bindings.
Because
this.containeris assigned and mutated only in theconstructor, it will not update ifprops.containerorthis.contextchange later. This is particularly problematic for context values driven by Reactrefs, which are typicallynullon the first render and populate on the second. IfPortalmounts before the ref populates, it will permanently attach todocument.bodyand never move to the intended context container.Add a
componentDidUpdatelifecycle method to detect changes to the resolved container and reparentthis.layer.🐛 Proposed fix
Add the
componentDidUpdatemethod to dynamically update the container:componentWillUnmount() { if (this.container && this.layer) { this.container.removeChild(this.layer); } this.layer = null; } + + componentDidUpdate() { + const nextContainer = this.props.container ?? this.context ?? document.body; + if (this.container !== nextContainer) { + if (this.container && this.layer) { + this.container.removeChild(this.layer); + } + this.container = nextContainer; + if (this.container && this.layer) { + this.container.appendChild(this.layer); + } + } + } layer: HTMLDivElement | null | undefined;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/portal/Portal.tsx` around lines 12 - 49, Update Portal’s container binding by adding componentDidUpdate to recompute the resolved container from the current props.container, context, and document.body. When the resolved container differs from this.container, remove this.layer from the old container, append it to the new container when available, and update this.container, preserving the existing layer and rendering behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/components/portal/Portal.tsx`:
- Around line 12-49: Update Portal’s container binding by adding
componentDidUpdate to recompute the resolved container from the current
props.container, context, and document.body. When the resolved container differs
from this.container, remove this.layer from the old container, append it to the
new container when available, and update this.container, preserving the existing
layer and rendering behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c51245a5-70c0-4bd4-9752-1ff029829012
⛔ Files ignored due to path filters (1)
src/components/tooltip/__tests__/__snapshots__/Tooltip.test.tsx.snapis excluded by!**/*.snap
📒 Files selected for processing (5)
src/common/PortalContainerContext.tsxsrc/components/flyout/Flyout.jssrc/components/portal/Portal.tsxsrc/components/portal/__tests__/Portal.test.tsxsrc/components/tooltip/Tooltip.tsx
…ls (PREVIEW-1156) Wire the blueprint-web portals rendered by ContentSidebar to the PortalContainerContext container so federated consumers (preview-client) can keep them inside a scoped root instead of document.body: - ContentSidebar: pass the context container to its <TooltipProvider>, so sidebar nav/tab tooltips portal into the scope (its own TooltipProvider otherwise reset the container to document.body). - AddTaskMenuV2: pass the container to <DropdownMenu.Content>. - TaskModalV2: pass the container to <Modal.Content>. Blueprint has no unified portal-container provider yet (see DSYS-440); until then each portal is wired per call site. box-ui-elements' own Flyout/tether dropdowns (VersionsItemActions, AnnotationActivityMenu) are already covered by the Flyout change; TaskFormV2's DatePicker already portals into its form.
Problem
preview-clientis a Module Federation remote embedded in a host document (EUA). It scopes its bundled box-ui-elements CSS under a root element (.preview-overlay) so those global styles can't leak into / override the host (e.g. sign-client) — see PREVIEW-1152. But BUIE (and the blueprint-web components BUIE renders) portal Tooltip/Flyout/Modal/DropdownMenu todocument.body, outside that root, so scoped rules can't reach them and preview's own portaled UI renders unstyled / invisible.Change
Introduce
PortalContainerContext(defaultnull= current behavior). A consumer wraps its subtree in one provider pointing at its own root; portals resolve their target as explicit prop > context container > document.body.Wired: Portal / Tooltip / Flyout consume the context (Tooltip & Flyout set both react-tether
renderElementToand TetherbodyElement); ContentSidebar passes the container to its internalTooltipProvider; AddTaskMenuV2 (DropdownMenu.Content) and TaskModalV2 (Modal.Content) pass it per call site.Open questions / known gaps (why WIP)
containeras a prop only (no context fallback), so every call site must be wired individually. A unified provider in blueprint is the real durable fix.@box/activity-feedrenders its own blueprint portals; not fixable here.containerto null at runtime during testing — root cause unconfirmed (provider ancestry vs. MF module identity). Needs follow-up.Backward compatibility
No behavior change when no provider is present. Existing Portal/Tooltip/Flyout tests pass (snapshots updated for
bodyElement); ContentSidebar/AddTaskMenuV2/TaskModalV2 suites pass (88 tests).Ref: PREVIEW-1156