Repository navigation
[$175] Web - App review modal opens without the dimmed backdrop #104039
Description
Problem
On web staging, the "Are you enjoying New Expensify?" app review modal opens without the dimmed backdrop. Other confirm modals show the dimmed backdrop. This modal uses the same CONFIRM modal type, so it should look the same.
Reproduction steps
This has not been reproduced on demand yet.
- Sign in to staging on web with an account that qualifies for the app review prompt. Your account qualifies when
nvp_appReviewhas atrigger,responseisn'tpositive, andlastPromptis empty or more than 30 days old (useProactiveAppReview.ts:29-46). - Reload the app so the modal opens during startup.
- Look at the area behind the modal.
To test locally, set nvp_appReview in Onyx to {trigger: 'test'} with no lastPrompt or response, then reload.
Expected result
A dimmed backdrop covers the page behind the modal, like other confirm modals.
Actual result
The modal card shows, but the page behind it isn't dimmed.
Possible cause (unconfirmed)
- The web backdrop fades in with a Reanimated
Keyframe(Backdrop/index.web.tsx:28-34). Reanimated keeps the element hidden until the keyframe animation starts. - On web, the animation lasts only 1 ms (animationTiming/index.ts:3). Reanimated may remove the generated keyframe before the animation starts if the main thread is busy.
- This modal opens on its own during startup, through lazy-loaded global modals (GlobalModals.tsx:32-43). The main thread is busiest then. Most other modals open after a click, when the page is idle.
- The modal card still shows because it fades in a different way (Container/index.web.tsx:51-52).
To confirm, inspect the backdrop element in DevTools. If it has visibility: hidden and an animation-name that isn't defined in the page's styles, this is the cause.
Platforms
Reported on web (staging). Other platforms not checked yet.
Slack thread: https://expensify.slack.com/archives/C05LX9D6E07/p1791888055462809
Upwork Automation - Do Not Edit
- Upwork Job URL: https://www.upwork.com/jobs/~022110466409538484967
- Upwork Job ID: 2110466409538484967
- Last Price Increase: 2026-10-14
Activity
melvin-bot commentedon Oct 14, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @parasharrajat (
External)[-]Web - App review modal opens without the dimmed backdrop[/-][+][$175] Web - App review modal opens without the dimmed backdrop[/+]melvin-bot commentedon Oct 14, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022110466409538484967
janiya2 commentedon Oct 14, 2026
Contributor details
Your Expensify account email: janithad2@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/janithad
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Web, the "Are you enjoying New Expensify?" app review modal (and other modals triggered during startup) opens without the dimmed backdrop, leaving the background undimmed. Meanwhile, confirm modals triggered by direct user interaction show the dimmed backdrop properly.
What is the root cause of that problem?
In
src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx:28-34, the backdrop's entering animation is implemented using a ReanimatedKeyframe:and attached via
entering={Entering}onAnimated.View:Here is the exact sequence causing the bug on Web:
react-native-reanimatedweb implementation, mounting anAnimated.Viewwith anenteringKeyframedynamically generates a CSS@keyframesrule, injects it into a document<style>tag, and assigns inline stylesanimation-name: ...andvisibility: hiddento the DOM element until the CSS animation begins.animationInTimingdefaults toDEFAULT_IN = 1ms (src/libs/Animation/animationTiming/index.ts:3).useProactiveAppReview.tsinsideGlobalModals.tsx:32-43(deferred throughrequestIdleCallbackwithstartTransition).visibility: hidden, leaving the backdrop invisible.Container/index.web.tsx:52-53already encountered and solved this exact Reanimated web keyframe flaw in PR Fix test drive modal not moving when keyboard openes #62799:Backdrop/index.web.tsx, however, was never updated to follow this fix and was left with the fragileKeyframeentering animation.What changes do you think we should make in order to solve the problem?
Align
src/components/Modal/ReanimatedModal/Backdrop/index.web.tsxwith the established, battle-tested pattern inContainer/index.web.tsx: replace theKeyframeentering animation withuseAnimatedStyleandwithTiming.Specifically, in
src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx:react-native-reanimated:EnteringKeyframe with aninitProgressshared value driven bywithTiming:Animated.Viewelements to removeentering={Entering}and applyanimatedBackdropStyle:Why this is the superior solution:
@keyframesinsertion and the stuck inlinevisibility: hiddenbug on web.Container/index.web.tsx(Fix test drive modal not moving when keyboard openes #62799).What alternative solutions did you explore? (Optional)
animationInTimingto 300ms on Web: This would introduce an unwanted 300ms visual delay on Web modals, which contradicts Expensify's standard design of instant (1ms) web modal presentation, and still does not guarantee safety if main-thread lag exceeds 300ms.useAnimatedStylewithwithTimingis standard in Expensify's ReanimatedModal pipeline and maintains clean exit animation handling (exiting={Exiting}) and dynamicbackdropOpacitysupport.Unit / Manual Verification Steps
opacity: 0.72and does not havevisibility: hidden.melvin-bot commentedon Oct 14, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
taieddie commentedon Oct 14, 2026
Contributor details
Your Expensify account email: taieddie8@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~01af851c9a7f771775
Proposal
Please re-state the problem that we are trying to solve in this issue.
When the "Are you enjoying New Expensify?" app review modal (
ProactiveAppReviewModal) opens automatically on Web, the modal card appears without its dimmed backdrop.What is the root cause of that problem?
On Web,
ReanimatedModalrenders the modal card viasrc/components/Modal/ReanimatedModal/Container/index.web.tsxand the backdrop viasrc/components/Modal/ReanimatedModal/Backdrop/index.web.tsx, but the two components use completely different animation mechanisms:Container/index.web.tsx(lines 25-53, PR#62799), the modal card's entrance animation was explicitly migrated away from a ReanimatedenteringKeyframeto a shared value (initProgress = useSharedValue(0)) animated bywithTiming(1, {duration: animationInTiming, easing, reduceMotion: ReduceMotion.Never})and rendered viauseAnimatedStyle. As noted by the inline comment on line 52:Web implementation triggers reanimated's progress progression manually cleanly instead of entering prop. BecausewithTimingupdates inline opacity directly without settingvisibility: hidden, the modal card always renders reliably.Backdrop/index.web.tsx(lines 28-34, 64, and 78), the backdrop was never migrated to matchContainer/index.web.tsxand still relies on anenteringKeyframe(new Keyframe(getModalInAnimation('fadeIn', backdropOpacity)).duration(animationInTiming).reduceMotion(ReduceMotion.Never)).Keyframeentering animation initializes the DOM element withvisibility: hiddenuntil the CSS animation start event fires to restorevisibility: initial. As noted by the issue reporter, on WebanimationInTimingdefaults toCONST.MODAL.ANIMATION_TIMING.DEFAULT_IN = 1ms (src/libs/Animation/animationTiming/index.ts), which is passed toBackdropwhen no custom duration is provided. With a 1ms duration or when main-thread activity during startup delays the browser paint cycle, the keyframe animation finishes or is cleaned up beforeanimationstartcan fire. Because of this,visibility: initialis never restored, leaving the backdrop DOM element stuck atvisibility: hiddenbehind the visible container.What changes do you think we should make in order to solve the problem?
Bring
src/components/Modal/ReanimatedModal/Backdrop/index.web.tsxinto full architectural alignment withsrc/components/Modal/ReanimatedModal/Container/index.web.tsx:src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx:EnteringKeyframememo with a shared value:const initProgress = useSharedValue(0).useEffectwatchingisBackdropVisible,backdropOpacity, andanimationInTiming:isBackdropVisiblebecomes true andbackdropOpacity > 0:initProgress.set(0)so that repeated opens or visibility toggles always trigger a fresh fade-in.initProgress.set(withTiming(1, {duration: animationInTiming, easing, reduceMotion: ReduceMotion.Never})).isBackdropVisiblebecomes false, allowexiting={Exiting}(new Keyframe(getModalOutAnimation('fadeOut', backdropOpacity))) to handle the exit animation smoothly down to 0, exactly matchingContainer/index.web.tsxlines 55-69.backdropStylememo with an animated style:const animatedStyles = useAnimatedStyle(() => ({opacity: initProgress.get() * backdropOpacity}), [initProgress, backdropOpacity]).animatedStylesto<Animated.View style={[styles.modalBackdrop, backdropStyle, style, animatedStyles]} exiting={Exiting} />in both the default andcustomBackdropbranches, and remove theentering={Entering}prop.What alternative solutions did you explore? (Optional)
Keyframeduration (e.g. Enforcing a minimum duration of 120ms or 200ms instead of 1ms on Web): While this reduces the likelihood of the 1ms race condition, ReanimatedKeyframeentering animations on Web can still suffer from keyframe injection and visibility-lock issues under main-thread contention.BackdroptouseSharedValueandwithTiming: This is the preferred solution because it completely eliminates theKeyframevisibility: hiddenvulnerability on Web and matches the existing pattern inContainer/index.web.tsx(PR#62799).Test Plan
Preconditions:
nvp_appReviewin Onyx to trigger the modal:Onyx.set('nvp_appReview', {trigger: 'test'}).Verification Steps:
opacityanimates smoothly to0.72andvisibilityis nothidden.Regression Verification:
melvin-bot commentedon Oct 14, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
khizzcr7 commentedon Oct 15, 2026
Proposal
What is the root cause of the problem?
On web, the modal backdrop's entering animation is a Reanimated
Keyframeattached viaentering={Entering}insrc/components/Modal/ReanimatedModal/Backdrop/index.web.tsx. Reanimated's web implementation injects a CSS@keyframesrule and sets the element tovisibility: hiddenuntil the animation starts. The app-review modal opens automatically at startup (deferred throughrequestIdleCallbackinuseProactiveAppReview), when the main thread is saturated with Onyx reconciliation, font loading, and lazy chunk evaluation - and the animation duration is only 1ms (DEFAULT_IN). The CSS animation lifecycle never resolves cleanly under that load, so the backdrop element stays stuck atvisibility: hiddenand the background never dims. Modals opened by direct user interaction don't hit this because the main thread is idle. Notably,Container/index.web.tsxalready fixed this exact Reanimated-web keyframe flaw (for the test-drive modal keyboard issue) by switching touseAnimatedStyle- the backdrop was never updated to match.How do you propose to fix it?
Align
Backdrop/index.web.tsxwith the proven pattern inContainer/index.web.tsx: replace theKeyframeentering animation with aninitProgressshared value driven bywithTiming(animationInTiming), and apply opacity throughuseAnimatedStyle(opacity: initProgress * backdropOpacity). Keep the existingexitinganimation andbackdropOpacitysupport, and remove theenteringprop from theAnimated.View. This eliminates the CSS keyframe injection entirely, so there is novisibility: hiddenstate to get stuck in.What will you do to verify the fix works?
nvp_appReviewto{trigger: 'test'}, reload web, and confirm the review modal opens on startup WITH the dimmed backdrop (DevTools shows the backdrop at full opacity, novisibility: hidden).Contributor details
Your Expensify account email: khizar.akram.ka@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~016c4e09f2cf8b7d6d
melvin-bot commentedon Oct 15, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
kks0488 commentedon Oct 16, 2026
Reproduced on web (
main@ bf2fb24, Chrome).Steps
nvp_appReviewto{trigger: 'test'}in Onyx so the "Enjoying New Expensify?" modal opens on load.requestAnimationFrameis paused but timers keep running).Result
visibility: visible,opacity: 0.72, page is dimmed.opacity: 0.72, but its computedvisibilitystayshiddenand itsREA-ENTERING-*@keyframesrule is already gone from the stylesheets, so the card shows with no dim — same as the video in the OP.Note: on desktop web this modal animates with
CENTERED_DURATION_IN_WEB= 120 ms (computedanimation-duration: 0.12son the live element), not 1 ms. Plain CPU throttling (6x/20x) did not reproduce it; it depends on frame timing vs. the timer that removes the keyframe rule.Happy to post a full proposal once this is ready for one.
1 remaining item
kks0488 commentedon Oct 16, 2026
Contributor details
Your Expensify account email: kks0488@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0197507708d4b35a03
Proposal
Please re-state the problem that we are trying to solve in this issue.
On web, the "Enjoying New Expensify?" modal sometimes opens with no dim behind it.
What is the root cause of that problem?
I reproduced this on
main(bf2fb24). The cause is how Reanimated runs the backdrop'senteringKeyframe on web (Backdrop/index.web.tsx#L28-L34, used at L62-L66). In react-native-reanimated 4.7.0:enteringis rendered withvisibility: hidden(createAnimatedComponent/AnimatedComponent.tsxL409-L419). Only theanimationstartevent makes it visible again (layoutReanimation/web/componentUtils.tsL268-L272).animation-nameis applied insiderequestAnimationFrame(componentUtils.tsL213).@keyframesrule is deleted by a plainsetTimeoutaftermax(duration * 5, ~160ms)(componentUtils.tsL276-L277,domUtils.tsL121-L141).On desktop web this modal uses
CENTERED_DURATION_IN_WEB= 120 ms, not 1 ms (Modal/index.tsx#L49, CONST#L2348). I confirmedanimation-duration: 0.12son the live element. So the rule is deleted about 600 ms after the backdrop mounts.If the next animation frame comes later than that,
animation-namepoints at a rule that no longer exists. That happens during a busy startup, which is when this modal auto-opens. It also happens in a background tab, whererequestAnimationFrameis paused but timers still run. In that caseanimationstartnever fires and the backdrop staysvisibility: hiddenuntil the modal closes. The modal card is fine becauseContainer/index.web.tsxalready uses a shared value instead ofentering.Evidence (set
nvp_appReviewto{trigger: 'test'}in Onyx, then reload):visibility: visibleandopacity: 0.72, and the page is dimmed.opacity: 0.72. But its computedvisibilityishidden, and itsREA-ENTERING-*@keyframesrule is no longer in any stylesheet. Result: the card shows with no dim, which matches the report.What changes do you think we should make in order to solve the problem?
Don't use a Reanimated layout animation for the web backdrop's fade-in. Fade it in with a plain CSS opacity transition instead. Nothing then hides the element, and nothing depends on a rule that a timer deletes.
Backdrop/index.web.tsx, removeentering={Entering}from bothAnimated.Views.opacity: 0andtransition: opacity <animationInTiming>msusing the same easing curve. Right after mount (in an effect, notrequestAnimationFrame), switch the opacity tobackdropOpacity. The browser then runs the fade on its own. If frames are late, the dim simply shows up at the end. It is never stuck hidden.exitingas it is. The exit animation never hides the element ahead of time.Backdrop: it should never rendervisibility: hidden, and it should end atbackdropOpacity.I tried this locally. With frames delayed the same way, the backdrop is visible at
opacity: 0.72. With normal frames it still fades 0 → 0.72 in about 120 ms using the existing easing. Only the web file changes, so native is untouched.What alternative solutions did you explore? (Optional)
withTiming, likeContainer/index.web.tsxdoes. This also works, but it puts a per-frame JS animation where a CSS transition is enough.melvin-bot commentedon Oct 16, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
mountiny commentedon Oct 16, 2026
Assigning @nkdengineer as they can reproduce
nkdengineer commentedon Oct 19, 2026
Proposal
What is the root cause of that problem?
The web backdrop fades in through a Reanimated
Keyframepassed asentering:App/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx
Lines 28 to 34 in 56583c8
On web, Reanimated runs an entering keyframe like this:
visibility: hidden.https://github.com/software-mansion/react-native-reanimated/blob/d36edbce863e3bfa63bad010247d03c2d330a367/packages/react-native-reanimated/src/createAnimatedComponent/AnimatedComponent.tsx#L408-L419
requestAnimationFrame.https://github.com/software-mansion/react-native-reanimated/blob/d36edbce863e3bfa63bad010247d03c2d330a367/packages/react-native-reanimated/src/layoutReanimation/web/componentUtils.ts#L210-L216
setTimeoutthat deletes the generated@keyframesrule. Our web animation lasts only 1 ms (DEFAULT_IN = 1), so the timeout is about 160 ms.https://github.com/software-mansion/react-native-reanimated/blob/d36edbce863e3bfa63bad010247d03c2d330a367/packages/react-native-reanimated/src/layoutReanimation/web/domUtils.ts#L125-L140
onanimationstart. The cleanup doesn't restore visibility.https://github.com/software-mansion/react-native-reanimated/blob/d36edbce863e3bfa63bad010247d03c2d330a367/packages/react-native-reanimated/src/layoutReanimation/web/componentUtils.ts#L268-L285
So if the next animation frame comes after the 160 ms cleanup, the animation is applied using a keyframe that no longer exists. It never starts,
animationstartnever fires, and the backdrop staysvisibility: hiddenforever.This happens when:
requestAnimationFramethere but still run timers. I can reproduce this reliably: open the app in two tabs and trigger the modal while the tab is not focused. When you switch to that tab, the modal shows without the backdrop. If the tab is focused when the modal opens, the backdrop shows normally.GlobalModals, while most modals open after a click when the page is idle.The modal card still shows because
Containerdoesn't use anenteringkeyframe. It animates a shared value instead:App/src/components/Modal/ReanimatedModal/Container/index.web.tsx
Lines 33 to 53 in 56583c8
What changes do you think we should make in order to solve the problem?
In
Backdrop/index.web.tsx, fade the backdrop in the same wayContaineralready fades the card in, and stop using theenteringkeyframe:progress. In an effect, animate it to1withwithTiming(same duration, easing andReduceMotion.Neveras before) whenisBackdropVisibleis true, and reset it to0when it's false so the fade runs again if the modal reopens.opacitystyle withuseAnimatedStyle:opacity = progress * backdropOpacity.entering={Entering}from bothAnimated.Views. Keep theExitingkeyframe as it is, because exiting animations are applied right away and don't have this race.Without an
enteringanimation, Reanimated never hides the element. A late animation frame can only delay the fade, so the backdrop can't get stuck hidden. This fixes every modal that uses this backdrop, not just the app review modal. Native isn't affected.What alternative solutions did you explore? (Optional)
nkdengineer commentedon Oct 19, 2026
@MelvinBot Let review this proposal.
MelvinBot commentedon Oct 19, 2026
Proposal review
The root cause is correct and the fix is sound. I recommend accepting it, with two small notes below.
Root cause verified. I checked each step against Reanimated 4.7.0, the version in App:
requestAnimationFrame(componentUtils.ts:210-216).@keyframesrule is deleted by a timer. With our 1 ms web duration, the timer ismax(5, 160.001)≈ 160 ms (domUtils.ts:125-140).onanimationstartrestoresvisibility. The cleanup callback doesn't touch it for entering animations (componentUtils.ts:268-285).So a frame that arrives after the timer leaves the backdrop hidden for good. The background-tab repro fits this well. Background tabs pause
requestAnimationFramebut still run timers. Tab restore or opening the app in a background tab is also a realistic way users hit this at startup.Fix. Using a shared value plus
withTimingmatches what Container/index.web.tsx:52-53 already does to avoid entering keyframes. It also fixes every modal that uses this backdrop. Keeping theExitingkeyframe is fine, because exiting animations are applied right away withoutrequestAnimationFrame.Notes for the PR:
style(Backdrop/index.web.tsx:63). That way callers that overrideopacitystill behave the same.Backdropstays mounted whileisBackdropVisibletoggles (ReanimatedModal/index.tsx:229).I verified this from the code only. I didn't run the repro in a browser.
view run
nkdengineer commentedon Oct 19, 2026
@MelvinBot Implement this
MelvinBot commentedon Oct 19, 2026
🤖 Draft PR created: Fix web modal backdrop staying hidden when it opens during a delayed animation frame
Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.
The PR follows your proposal. It changes
Backdrop/index.web.tsxso the backdrop fades in with a shared value andwithTiminginstead of theenteringkeyframe. TheExitingkeyframe is unchanged.It also adds a unit test that checks the web backdrop renders without an
enteringanimation. The test fails onmainand passes with the fix.Lint, typecheck, formatting, spelling, the React Compiler check, and the related modal tests all pass. I couldn't check it in the browser because the test session showed a blank page before any modal opened. Please run the background-tab repro and a close-and-reopen check before you mark it ready.
view run · no recording available