Skip to content

[CLOV-1606][BpkModalV3] Fix infinite focus redirect loop when BpkModalV3 and BpkDrawer coexist#4635

Open
Faye (Faye-Xiao) wants to merge 8 commits into
mainfrom
CLOV-1606
Open

[CLOV-1606][BpkModalV3] Fix infinite focus redirect loop when BpkModalV3 and BpkDrawer coexist#4635
Faye (Faye-Xiao) wants to merge 8 commits into
mainfrom
CLOV-1606

Conversation

@Faye-Xiao
Copy link
Copy Markdown
Contributor

@Faye-Xiao Faye (Faye-Xiao) commented May 25, 2026

Description

When a BpkDrawer (which uses the legacy withScrim/focusScope focus management system) is open at the same time as a BpkModalV3 (which uses ark-ui's @zag-js/focus-trap), both systems simultaneously listen to focusin on document. This causes an infinite focus redirect loop that overflows the call stack.

Root cause: focusScope registers a bubble-phase focusin listener that synchronously calls element.focus(). @zag-js/focus-trap registers a capture-phase listener but does not call stopImmediatePropagation when focus is already inside the modal. The result is a recursive focusin dispatch loop (~8 frames per iteration) that overflows the call stack after ~100–200 iterations.

Fix: When BpkModalV3Root opens, it calls focusScope.unscopeFocus() to deactivate the legacy listener before ark-ui's focus trap takes over. This is a compatibility patch that keeps both systems stable while BpkDrawer has not yet been migrated to ark-ui.

Changes

  • BpkModalV3Root.tsx: Call focusScope.unscopeFocus() in a useEffect when isOpen becomes true, with a comment explaining why.
  • BpkModalV3-test.tsx: Mock focusScope and add 3 test cases covering: modal opens (unscopeFocus called), modal closed (unscopeFocus not called), and closed → open transition.
  • BpkModalV3.stories.tsx: Add WithDrawer story demonstrating BpkModalV3 opened from within a BpkDrawer.

Test Screenshots

Tested it in local carhire

Platform Before After
mWeb before-fix-mweb after-fix-mweb
destop before-fix-desktop after-fix-desktop
  • no error in mWeb
  • no change in desktop, no issue with keyboard management in desktop

Checklist

  • Ensure the PR title includes the name of the component you are changing so it's clear in the release notes for consumers of the changes in the version e.g [CLOV-1606][BpkModalV3] Fix infinite focus redirect loop when BpkModalV3 and BpkDrawer coexist
  • README.md (If you have created a new component) — N/A, no new component
  • Component README.md — N/A
  • Tests
  • Accessibility tests
    • The following checks were performed:
      • Ability to navigate using a keyboard only — this fix restores correct keyboard focus when both components coexist
      • Zoom functionality (Deque University explanation):
        • The page SHOULD be functional AND readable when only the text is magnified to 200% of its initial size
        • Pages must reflow as zoom increases up to 400% so that content continues to be presented in only one column i.e. Content MUST NOT require scrolling in two directions (both vertically and horizontally)
      • Ability to navigate using a screen reader only
  • Storybook examples created/updated — WithDrawer story added to bpk-component-modal-v3
  • For breaking changes or deprecating components/properties, migration guides added to the description of the PR — N/A, this is a bug fix

…lV3 and BpkDrawer coexist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Faye-Xiao Faye (Faye-Xiao) added the patch Patch production bug label May 25, 2026
@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link
Copy Markdown

skyscanner-backpack-bot Bot commented May 25, 2026

Warnings
⚠️

Package source files (e.g. packages/package-name/src/Component.js) were updated, but snapshots weren't. Have you checked that the tests still pass?

Browser support

If this is a visual change, make sure you've tested it in multiple browsers.

Generated by 🚫 dangerJS against a91f499

@Faye-Xiao Faye (Faye-Xiao) marked this pull request as ready for review May 25, 2026 11:57
Copilot AI review requested due to automatic review settings May 25, 2026 11:57
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

Copy link
Copy Markdown
Contributor

@Supremeyh Ezreal Yang (Supremeyh) left a comment

Choose a reason for hiding this comment

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

Check on story, it seems cannot close drawer with keyboard Esc and when close the modal, it would flash fast, is it expected?

Screen.Recording.2026-05-27.at.11.43.04.mov

// redirect loop that overflows the call stack.
useEffect(() => {
if (isOpen) {
focusScope.unscopeFocus();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the main concern with the current implementation.

Looking at withScrim.tsx:114-115, the drawer only calls focusScope.scopeFocus(this.dialogElement) once — in componentDidMount. It has no mechanism to re-register after an external caller clears the global state.

When BpkModalV3 closes (drawer still mounted), the sequence is:

  1. BpkModalV3Root opens → focusScope.unscopeFocus() called ✅
  2. ark-ui focus trap takes over the modal ✅
  3. User closes BpkModalV3 → BpkModalV3Root unmounts / isOpen becomes false
  4. focusScope listener is gone and never restored ❌
  5. The drawer is still visually open but focus can now escape it ❌
    The PR trades a crash for a silent a11y regression. On desktop this matters most: keyboard users can now Tab out of the drawer after the modal closes.

Suggested fix — save and restore the scope element:

// BpkModalV3Root.tsx
const scopedElementRef = useRef<HTMLElement | null>(null);

useEffect(() => {
  if (isOpen) {
    // Save current focus scope element before clearing it
    // (focusScope doesn't expose its element directly — see note below)
    focusScope.unscopeFocus();
  } else if (scopedElementRef.current) {
    // Re-activate the saved scope when modal closes
    focusScope.scopeFocus(scopedElementRef.current);
    scopedElementRef.current = null;
  }
}, [isOpen]);

However, focusScope.ts doesn't expose the currently scoped element, so this would require either:

  • Option A: Add getScopedElement(): HTMLElement | null to focusScope API, or
  • Option B: Add pauseFocus() / resumeFocus() to focusScope that internally preserves teardownFn, or
  • Option C (minimal): withScrim re-calls scopeFocus in componentDidUpdate when the drawer is still open

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Fixed in the latest commit by replacing unscopeFocus() with pauseFocus() / resumeFocus() pattern.

When the modal opens, pauseFocus() removes the focusin listener but keeps the element reference. When the modal closes, resumeFocus() re-registers the listener without calling focus() immediately, so ark-ui's own focus-return logic runs first and there is no focus jump.

The key change is in focusScope.ts: extracted a registerListener() helper that only attaches the listener (no initial focus steal), which resumeFocus() uses.

import { durationBase } from '@skyscanner/bpk-foundations-web/tokens/base.es6';

import { getDataComponentAttribute, useBodyLock } from '../../../../bpk-react-utils';
import focusScope from '../../../../bpk-scrim-utils/src/focusScope';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It imports focusScope directly from a sibling package's src/ path. This creates a hard coupling between bpk-component-modal and the internals of bpk-scrim-utils. The import path ../../../../bpk-scrim-utils/src/focusScope bypasses the package boundary. If bpk-scrim-utils reorganizes its internals, BpkModalV3Root breaks silently.

Minor suggestion: at minimum, export focusScope from bpk-scrim-utils' package index so this reads as an inter-package dependency, not an internal-path hack.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Added focusScope as a named export in bpk-scrim-utils/index.ts, and updated the import in BpkModalV3Root to use the package index instead of the src path.

Comment on lines 87 to +88
describe('Root', () => {
beforeEach(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tests are correct for what they test. Three concerns:

  • There is no test for the close path: the tests verify unscopeFocus is called on open, but there's no test asserting behaviour when the modal closes while a drawer is active. This is precisely the gap where the a11y regression lives.

  • scopeFocus mock is registered but never asserted in any test — suggests the author considered testing restore behavior but didn't implement it.

  • The beforeEach clears only unscopeFocus but not scopeFocus. If a future test for restore is added, the mock should clear both:

beforeEach(() => {
  (focusScope.unscopeFocus as jest.Mock).mockClear();
  (focusScope.scopeFocus as jest.Mock).mockClear(); // add this
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All three points fixed:

  • Added a test for the close path: should call focusScope.resumeFocus when modal transitions from open to closed
  • The scopeFocus mock is now also cleared in beforeEach alongside pauseFocus and resumeFocus
  • Updated all existing tests from unscopeFocus to pauseFocus / resumeFocus to match the new implementation

</ModalContainer>
);

const WithDrawerExample = () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The WithDrawerExample story is excellent — it clearly demonstrates the exact failure scenario and serves as a regression canary. One nit: the story doesn't close the modal and verify the drawer still traps focus, which would expose the regression mentioned above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added verification steps to the modal body text: after closing the modal, press Tab inside the drawer and check that focus stays trapped. This makes it easy to test the post-close behaviour manually in Storybook.

…(Option B)

Replace focusScope.unscopeFocus() with pauseFocus()/resumeFocus() so that
the drawer's focus trap is restored when BpkModalV3 closes.

- focusScope: extract registerListener() helper (no initial focus steal);
  add pausedElement state; add pauseFocus() (removes listener, keeps element)
  and resumeFocus() (re-registers listener only, lets ark-ui return focus first)
- BpkModalV3Root: call pauseFocus on open, resumeFocus on close
- Tests: update BpkModalV3-test mock/assertions; add resumeFocus close test;
  add 5 new unit tests for pauseFocus/resumeFocus in focusScope-test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

- Export focusScope from bpk-scrim-utils/index.ts to make the
  cross-package dependency explicit rather than an internal src path hack
- Update BpkModalV3Root and test imports to use the package index
- Add keyboard focus trap verification steps to WithDrawer story

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@Faye-Xiao Faye (Faye-Xiao) left a comment

Choose a reason for hiding this comment

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

Thanks for the review! The Esc key and flash issue — I will verify manually in the Storybook. Will update here after testing.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

Faye (Faye-Xiao) and others added 2 commits May 27, 2026 14:39
…lashing when BpkModalV3 closes

When a BpkModalV3 is dismissed (via close button, scrim click, or Esc),
the legacy Portal class component (used by BpkDrawer/BpkModal/BpkDialog)
was simultaneously triggering its own close animation, causing a visible
flash. The root cause is that Portal registers document-level mousedown
and keydown listeners, and the ark-ui portal node is a sibling DOM node
rather than a child, so isPortalClick evaluates false for any interaction
inside the modal.

Fix: introduce a counter-based portalLock singleton in bpk-react-utils.
BpkModalV3Root calls lock() on open and unlock() via useEffect cleanup,
so legacy Portal components skip their onDocumentMouseDown / onDocumentKeyDown
handlers while the modal is visible. A counter (not a boolean) handles
nested BpkModalV3 instances correctly.

TODO: Remove portalLock and the corresponding guards in Portal.tsx once
BpkDrawer, BpkModal, and BpkDialog are deprecated. (CLOV-1643)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

1 similar comment
@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

Faye (Faye-Xiao) and others added 2 commits May 28, 2026 14:55
… animation

- BpkScrim: ignore mousedown/touchstart while portalLock is active, so a
  BpkModalV3 scrim click cannot fall through to a legacy scrim below it
- BpkModalV3Root: switch portalLock/focusScope effect dependency from isOpen
  to bodyLockOpen (delayed by durationBase ms) so the lock stays active for
  the full exit-animation duration, not just until the state change
- BpkModalV3Scrim: keep pointer-events:auto on [data-state='closed'] so the
  scrim absorbs events during its fade-out and nothing reaches legacy overlays
  sharing the same z-index (TODO: CLOV-1643 remove when legacy components gone)
- Update BpkModalV3 tests to use fake timers for the delayed-unlock assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The BpkDrawer + BpkModalV3 story is no longer needed as a test case
for CLOV-1643 compatibility work. Remove it to avoid the unrelated
BpkDrawer exit-animation timing issue (durationSm vs durationBase)
appearing as a regression in this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Patch production bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants