You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-09-17] [Exploratory] Notifications-User is navigated to the Home page after opening notification and app is killed #98520
Version Number: 9.4.53-0 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:https://test-management.browserstack.com/projects/2219752/folder/13176925/test-cases/41237591 Email or phone of affected tester (no customers): N/A Issue reported by: Applause Internal Team Bug source: Exploratory - Significant User Experience Deterioration Device used: iPhone 15 iOS 26.5.2 App Component: Notifications
Action Performed:
As User A on a main testing device, kill the app
As User B on the secondary device, send a message in 1:1 chat with User A
As User A, tap the push-notification
Expected Result:
User is navigated to the correct chat after tapping the notification
Actual Result:
User is navigated to the Home page instead of the correct chat after opening a push notification while the app is killed
navigateToReport cancels the navigation before it ever runs, because it decides "a transition is already in flight" from a disk-persisted Onyx flag rather than from the app's actual state:
// When transitioning to the new experience via the singleNewDotEntry flow, the navigation// is handled elsewhere. So we cancel here to prevent double navigation.if(isSingleNewDotEntry){Log.info('[PushNotification] Not navigating because this is a singleNewDotEntry flow',false,{reportID});return;}
isSingleNewDotEntry is cached from ONYXKEYS.HYBRID_APP at subscribeToPushNotifications.ts:51-61. That key is durable, and nothing can ever turn the flag back off from the App side:
It is not in initialKeyStates or ramOnlyKeys, so it is written to disk and is not reset on boot — src/setup/index.ts:43-92.
The native handoff payload is applied with Onyx.METHOD.MERGE, so a payload that simply omitsisSingleNewDotEntry cannot clear a stale true — src/libs/actions/Session/index.ts:807-813.
No non-test code in App ever writes the flag to false.
So once the flag is true — either because it went stale, or because the native side legitimately sets it for a notification launch — every push tap on that install is silently dropped. Nothing navigates, so the user is left wherever the cold boot put them, which is Home. There is no error, only an Log.info line.
This is a regression in how the guard is written. It started out route-scoped, which made it safe:
11f879cb19d ("post-merge fixes", 2025-03-04) swapped that for the bare flag read, and CONST.HYBRID_APP.SINGLE_NEW_DOT_ENTRY no longer exists anywhere in the repo. Every other consumer of this flag still pairs it with a route check — for example src/components/ScreenWrapper/index.tsx:207 ANDs it with initialURLMatchesActiveRoute. The push-notification guard is the only one that lost that safety.
⚠️Confidence: medium. The mechanism is code-verified end to end, but I could not confirm it is what fired here. I had no production logs available in this run, and the value the native side puts in the handoff payload on a killed-app notification launch is decided outside this repo, so I can't verify it. See alternatives below.
Why the timing/race theories don't hold, and why iOS only
Airship listener bound too late.PushNotification.init() attaches the Airship listener at line 33beforeonSelected populates the handler map at lines 36-43, which looks like a cold-start race. It isn't reachable: the buffered launch event replay round-trips through the native bridge and awaits takePendingEvents, while lines 36-43 run synchronously in the same tick. Ruled out.
waitForProtectedRoutes() hanging. This is exactly what caused the Android sibling issue, but that code is unchanged since the 2026-05-28 revert and is not gated on TabNavigator in this build. Ruled out.
iOS only. On Android, PushReceived can run via Headless JS while the app is killed, so report data is already in Onyx by the time the tap is handled. iOS has no such path — nothing runs until the full RN app boots, so the tap navigation is entirely dependent on this one code path. That asymmetry is consistent with the report being iOS-only, though it does not prove the guard is at fault.
What changes do you think we should make in order to solve the problem?
Stop treating a persisted flag as evidence of an in-flight transition. In navigateToReport, only skip navigation when the app is actually sitting on the single-entry transition route, mirroring what ScreenWrapper already does — pair isSingleNewDotEntry with a current-route or initialURL check instead of reading it raw.
Two things worth doing alongside it:
Add a fallback. The guard's premise is that "the navigation is handled elsewhere." On a push tap there is no evidence anything else has the target reportID, so when the guard skips, the notification is lost with no recovery. If the guard stays, it needs a path that still reaches the report.
Move the transient flags off a durable key.src/types/onyx/HybridApp.ts:26-65 holds isSingleNewDotEntry, closingReactNativeApp, readyToShowAuthScreens, signingInWithSAML and transitionStartTimestamp — all in-flight state — on a key that is disk-persisted and preserved across Onyx.clear(). App already has a RAM_ONLY_* convention for exactly this (src/setup/index.ts:70-92).
Two cheap things that would confirm this before anyone writes code:
Search production logs for [PushNotification] Not navigating because this is a singleNewDotEntry flow on iOS cold starts. Its presence confirms this root cause outright and sizes the impact. If it's absent, check for [PushNotification] onSelected() - Navigation is ready. Navigating... to see whether navigation was attempted at all.
Confirm from the BrowserStack test case whether this was the HybridApp build or the standalone NewDot build. On standalone, CONFIG.IS_HYBRID_APP is false, HYBRID_APP is never populated, and this theory is void.
Note on scope: the guard itself is in App and fixable here. But whether the flag is stale or is legitimately set on a notification launch is decided by the native OldDot side, which lives outside this repo. If the log line above confirms the flag is firing, an internal engineer with access to that repository should confirm which of the two it is, since that determines whether the App-side fix is sufficient or the handoff payload also needs correcting.
What alternative solutions did you explore? (Optional)
Two other candidates, both unresolved
Airship iOS SDK bump.Expensify/App#92497 (merged 2026-06-08, present in 9.4.53-0) took the Airship iOS native SDK from 19.8.2 to 20.7.0 — the SDK that owns cold-start notification buffering and replay. On the App side it touched only Podfile, Podfile.lock and project.pbxproj, with zero Swift changes, so any behavioral change is inside the SDK or its companion native PR, neither of which I can read. Its QA steps only covered a warm tap, never a killed-app tap. If the log line above is absent, this becomes the leading candidate and needs an internal engineer.
Empty reportID in the payload.src/ROUTES.ts:2506-2509 — REPORT_WITH_ID.getRoute() returns ROUTES.HOME on a falsy reportID, so an empty-string reportID would navigate the user to Home explicitly. This is a literal "lands on Home" path, but it needs the sender to emit '' (the type declares reportID: number), and it would log Invalid reportID is used to build the REPORT_WITH_ID route. Worth grepping for alongside the line above; I could not verify the sender.
Related issues
Expensify/App#92138 — "Android: Tapping push notification on cold start opens last active chat instead of the notification's chat" (closed). Same class of bug: a cold-start push tap that never reaches the target chat. It was closed on 2026-06-01 as a regression from a PR that got reverted — no forward fix was ever merged, and the only proposed fix PR was closed unmerged. Given that, this issue may be worth treating as a continuation rather than a new bug. The differing destination ("last active chat" vs "Home") is explained by Expensify/App#97446 (2026-07-30) changing what the root path resolves to.
Expensify/App#99732 — "Push notification is received, but the message is not shown in chat" (open). Possibly related; same subsystem, different symptom.
Process note: this area has had three reverts in ten weeks. Any fix here should include an explicit killed-app tap in its QA steps — the warm tap works and is what has been getting tested.
I could not verify this on a device: the forced test platform for this run is web, and the web PushNotification module is a no-op stub, so a killed-app iOS push tap is not reproducible there.
💡 Suggested AI code review rule
This looks like a recurring, mechanically detectable pattern, so it may be worth a coding-standards rule so the AI reviewer catches the next one. Proposed for Expensify/App:
Rule:CONSISTENCY-18 — Transient state belongs in a RAM-only Onyx key
Condition: Flag a boolean or timestamp field that represents an in-flight operation (isSingleNewDotEntry, closingReactNativeApp, signingIn*, readyToShow*, transition*Timestamp) when it lives on a disk-persisted Onyx key. Either move it to a RAM_ONLY_* key, or require every consumer to AND it with a current-route or initialURL check.
Why: A persisted flag outlives the operation it describes. Onyx.merge cannot clear an omitted key, and KEYS_TO_PRESERVE carries it through sign-out, so one stale true silently disables a feature forever with no error — the mechanism behind this issue.
Search pattern: fields in src/types/onyx/*.ts matching /^\s*(is|should|has|closing|signingIn|readyTo)[A-Za-z]*\??:\s*boolean/ whose key appears in KEYS_TO_PRESERVE but not in ramOnlyKeys; then check each consumer for an accompanying route/URL guard.
The full rule file, with the ❌/✅ code inline in App's coding-standards format, would go in Expensify/App itself. If it's worth adding, a maintainer can open it there as a draft PR.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/98520) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
changed the title [-][Exploratory] Notifications-User is navigated to the Home page after opening notification and app is killed[/-][+][Due for payment 2026-09-17] [Exploratory] Notifications-User is navigated to the Home page after opening notification and app is killed[/+]on Sep 10, 2026
The solution for this issue has been 🚀 deployed to production 🚀 in version 9.4.75-3 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:
If no regressions arise, payment will be issued on 2026-09-17. 🎊
The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the Contributor+ Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]. If no checklist is needed for this issue, you can click: [no checklist button]
Activity
applause-bot commentedon Aug 13, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/f123b7c2f7c2f7da29264dfa85c198f9dd18590387867a8284680f58e79e3eff
MelvinBot commentedon Sep 8, 2026
Proposal
What is the root cause of that problem?
navigateToReportcancels the navigation before it ever runs, because it decides "a transition is already in flight" from a disk-persisted Onyx flag rather than from the app's actual state:src/libs/Notification/PushNotification/subscribeToPushNotifications.ts:129isSingleNewDotEntryis cached fromONYXKEYS.HYBRID_APPatsubscribeToPushNotifications.ts:51-61. That key is durable, and nothing can ever turn the flag back off from the App side:initialKeyStatesorramOnlyKeys, so it is written to disk and is not reset on boot —src/setup/index.ts:43-92.KEYS_TO_PRESERVE, so it survivesOnyx.clear()on sign-out —src/libs/actions/App.ts:155.Onyx.METHOD.MERGE, so a payload that simply omitsisSingleNewDotEntrycannot clear a staletrue—src/libs/actions/Session/index.ts:807-813.false.So once the flag is
true— either because it went stale, or because the native side legitimately sets it for a notification launch — every push tap on that install is silently dropped. Nothing navigates, so the user is left wherever the cold boot put them, which is Home. There is no error, only anLog.infoline.This is a regression in how the guard is written. It started out route-scoped, which made it safe:
11f879cb19d("post-merge fixes", 2025-03-04) swapped that for the bare flag read, andCONST.HYBRID_APP.SINGLE_NEW_DOT_ENTRYno longer exists anywhere in the repo. Every other consumer of this flag still pairs it with a route check — for examplesrc/components/ScreenWrapper/index.tsx:207ANDs it withinitialURLMatchesActiveRoute. The push-notification guard is the only one that lost that safety.Why the timing/race theories don't hold, and why iOS only
PushNotification.init()attaches the Airship listener at line 33 beforeonSelectedpopulates the handler map at lines 36-43, which looks like a cold-start race. It isn't reachable: the buffered launch event replay round-trips through the native bridge and awaitstakePendingEvents, while lines 36-43 run synchronously in the same tick. Ruled out.waitForProtectedRoutes()hanging. This is exactly what caused the Android sibling issue, but that code is unchanged since the 2026-05-28 revert and is not gated on TabNavigator in this build. Ruled out.PushReceivedcan run via Headless JS while the app is killed, so report data is already in Onyx by the time the tap is handled. iOS has no such path — nothing runs until the full RN app boots, so the tap navigation is entirely dependent on this one code path. That asymmetry is consistent with the report being iOS-only, though it does not prove the guard is at fault.What changes do you think we should make in order to solve the problem?
Stop treating a persisted flag as evidence of an in-flight transition. In
navigateToReport, only skip navigation when the app is actually sitting on the single-entry transition route, mirroring whatScreenWrapperalready does — pairisSingleNewDotEntrywith a current-route orinitialURLcheck instead of reading it raw.Two things worth doing alongside it:
reportID, so when the guard skips, the notification is lost with no recovery. If the guard stays, it needs a path that still reaches the report.src/types/onyx/HybridApp.ts:26-65holdsisSingleNewDotEntry,closingReactNativeApp,readyToShowAuthScreens,signingInWithSAMLandtransitionStartTimestamp— all in-flight state — on a key that is disk-persisted and preserved acrossOnyx.clear(). App already has aRAM_ONLY_*convention for exactly this (src/setup/index.ts:70-92).Two cheap things that would confirm this before anyone writes code:
[PushNotification] Not navigating because this is a singleNewDotEntry flowon iOS cold starts. Its presence confirms this root cause outright and sizes the impact. If it's absent, check for[PushNotification] onSelected() - Navigation is ready. Navigating...to see whether navigation was attempted at all.CONFIG.IS_HYBRID_APPis false,HYBRID_APPis never populated, and this theory is void.What alternative solutions did you explore? (Optional)
Two other candidates, both unresolved
Airship iOS SDK bump. Expensify/App#92497 (merged 2026-06-08, present in 9.4.53-0) took the Airship iOS native SDK from 19.8.2 to 20.7.0 — the SDK that owns cold-start notification buffering and replay. On the App side it touched only
Podfile,Podfile.lockandproject.pbxproj, with zero Swift changes, so any behavioral change is inside the SDK or its companion native PR, neither of which I can read. Its QA steps only covered a warm tap, never a killed-app tap. If the log line above is absent, this becomes the leading candidate and needs an internal engineer.Empty
reportIDin the payload.src/ROUTES.ts:2506-2509—REPORT_WITH_ID.getRoute()returnsROUTES.HOMEon a falsyreportID, so an empty-stringreportIDwould navigate the user to Home explicitly. This is a literal "lands on Home" path, but it needs the sender to emit''(the type declaresreportID: number), and it would logInvalid reportID is used to build the REPORT_WITH_ID route. Worth grepping for alongside the line above; I could not verify the sender.Related issues
I could not verify this on a device: the forced test platform for this run is web, and the web
PushNotificationmodule is a no-op stub, so a killed-app iOS push tap is not reproducible there.💡 Suggested AI code review rule
This looks like a recurring, mechanically detectable pattern, so it may be worth a coding-standards rule so the AI reviewer catches the next one. Proposed for
Expensify/App:CONSISTENCY-18— Transient state belongs in a RAM-only Onyx keyisSingleNewDotEntry,closingReactNativeApp,signingIn*,readyToShow*,transition*Timestamp) when it lives on a disk-persisted Onyx key. Either move it to aRAM_ONLY_*key, or require every consumer to AND it with a current-route orinitialURLcheck.Onyx.mergecannot clear an omitted key, andKEYS_TO_PRESERVEcarries it through sign-out, so one staletruesilently disables a feature forever with no error — the mechanism behind this issue.src/types/onyx/*.tsmatching/^\s*(is|should|has|closing|signingIn|readyTo)[A-Za-z]*\??:\s*boolean/whose key appears inKEYS_TO_PRESERVEbut not inramOnlyKeys; then check each consumer for an accompanying route/URL guard.subscribeToPushNotifications.ts:129ScreenWrapper/index.tsx:207The full rule file, with the ❌/✅ code inline in App's coding-standards format, would go in
Expensify/Appitself. If it's worth adding, a maintainer can open it there as a draft PR.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/98520)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
melvin-bot commentedon Sep 8, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @abdulrahuman5196 (
External)melvin-bot commentedon Sep 8, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
9 remaining items
[-][Exploratory] Notifications-User is navigated to the Home page after opening notification and app is killed[/-][+][Due for payment 2026-09-17] [Exploratory] Notifications-User is navigated to the Home page after opening notification and app is killed[/+]melvin-bot commentedon Sep 10, 2026
@abdulrahuman5196
The solution for this issue has been 🚀 deployed to production 🚀 in version 9.4.75-3 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:
getRootState, so notification waits until navigation is ready #100618If no regressions arise, payment will be issued on 2026-09-17. 🎊
The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the Contributor+ Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]. If no checklist is needed for this issue, you can click: [no checklist button]
melvin-bot commentedon Sep 14, 2026
@abdulrahuman5196 Whoops! This issue is 2 days overdue. Let's get this updated quick!
melvin-bot commentedon Sep 16, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Sep 16, 2026
Payment Summary
Resolving PRs:
fix: use
getRootState, so notification waits until navigation is ready #100618Reviewer: @abdulrahuman5196 owed $250 via NewDot
Contributor: @war-in does not require payment (Contractor)
BugZero Checklist (@mallenexpensify)
melvin-bot commentedon Sep 21, 2026
@mallenexpensify Eep! 4 days overdue now. Issues have feelings too...
mallenexpensify commentedon Sep 22, 2026
Closing. @abdulrahuman5196 doesn't look like you're due payment here, comment and tag me if that's not correct. Thx