Overlay migration: dismiss coordination analysis and stress test#76487
Draft
Overlay migration: dismiss coordination analysis and stress test#76487
Conversation
Add a comprehensive plan for migrating overlay components (popovers, dialogs, dropdowns, menus, tooltips, selects) from @wordpress/components to @wordpress/ui built on @base-ui/react. Covers the 5-phase migration strategy, architectural decisions, bundling model constraints, and open questions. Made-with: Cursor
Technical analysis of how Base UI's dismiss coordination mechanisms behave when overlay components come from different bundles with independent React contexts but a shared React instance. Key finding: click-outside dismiss works correctly across bundles via the insideReactTree mechanism. Escape key has pre-existing limitations unrelated to bundling. Made-with: Cursor
Add a stress test for verifying overlay dismiss coordination across independent @base-ui/react bundles. Includes: - esbuild script that produces two bundles (IIFE for wp-env, ESM for Storybook), each with its own @base-ui/react copy but shared React - wp-env test plugin with admin page (Tools > Overlay Dismiss Test) - Storybook stories mixing WPDS components with pre-built Bundle B - Test scenarios covering dismiss coordination, popover nesting, three-level nesting, and legacy @wordpress/components interop Build the test bundles before use: node packages/e2e-tests/plugins/overlay-dismiss-stress-test/build-bundles.mjs Made-with: Cursor
- Add TypeScript declaration for @cross-bundle-test/bundle-b Vite alias so tsc --build can resolve the module - Fix PHP coding standards alignment in plugin.php Made-with: Cursor
The previous Record<string, React.ComponentType> types were too narrow for compound components with subprops (e.g., BundleB.Select.Root). Made-with: Cursor
The Storybook build in CI doesn't have the pre-built ESM bundles. Provide a stub module that renders null components as a fallback, so CI Storybook builds succeed without running the esbuild script. Made-with: Cursor
Fix import/no-extraneous-dependencies, no-console, prettier formatting, and eslint-comments/disable-enable-pair issues caught by CI full lint. Made-with: Cursor
|
Size Change: 0 B Total Size: 8.76 MB ℹ️ View Unchanged
|
|
Flaky tests detected in 147f037. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/23255281671
|
Update both architecture documents with findings from source code investigation of @base-ui/react's actual dismiss implementations: - Strategy 1: FloatingTree (Menu, Popover, Menubar, NavigationMenu) - Strategy 2: React context counter (Dialog, AlertDialog, Drawer) - Strategy 3: No nesting awareness (Select, Tooltip, PreviewCard, Combobox) Add per-component summary table, cross-bundle impact analysis for each strategy, and refined risk assessment in the migration plan. Made-with: Cursor
Each section now opens with a brief summary stating whether the scenario works across bundles, why, and the confidence/risk level. Made-with: Cursor
The ESM bundles contain CJS dependencies (use-sync-external-store) that
call require('react') at runtime. esbuild wraps these in a __require
shim that throws in browser ESM contexts like Vite.
Fix by adding a banner to the ESM build that provides a require()
function mapping externalized packages to their ESM imports, and using
platform: 'neutral' with mainFields: ['module', 'main'] to prefer ESM
entry points where available.
Made-with: Cursor
The IIFE bundles had the same "Dynamic require of react is not
supported" issue as the ESM bundles. CJS deps (use-sync-external-store)
call require('react') internally, and esbuild's __require shim throws
in browser contexts where require is not defined.
Add a banner to IIFE builds that maps require() calls to WordPress
globals (window.React, window.ReactDOM, window.ReactJSXRuntime).
Also add react-jsx-runtime to the script dependencies in plugin.php.
Made-with: Cursor
Stress test scenario 1.5 shows that Escape in cross-bundle Popover-in-Popover nesting works correctly — only the inner Popover closes. The predicted FloatingTree isolation regression does not occur. The reason: useDismiss attaches closeOnEscapeKeyDown as a React synthetic onKeyDown handler on the floating element. The inner Popover's handler fires first and calls stopPropagation(), preventing the outer from receiving it through the shared React component tree. Since React is externalized and shared, this works across bundles. Dialog-in-Dialog remains the only known Escape regression (Dialog disables its Escape handler entirely when not topmost via escapeKey: false, bypassing the synthetic event path). Made-with: Cursor
Add new "Portal Container Nesting and Visual Stacking" section to the cross-bundle dismiss coordination doc. Base UI's FloatingPortal uses a PortalContext (React context) to nest child portal containers inside parent portals. Across bundles this context is isolated, causing incorrect DOM ordering in multi-level nesting (e.g., Dialog(A) → Popover(B) → Select(A)): the Select portal nests inside Dialog's portal instead of Popover's, rendering behind the Popover visually. Mitigated by Phase 2 z-index overrides; fully resolved in Phase 4. Made-with: Cursor
Refactor the wp-env playground to render each scenario in both same-bundle and cross-bundle modes, side by side with data-testid attributes for E2E targeting. Add a new Dialog-in-Dialog scenario to test the known DialogRootContext counter regression. Add Playwright E2E test suite that exercises each scenario in both modes, comparing click-outside, Escape key, and nesting behaviors. The test for Dialog-in-Dialog explicitly documents the cross-bundle regression (Escape closes both dialogs) vs the same-bundle baseline (Escape closes only the inner dialog). Made-with: Cursor
- Fix click-outside tests: use force:true to bypass Base UI's data-base-ui-inert interception on dialog popups when a child overlay is open. - Replace flaky mouse.click(5,5) tests with Escape-based tests and Escape-twice sequences for more reliable dismiss testing. - Remove Dialog-in-Dialog regression test: automated testing confirms Escape correctly closes only the inner dialog in cross-bundle mode too, contradicting the earlier predicted regression. Result: all 26 tests pass identically in same-bundle and cross-bundle modes. Zero behavioral difference in dismiss coordination. Made-with: Cursor
…narios Add three new interop test scenarios (2.1-2.3) to the overlay dismiss stress test, mixing @wordpress/components overlays (Modal, Popover) with Base UI overlays (simulating @wordpress/ui). Key findings: - 2.1 Legacy Modal + Base UI Select: works correctly in both modes - 2.2 Base UI Dialog + Legacy Popover: Escape closes BOTH (legacy Popover doesn't call stopPropagation on the Escape event) - 2.3 Legacy Modal + Base UI Dialog + Select: works correctly in both modes, Escape cascades properly through all three levels All 38 tests (26 pure Base UI + 12 legacy interop) pass identically in same-bundle and cross-bundle configurations. Made-with: Cursor
Restructure both docs to front-load the practical answers: what breaks, how bad is it, and how to fix it. Key corrections: - Dialog-in-Dialog Escape: previously listed as "degraded", but automated E2E testing proves it works correctly across bundles. Reclassified as NOT a regression. - Add clear degradation/mitigation tables to both documents. - Add "Confirmed Regressions and Mitigations" section to migration plan with concrete fixes for each issue. - Update per-component risk table and implications section. Confirmed regressions (only 2): 1. Visual stacking in 3+ level cross-bundle nesting (PortalContext isolation) — mitigated by Phase 2 z-index overrides 2. Legacy Popover Escape inside Base UI Dialog closes both — fixable with one-line stopPropagation() in @wordpress/compose Made-with: Cursor
…ition The Storybook three-level nesting story was using A→B→B (Dialog from WPDS, Popover+Select from Bundle B), which doesn't trigger the visual stacking regression because the inner two overlays share PortalContext. Replace with A→B→A pattern (matching the wp-env playground) plus a same-bundle baseline for comparison. Add Bundle A alias to Storybook. Update docs to clarify that the visual stacking regression only occurs when bundles interleave in the nesting chain (A→B→A). If adjacent overlays share a bundle (A→B→B), stacking is correct. Made-with: Cursor
…roach Remove references to promoting `@wordpress/ui` to `wpScript: true` as a mitigation for PortalContext isolation. Instead, document a tentative `globalThis`-based shared portal context at the `@wordpress/ui` level, which uses Base UI's public `container` prop API and is version-resilient. Marked as needing validation with the Base UI team. Made-with: Cursor
Phase 4 (migrating @wordpress/components internals to Base UI) does NOT result in a single shared Base UI bundle. @wordpress/components (wpScript: true) and @wordpress/ui consumers each still bundle their own copy of @base-ui/react with independent PortalContext instances. The shared portal context approach via globalThis is the actual mitigation, regardless of Phase 4 completion. Made-with: Cursor
This was referenced Mar 26, 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.
Summary
Empirical investigation into whether overlays from
@wordpress/ui(built on@base-ui/react) will work correctly when multiple bundles coexist on the page, and when they coexist with legacy@wordpress/componentsoverlays. Includes 38 automated E2E tests and interactive playgrounds.What breaks, and how to fix it
Scenario A: Two separate
@base-ui/reactbundlesThis is the real-world scenario:
@wordpress/block-editor,@wordpress/dataviews, etc. each bundle their own copy of@wordpress/ui(and thus@base-ui/react), with independent React contexts but a shared React instance.PortalContext_A, finds the Dialog's portal, and nests there instead of inside the Popover's portal. This only happens when bundles alternate in the nesting chain (A→B→A). If the inner overlays share a bundle (A→B→B), stacking is correct because they sharePortalContext.@wordpress/uilevel viaglobalThispattern (needs validation with Base UI team).Everything else works — confirmed by 26 automated E2E tests, all passing identically in same-bundle and cross-bundle:
Scenario B:
@wordpress/ui+@wordpress/componentscoexistingThis is the transition period: Base UI overlays from
@wordpress/uinested inside legacy overlays from@wordpress/components, and vice versa.useDialogin@wordpress/compose) callsevent.preventDefault()but notstopPropagation(), so the event reaches Base UI's document-level handler. Both Popover and Dialog close instead of just the Popover.event.stopPropagation()incloseOnEscapeRefinpackages/compose/src/hooks/use-dialog/index.ts.Everything else works — confirmed by 12 automated E2E tests:
Bottom line
@wordpress/componentsstopPropagation()Shared portal context (tentative — needs validation with Base UI team)
The visual stacking regression is caused by
PortalContextisolation across bundles. Base UI's internalPortalContextis private, but every*.Portalcomponent accepts a publiccontainerprop.@wordpress/uicould create its own shared React context viaglobalThis.__wpuiPortalContext ??= React.createContext(null), allowing all independently bundled copies to coordinate portal nesting through DOM element references. This approach is version-resilient (decoupled from Base UI internals), uses a stable public API, and could ship as early as Phase 1. See the cross-bundle dismiss coordination doc for details.Note: migrating
@wordpress/componentsoverlay internals to@base-ui/react(Phase 4) does not resolvePortalContextisolation —@wordpress/components(wpScript: true) and the various consumers of@wordpress/ui(wpScript: false) still bundle separate copies of@base-ui/react. The shared portal context approach is needed regardless.Documentation
Stress test infrastructure
An esbuild script produces two independent bundles from
@base-ui/react, each with its ownReact.createContext()instances but sharing the same React runtime — simulating whatwp-builddoes when twowpScript: truepackages import@wordpress/ui.wp-env playground (side-by-side comparison)
Each scenario renders twice: same-bundle (baseline) vs cross-bundle. Includes 5 pure Base UI scenarios and 3 legacy interop scenarios.
E2E test suite (38 tests)
Storybook (interactive, with same-bundle vs cross-bundle comparison)
The three-level nesting story has two variants:
Test scenarios and results
Pure Base UI (26 tests):
Legacy interop (12 tests):
stopPropagationinterop issue)Test plan