Fix focus-trap crash when a bottom-docked modal's initialFocus ref is null (Sentry APP-C26)#92747
Conversation
|
@bernhardoj Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
| trapStack: sharedTrapStack, | ||
| clickOutsideDeactivates: true, | ||
| initialFocus, | ||
| initialFocus: getSafeInitialFocus, |
There was a problem hiding this comment.
Let's revert this one, I don't think we need this.
Feels like we are recreating the focus-trap getNodeForOption.
I think it's the consumer's responsibility to make sure they don't pass 0 or '' or invalid element. If in the future there is a bug where the initial focus is an invalid element, someone will patch getSafeInitialFocus to check the validity of the element, and we will end up with an "inferior getNodeForOption".
For null, though, we can actually prevent this by type-checking. However, the focus-trap codebase accepts () => void as the initial focus, which accepts () => null.
There was a problem hiding this comment.
Reverted the FocusTrapForModal layer — you're right, that's the consumer's responsibility and it would've drifted into an inferior getNodeForOption over time. Kept the fix at the source: BaseModal now coerces the bottom-docked getter's null to false. On the checklist + recordings next.
b33cdc5 to
6773e36
Compare
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb Chromeandroid.mweb.mp4iOS: HybridAppiOS: mWeb Safariios.mweb.mp4MacOS: Chrome / Safariweb.mp4 |
|
@trasnake87 please complete the checklist (including recordings) and address #92747 (comment) |
JmillsExpensify
left a comment
There was a problem hiding this comment.
No product review required.
6773e36 to
866e1b7
Compare
|
@bernhardoj done on all three:
Ready for re-review. |
866e1b7 to
abf69d2
Compare
|
Update: added the regression test ( |
| // Capture the props BaseModal passes to the underlying modal (specifically `initialFocus`). | ||
| // Render null so the bottom-docked dismiss button never mounts — i.e. its ref stays `null`, | ||
| // which is exactly the state that makes focus-trap throw "`initialFocus` was specified but was | ||
| // not a node" (Sentry APP-C26). The fix must coerce that `null` to `false`. |
There was a problem hiding this comment.
I don't like that we need to put the sentry number reference or the issue number reference (line 28)
| isInLandscapeMode: false, | ||
| })); | ||
|
|
||
| describe('BaseModal — bottom-docked initialFocus null guard (#92415)', () => { |
There was a problem hiding this comment.
Btw, can we create a BaseModalTest file that will contain all BaseModal test cases instead of creating 1 file for 1 test case?
Then we need to mock the ReanimatedModal for this specific test case only instead for the whole test suite.
|
Also, lint fails |
abf69d2 to
286212f
Compare
|
@bernhardoj addressed both — renamed to a general |
| describe('BaseModal', () => { | ||
| afterEach(() => { | ||
| jest.dontMock('@components/Modal/ReanimatedModal'); | ||
| jest.dontMock('@hooks/useResponsiveLayout'); |
There was a problem hiding this comment.
I think jest.resetModules(); is simpler here instead of dontMock.
| // modal, the dismiss-button ref can be `null` by the time focus-trap reads it (the read is deferred), so | ||
| // the getter must coerce that `null` to `false`. The mocks are scoped to this test (via jest.doMock) so | ||
| // they don't leak into other BaseModal cases. | ||
| let captured: Record<string, unknown> = {}; |
There was a problem hiding this comment.
Let's use ReanimatedModalProps as the props type and for this specifically, add | undefined (ReanimatedModalProps | undefined).
| // the getter must coerce that `null` to `false`. The mocks are scoped to this test (via jest.doMock) so | ||
| // they don't leak into other BaseModal cases. | ||
| let captured: Record<string, unknown> = {}; | ||
| jest.doMock('@hooks/useResponsiveLayout', () => ({ |
There was a problem hiding this comment.
We don't need this because by default, test runs on small screen
Lines 12 to 21 in 380da60
| </BaseModal>, | ||
| ); | ||
|
|
||
| const initialFocus = captured.initialFocus as (() => unknown) | undefined; |
There was a problem hiding this comment.
| const initialFocus = captured.initialFocus as (() => unknown) | undefined; | |
| const initialFocus = captured?.initialFocus; | |
| expect(typeof initialFocus).toBe('function'); | |
| // dismiss button never mounted -> ref.current is null -> the getter resolves to false (no crash) | |
| expect((initialFocus as () => unknown)()).toBe(false); |
|
Also, please move the file under |
…dals For a bottom-docked modal, BaseModal passes () => bottomDockedDismissButtonRef.current as initialFocus. focus-trap reads it on a deferred setTimeout, by which point the dismiss button can be unmounted, so the getter returns null — and focus-trap throws on a null (vs false/undefined). Coerce the null to false at the source, with a regression test.
|
@bernhardoj all five addressed — moved to |
286212f to
1cb4a97
Compare
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 @mountiny has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/mountiny in version: 9.4.1-0 🚀
Bundle Size Analysis (Sentry): |
Help site review — no changes requiredI reviewed the changes in this PR against Expensify's help site files under Conclusion: No help site documentation changes are required, so I did not create a draft PR. WhyThis is an internal, web-only bug fix with no user-facing behavior change:
There are no new or renamed features, tabs, settings, buttons, or workflows — nothing that the customer-facing help site describes. The help articles document product features and how-to flows, none of which are affected by this crash fix. @trasnake87, since no help site changes are required, there is no linked help site PR to review. If you believe a docs update is warranted, reply with details and I'll create one. |
Explanation of Change
On web (narrow / mobile viewport), opening a bottom-docked popover or menu on
/home— the FAB "create" menu, a report-action context menu, the emoji picker, attachment options (all render as bottom sheets on small screens) — could crash the screen with:(Sentry APP-C26, ~153 users / 171 events, non-deterministic.)
Root cause.
BaseModalpasses() => bottomDockedDismissButtonRef.currentasinitialFocusfor bottom-docked modals (src/components/Modal/BaseModal.tsx). focus-trap readsinitialFocuson a deferredsetTimeout, and by then the dismiss button can already be unmounted — the sheet was dismissed quickly, or a relayout (resize / rotate / keyboard) flippedshouldShowBottomDockedDismissButton— so the getter returnsnull. focus-trap'sgetNodeForOptiontreatsundefined("auto-focus first tabbable") andfalse("skip initial focus") as valid, but throws on any other falsy value (null/''/0). So the transientnullcrashes the trap.Fix (two layers).
BaseModalcoerces the getter'snulltofalseat the source:() => bottomDockedDismissButtonRef.current ?? false.FocusTrapForModal(web) normalizes any throwinginitialFocusvalue tofalseat the single choke point every modal trap flows through, so no current or future caller can crash the trap.false/undefinedand real nodes pass through unchanged.Fixed Issues
$ #92415
PROPOSAL: #92415 (comment)
Tests
tests/unit/FocusTrapForModalTest.tsx: aninitialFocusgetter that returnsnull(and a barenull) now resolves tofalseinstead of crashing; a real node andfalsepass through unchanged. The new cases returnnull(and would crash focus-trap) without this change./home): repeatedly open and quickly dismiss the FAB create menu, a report-action context menu, and the emoji picker; rotate / resize the window while one is opening. The screen no longer throws theinitialFocuserror.Offline tests
N/A — this is focus/UI behavior with no network dependency.
QA Steps
/home.`initialFocus` was specified but was not a nodeerror appears in the console.PR Author Checklist
Note: this is a web-only fix — focus-trap doesn't run on native — so the native platform items below are N/A (the change can't affect native); verified on web (recording in Screenshots/Videos).
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
92415_web_fab_menu.mp4