[CLOV-1606][BpkModalV3] Fix infinite focus redirect loop when BpkModalV3 and BpkDrawer coexist#4635
[CLOV-1606][BpkModalV3] Fix infinite focus redirect loop when BpkModalV3 and BpkDrawer coexist#4635Faye (Faye-Xiao) wants to merge 8 commits into
Conversation
…lV3 and BpkDrawer coexist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
Ezreal Yang (Supremeyh)
left a comment
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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:
- BpkModalV3Root opens → focusScope.unscopeFocus() called ✅
- ark-ui focus trap takes over the modal ✅
- User closes BpkModalV3 → BpkModalV3Root unmounts / isOpen becomes false
- focusScope listener is gone and never restored ❌
- 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
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| describe('Root', () => { | ||
| beforeEach(() => { |
There was a problem hiding this comment.
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
});
There was a problem hiding this comment.
All three points fixed:
- Added a test for the close path:
should call focusScope.resumeFocus when modal transitions from open to closed - The
scopeFocusmock is now also cleared inbeforeEachalongsidepauseFocusandresumeFocus - Updated all existing tests from
unscopeFocustopauseFocus/resumeFocusto match the new implementation
| </ModalContainer> | ||
| ); | ||
|
|
||
| const WithDrawerExample = () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
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>
Faye (Faye-Xiao)
left a comment
There was a problem hiding this comment.
Thanks for the review! The Esc key and flash issue — I will verify manually in the Storybook. Will update here after testing.
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
…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>
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
1 similar comment
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
… 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>
|
Visit https://backpack.github.io/storybook-prs/4635 to see this build running in a browser. |
Description
When a
BpkDrawer(which uses the legacywithScrim/focusScopefocus management system) is open at the same time as aBpkModalV3(which uses ark-ui's@zag-js/focus-trap), both systems simultaneously listen tofocusinondocument. This causes an infinite focus redirect loop that overflows the call stack.Root cause:
focusScoperegisters a bubble-phasefocusinlistener that synchronously callselement.focus().@zag-js/focus-trapregisters a capture-phase listener but does not callstopImmediatePropagationwhen focus is already inside the modal. The result is a recursivefocusindispatch loop (~8 frames per iteration) that overflows the call stack after ~100–200 iterations.Fix: When
BpkModalV3Rootopens, it callsfocusScope.unscopeFocus()to deactivate the legacy listener before ark-ui's focus trap takes over. This is a compatibility patch that keeps both systems stable whileBpkDrawerhas not yet been migrated to ark-ui.Changes
BpkModalV3Root.tsx: CallfocusScope.unscopeFocus()in auseEffectwhenisOpenbecomestrue, with a comment explaining why.BpkModalV3-test.tsx: MockfocusScopeand add 3 test cases covering: modal opens (unscopeFocuscalled), modal closed (unscopeFocusnot called), and closed → open transition.BpkModalV3.stories.tsx: AddWithDrawerstory demonstratingBpkModalV3opened from within aBpkDrawer.Test Screenshots
Tested it in local carhire
Checklist
[CLOV-1606][BpkModalV3] Fix infinite focus redirect loop when BpkModalV3 and BpkDrawer coexistREADME.md(If you have created a new component) — N/A, no new componentREADME.md— N/AWithDrawerstory added tobpk-component-modal-v3