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
Notifications-Push notification is received, but the message is not shown in chat #99732
Version Number: 9.4.63-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/41237593 Email or phone of affected tester (no customers): N/A Issue reported by: Applause Internal Team Bug source: Regression TC Execution Device used: iPhone 15 iOS 26.6 App Component: Notifications
Action Performed:
As User A in the main testing device, navigate to the "Account settings" > "Troubleshoot"
Enable the "Force offline" toggle
As User B, send a direct message (DM) to User A
As User A, mote that push notification is received on the device
Observe the chat message in LHN
Open the chatVerify that the new message appears within the chat conversation
Expected Result:
The new message appears within the chat conversation and in LHN for User A, after receiving a notification in force offline mode
Actual Result:
Push notification is received, but the message is not shown in chat and LHN
ℹ️ This looks like expected behavior of the debug-only "Force offline" toggle, not a production bug. Details below for the BugZero/Contributor+ team to confirm before this is routed as an external job.
What is the root cause of that problem?
Push notifications are delivered by the OS (APNs), which is completely independent of the app's network layer. The "Force offline" toggle only simulates offline inside the app — it can't stop Apple from delivering the push. So you end up in a state that can't happen in real life: the phone receives a push while the app is pretending it has no connection.
When the push arrives, the handler applyOnyxData does not blindly write the message to Onyx. It runs the reliable-updates gap check in applyOnyxUpdatesReliably:
If the client is up to date (no gap), it applies the payload directly — message would show even offline.
If the client is behind (doesClientNeedToBeUpdated), it must fetch the missing updates from the server first.
Because the app has been force-offline, it stopped receiving Pusher updates, so its local update watermark is stale and a gap is effectively guaranteed. The required catch-up fetch (GetMissingOnyxMessages) is then force-failed at HttpUtils.ts:139 (FAILED_TO_FETCH when shouldForceOffline is set). The new report action never lands in Onyx → nothing in chat or LHN. Toggle Force offline back off and the message loads correctly.
In a real offline scenario (airplane mode), you would not receive the push at all — so this mismatch is an artifact of the debug simulation, not something a real user can hit.
What changes do you think we should make in order to solve the problem?
Recommended: treat as expected / not-a-bug and update the regression test case (TC 41237593) — "Force offline" is a debug-only tool (TestToolMenu), and the expected result (message appears while offline) contradicts the whole point of simulating offline.
If the team instead wants the phantom notification to be less confusing, that's a product decision, not a straightforward fix — see alternatives.
What alternative solutions did you explore? (Optional)
Why a "just apply the message anyway" fix is risky
Apply the push payload directly, bypassing the gap check: would corrupt the update-ID sequence (out-of-order apply is exactly what the reliable-updates system exists to prevent), risking wider data inconsistency for a debug-only edge case.
Optimistically render just the notified report action without touching the watermark: possible but non-trivial, and it still leaves the rest of the app in a knowingly-stale state — arguably more confusing, not less.
Suppress/annotate notifications while force-offline: the app can't intercept APNs delivery, so this isn't achievable client-side for a push already handed to the OS.
Note on verification: this is a native iOS push-notification flow, so it can't be reproduced in a web browser session; the analysis above is based on tracing the code path (two independent investigations agreed, high confidence). Runtime confirmation would come from client logs for the affected user showing the GetMissingOnyxMessages fetch being force-failed around the DM's arrival.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/99732) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
@brunovjk I traced this before it gets routed and wanted to add a couple of technical points, because I think the "expected behavior / just update the TC" framing is missing part of what's going on.
The core mechanism in the auto-proposal is right: the push handler runs the reliable-updates gap check in applyOnyxUpdatesReliably, and when the client is behind it calls GetMissingOnyxMessages, which is force-failed while Force offline is on. The notified action then gets deferred and dropped by finalizeUpdatesAndResumeQueue (clearDeferredOnyxUpdates()), so it only comes back on the next reconnect. Two corrections though:
Force offline does not stop the Pusher connection or "stop receiving updates". The socket stays connected; incoming events are explicitly ignored by the shouldForceOffline guard (native L153-L157, web L181-L185). Minor, but it matters for the next point.
The gap is not guaranteed. If the DM's previousUpdateID equals the client's watermark (client was caught up and nothing else landed in between), applyOnyxUpdatesReliably takes the no-gap branch (L51-L52) and applies the payload locally — the message does appear while Force offline is on. So the real behavior is inconsistent: whether the notified message shows offline depends on whether an unrelated update happened to land between going offline and the DM arriving. That inconsistency is what reads as a bug to a tester and is why this is flaky to reproduce.
On the fix — I don't think the only options are "risky blanket apply" vs "update the TC". The full onyxData for the notified action is already in the push payload. A narrowly-scoped option is to apply just that report-action ADD optimistically without advancing lastUpdateIDAppliedToClient, so the message the user was just notified about surfaces; on reconnect GetMissingOnyxMessages re-fetches and re-applies the same range idempotently and advances the watermark in order, so global ordering is preserved. Scoped to new-action ADDs (not edits/deletes or actions that depend on unseen state), this is idempotent and is arguably less confusing than a push banner with no matching in-app message. It's still a product call on whether a notified message should surface while offline.
If you think it's worth pursuing rather than a TC update, I'm happy to put up a full proposal with automated tests. Otherwise I agree updating TC 41237593 is the right move.
I found that the Airship payload already contains the Onyx updates needed to render the message. applyOnyxData packages those updates and passes them to the reliable-update flow.
When Pusher is disabled by Force offline, the next push can expose an update-ID gap. applyOnyxUpdatesReliably then pauses the queue and fetches the missing range instead of applying the embedded Airship data.
// Test mode where all requests will succeed in the server, but fail to return a response
if(shouldFailAllRequests||shouldForceOffline){
thrownewHttpsError({
message: CONST.ERROR.FAILED_TO_FETCH,
});
}
The deferred notification update is consequently never applied, so neither the report action nor its LHN report update reaches Onyx. This can also represent a real device receiving APNs while the Expensify API or Pusher connection is unavailable, so it should not be treated only as a debug-tool artifact.
What changes do you think we should make in order to solve the problem?
I think we should update applyOnyxData. When the client is offline, the push has usable onyxData, and its previousUpdateID reveals a gap, apply that embedded data with Onyx.update without advancing the reliable-update watermark.
The watermark must remain unchanged in this fallback. On reconnect, the normal missing-update flow will still fetch and apply the complete ordered range, including any updates absent from the notification. That preserves reliable-update recovery while allowing the self-contained message and LHN updates to render immediately.
Online notifications remain on the existing path. Contiguous offline notifications also keep the existing reliable application behavior, and payloads marked hasPendingOnyxUpdates remain unaffected because they contain no embedded data to apply.
What alternative solutions did you explore? (Optional)
I considered allowing GetMissingOnyxMessages through Force offline, but that would make the toggle perform network synchronization and undermine the simulated-offline state. I also considered advancing the watermark after applying the push payload, but that could conceal updates missing before the notification. Applying the payload without moving the watermark gives the UI its available data while retaining the existing catch-up guarantee.
I reproduced this on an iOS dev build and traced it end to end. The expectation in the test case turns out to be reachable for some notification payloads and impossible for others.
A contiguous payload applies fine while offline. With the client watermark at 15712972968 and a push carrying previousUpdateID: 15712972968, doesClientNeedToBeUpdated returned false, applyAirshipOnyxUpdates ran, and I watched the action land: Onyx merge called for key: reportActions_616188078900122 ... hasChanged: true, watermark advancing to 15712972969. No request was made at all.
What dead-ends is every payload that needs a catch-up fetch first, either a real gap or hasPendingOnyxUpdates (the server omits onyxData when the push exceeds the payload budget). Both land in fetchMissingUpdates. Here is the run with the client at 15712972968 and the push at previousUpdateID: 15712972973:
[PushNotification] reliable onyx update received with {"lastUpdateID":15712972974,"previousUpdateID":15712972973,"onyxDataCount":1}. The message content is right there in the payload.
lastUpdateIDFromClient is less than the previousUpdateID received, client needs updating, then [applyOnyxUpdatesReliably] Fetching missing updates and the queue pauses.
GetMissingOnyxMessages is sent and the force-offline throw discards the response: [Network] API request error: Failed to fetch. validateAndApplyDeferredUpdates re-detects the same gap and sends a second one, which fails identically.
[alrt] [DeferredUpdates] Aborting call to GetMissingOnyxMessages, repeated params with {"lastUpdateIDFromClient":15712972968,"latestMissingUpdateID":15712972973}. The loop guard trips because nothing moved the client.
finalizeUpdatesAndResumeQueue runs clearDeferredOnyxUpdates(), dropping the deferred entry holding the notification's own onyxData, and the queue unpauses into [SequentialQueue] Unable to flush. We are offline.
I checked the store afterwards: reportActions_616188078900122 never received the action and the watermark was still 15712972968. Nothing for the chat or the LHN to render, which is the reported symptom.
flowchart TD
A["Force offline on, push previousUpdateID 15712972973, client at 15712972968"] -->|"client needs updating"| B["fetchMissingUpdates, SequentialQueue paused"]
B -->|"response discarded by the shouldForceOffline throw"| C["GetMissingOnyxMessages, first attempt: Failed to fetch"]
C -->|"validateAndApplyDeferredUpdates re-detects the gap"| D["GetMissingOnyxMessages, second attempt: Failed to fetch"]
D -->|"Log.alert, repeated params"| E["Aborting call to GetMissingOnyxMessages"]
E -->|"clearDeferredOnyxUpdates"| F["Payload discarded, reportActions never written"]
Loading
The throw that turns both fetches into Failed to fetch:
// This function will reset the query variables, unpause the SequentialQueue and log an info to the user.
functionfinalizeUpdatesAndResumeQueue(){
console.debug('[OnyxUpdateManager] Done applying all updates');
resolveQueryPromiseWrapper();
queryPromiseWrapper=createQueryPromiseWrapper();
clearDeferredOnyxUpdates();
isFetchingForPendingUpdates=false;
}
Nothing on this path checks the offline state before committing to a recovery that is entirely network-dependent. A push is delivered by the OS, so it is the one update source that can arrive while the app's own network layer is down, and the code treats it exactly like a Pusher event that came over a working connection. With the toggle on, Pusher events are dropped at the socket handler and never get this far:
Log.info('[Pusher] Ignoring a Push event because shouldForceOffline = true');
return;
}
Which payload shape the tester's run hit is decided server-side in MobilePushNotifications.php, so I can't pin that one. Every fetch-requiring shape converges on the same dead end.
I don't think the message itself can be rendered in that state. Doing so means applying 15712972974 ahead of the updates between, the exact reordering reliable updates exists to prevent, so TC 41237593 needs amending too. Recovery already works: I turned the toggle off and the reconnect carried the client from 15712972969 to 15713086673 on its own.
* Called when shouldForceOffline changes in Onyx (debug tool).
*/
functionsetForceOffline(force: boolean){
shouldForceOffline=force;
Log.info(`[NetworkState] shouldForceOffline set to ${force}`);
updateState();
if(!force){
armReachabilityRecovery();
}
What changes do you think we should make in order to solve the problem?
Two parts, one in code and one in the test case.
In code, stop starting a catch-up fetch we already know cannot complete. applyOnyxUpdatesReliably gains an opt-in flag, and the push handler is the only caller that sets it, since it is the only one that can be invoked while the app is offline:
const fetchMissingUpdates = () => {
+ // GetMissingOnyxMessages cannot succeed while we are offline, and the recovery it drives pauses the+ // sequential queue and discards this payload on the way out. The reconnect refetches the same range.+ if (shouldSkipFetchWhenOffline && getIsOffline()) {+ Log.info('[applyOnyxUpdatesReliably] Not fetching missing updates while offline, the reconnect will close the gap');+ return Promise.resolve();+ }+
Log.info('[applyOnyxUpdatesReliably] Fetching missing updates');
SequentialQueue.pause();
I replayed the identical push against the same client state with this in place. The two GetMissingOnyxMessages attempts became zero, [applyOnyxUpdatesReliably] Not fetching missing updates while offline, the reconnect will close the gap appeared in their place, the [DeferredUpdates] Aborting call alert was gone, and the sequential queue was no longer paused and unpaused for nothing. The contiguous payload still applied to Onyx and still advanced the watermark, which is the case worth protecting here. The regression test covers both and fails on current main with Expected: 0, Received: 2.
One thing worth calling out: getIsOffline() also covers the softer states, internetUnreachable and sustainedFailuresActive, where the network may actually be usable and the old code's fetch could occasionally have gone through. I don't think that costs anything. The reachability ping polls every 5s while unreachable, any successful request clears the hard stop, and the reconnect refetches the same range, so the message arrives on the same recovery it would have anyway, without a doomed request fired from a background notification handler.
For the test case, TC 41237593 currently asserts the message appears while Force offline is enabled, which only holds when the payload happens to be contiguous:
- Verify that the new message appears while Force offline is enabled.+ Verify that the push is received while Force offline is enabled.+ Disable Force offline, then verify the message appears in chat and LHN.
What alternative solutions did you explore? (Optional)
I looked at applying the notification's onyxData anyway when the client is behind. Tempting, because the data is sitting right there in the payload, onyxDataCount: 1 in the log above. But it writes 15712972974 over a client that has not seen the updates in between, and the later reconnect then replays those on top. That is the reordering the deferred-update machinery exists to stop.
I also tried leaving the update in the deferred queue instead of discarding it, so it could apply once the gap closed. That keeps the sequential queue paused for the whole offline period, and the reconnect resupplies the same range anyway, so it buys nothing for a real cost.
I reproduced the mechanism as a runnable test on current main rather than inferring it. I had no iOS device build, so the device-level step — the Airship payload reaching applyOnyxData — is read from code; every number below came out of a run I can hand over.
The payload reaches applyOnyxUpdatesReliably, which has two outcomes:
A contiguous payload applies while the app is offline. Driving one whose previousUpdateID equals the client watermark through that function gave me fetches=0 applyCalls=1 watermark=2: no request, message written. A payload needing a catch-up first (a real gap, or hasPendingOnyxUpdates) goes to fetchMissingUpdates, and GetMissingOnyxMessages cannot return anything while Force offline is on:
// Test mode where all requests will succeed in the server, but fail to return a response
if(shouldFailAllRequests||shouldForceOffline){
thrownewHttpsError({
message: CONST.ERROR.FAILED_TO_FETCH,
});
}
The Reauthentication middleware swallows that throw for side-effect requests, so the fetch resolves with undefined. escalateIfFetchStalled deliberately steps aside for that case and hands it to "the existing retry paths":
My run on main prints exactly that: fetchArgs=[[1,4],[1,4]] — the same (updateIDFrom, updateIDTo) twice — applyCalls=0, watermark=1, and one Log.alert("[DeferredUpdates] Aborting call to GetMissingOnyxMessages, repeated params"). The payload holding the message is then discarded:
// This function will reset the query variables, unpause the SequentialQueue and log an info to the user.
functionfinalizeUpdatesAndResumeQueue(){
console.debug('[OnyxUpdateManager] Done applying all updates');
resolveQueryPromiseWrapper();
queryPromiseWrapper=createQueryPromiseWrapper();
clearDeferredOnyxUpdates();
isFetchingForPendingUpdates=false;
}
Nothing is left for the chat or the LHN to render, which is the reported symptom.
Why the repro steps look like this
Force offline is in the steps because it is the only state that fails every response while the OS still delivers the push. It also manufactures the gap: the socket stays up and events are dropped at the callback, so the watermark falls behind while updates are still arriving.
Log.info('[Pusher] Ignoring a Push event because shouldForceOffline = true');
return;
}
And since a contiguous payload does apply, whether the tester sees the message depends on whether an unrelated update landed between the toggle and the DM. That is why this reproduces inconsistently, and why TC 41237593 asserts something that holds for only one of two payload shapes.
This is not confined to the debug toggle. HttpUtils throws on every non-ok status, so a 5xx, a timeout or a dropped connection resolve to undefined too and reach the same two-requests-then-alert path.
History, and why existing tests miss it
The loop-breaker landed as Log.info in #41700 (2024-05-06) and was correct as a loop guard. 8fd34124733 (2026-07-31) promoted it to a production alert, saying "Info stays on the device, so we could not tell whether that guard has ever fired in the field." It fires here — once per push received while the API is unreachable. Separately, b40d8305695 (2026-07-06) added the !response opt-out, pointing at retry paths that were never there.
The suite has one failed-fetch test, and it reads the fetch count instead of asserting it, so the duplicate is invisible:
What changes do you think we should make in order to solve the problem?
React to the fetch's actual outcome instead of predicting it. In OnyxUpdateManager/index.ts:
if (escalateIfFetchStalled(response, lastUpdateIDFromClient)) {
return;
}
- return validateAndApplyDeferredUpdates(clientLastUpdateID);+ // No response and no movement means the fetch never reached the server, so the identical range+ // would fail the same way. Apply what is already chained, then leave the gap to the reconnect.+ const shouldRefetchMissingUpdates = !!response || getPersistedLastUpdateID() > lastUpdateIDFromClient;+ return validateAndApplyDeferredUpdates(clientLastUpdateID, undefined, shouldRefetchMissingUpdates);
and in OnyxUpdateManager/utils/index.ts, validateAndApplyDeferredUpdates takes shouldRefetchMissingUpdates = true and honours it just above the loop-breaker:
+ if (!shouldRefetchMissingUpdates) {+ Log.info('[DeferredUpdates] Not refetching missing updates after a failed fetch', false, {newLastUpdateIDFromClient, latestMissingUpdateID});+ resolve(undefined);+ return;+ }+
// Prevent info loops of calls to GetMissingOnyxMessages
Neither half works alone: without the first the util never learns the fetch failed, without the second the manager cannot both apply the already-chained deferred updates and skip the refetch. I picked this over an early return in the manager for that second reason — an early return also drops deferred updates that arrived while the fetch was in flight and are applicable now.
The getPersistedLastUpdateID() term keeps the old behaviour whenever the client did advance, which is why all 50 existing tests in OnyxUpdateManagerTest, OnyxUpdatesTest and NetworkTest still pass. fetchMissingUpdates has one caller, checkIfClientNeedsToBeUpdated, on both its branches; the util gains an optional third argument, so its other call sites are unchanged. The test mock currently drops extra arguments and needs to forward them.
Regression safety, each checked against a run rather than reasoned about:
Contiguous offline payloads still apply and still advance the watermark (fetches=0 applyCalls=1 watermark=2).
A successful fetch still recurses and applies deferred updates in order; a 200 that does not advance the client still escalates to one incremental ReconnectApp and still alerts.
Any response object, including a 407 or a 500, is untouched — !!response is true.
The SequentialQueue is still paused and unpaused exactly once, and recovery is unchanged: the reconnect refetches the same range from the unmoved watermark.
Tests, red on main and green with this change:
Gap payload plus a fetch that resolves without a response → GetMissingOnyxMessages called once with (1, 4); on main, twice with the same arguments.
Same case → Log.alert not called; on main the loop-breaker alert fires.
Contiguous offline payload → no fetch, applied, watermark advances. Passes on both, and is the guard against over-correcting.
The limit, plainly: this stops a doomed request and a false production alert, it does not make the message appear. I do not think it can appear in that state without writing lastUpdateID N over a client that has not seen the range below it, which is what this machinery exists to prevent. So TC 41237593 should assert that the push arrives while offline, and that the message appears once Force offline is turned back off.
What alternative solutions did you explore? (Optional)
Apply the notification's onyxData anyway without advancing the watermark. The data is right there, so it is tempting. But the skipped range can contain an edit or a delete of that same action, and the reconnect replays that range on top afterwards, so the user can be shown a message that was already edited or deleted. applyOnyxData cannot tell a fresh ADD from either.
Skip the fetch up front when getIsOffline() is true. Cheaper, but it predicts the outcome from a heuristic. getIsOffline() also covers internetUnreachable and sustainedFailuresActive, and the repo documents that requests succeed in exactly those states:
so it would suppress catch-up fetches that would have worked. It is also unreliable in the context this handler was written for: those flags come from an Onyx callback, and Android background pushes run where those callbacks do not.
Keep the payload deferred instead of discarding it. The reconnect refetches the same range including this update, so it buys nothing and leaves state behind.
Thank you everyone for the proposals, and sorry for the inconvenience. However, I’m starting to think this may be expected behavior rather than a bug. @deetergp could you please confirm please? Thank you.
@brunovjk Yes, I am pretty sure this is expected behavior. The payload for the message isn't contained in the push notification, and if user A was truly offline, they wouldn't get the push notification anyway.
@brunovjk Yes, I am pretty sure this is expected behavior. The payload for the message isn't contained in the push notification, and if user A was truly offline, they wouldn't get the push notification anyway.
Great, thanks a lot @deetergp. Should we close this out, or do we need to get someone else to confirm? Thanks.
Activity
applause-bot commentedon Aug 28, 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/317ae0dbf2488108c38299ac666b42d887c5580f0da2d7a241c7e0266aeaf6d3
MelvinBot commentedon Aug 28, 2026
Proposal
What is the root cause of that problem?
Push notifications are delivered by the OS (APNs), which is completely independent of the app's network layer. The "Force offline" toggle only simulates offline inside the app — it can't stop Apple from delivering the push. So you end up in a state that can't happen in real life: the phone receives a push while the app is pretending it has no connection.
When the push arrives, the handler
applyOnyxDatadoes not blindly write the message to Onyx. It runs the reliable-updates gap check inapplyOnyxUpdatesReliably:doesClientNeedToBeUpdated), it must fetch the missing updates from the server first.Because the app has been force-offline, it stopped receiving Pusher updates, so its local update watermark is stale and a gap is effectively guaranteed. The required catch-up fetch (
GetMissingOnyxMessages) is then force-failed atHttpUtils.ts:139(FAILED_TO_FETCHwhenshouldForceOfflineis set). The new report action never lands in Onyx → nothing in chat or LHN. Toggle Force offline back off and the message loads correctly.In a real offline scenario (airplane mode), you would not receive the push at all — so this mismatch is an artifact of the debug simulation, not something a real user can hit.
What changes do you think we should make in order to solve the problem?
Recommended: treat as expected / not-a-bug and update the regression test case (TC 41237593) — "Force offline" is a debug-only tool (TestToolMenu), and the expected result (message appears while offline) contradicts the whole point of simulating offline.
If the team instead wants the phantom notification to be less confusing, that's a product decision, not a straightforward fix — see alternatives.
What alternative solutions did you explore? (Optional)
Why a "just apply the message anyway" fix is risky
Note on verification: this is a native iOS push-notification flow, so it can't be reproduced in a web browser session; the analysis above is based on tracing the code path (two independent investigations agreed, high confidence). Runtime confirmation would come from client logs for the affected user showing the
GetMissingOnyxMessagesfetch being force-failed around the DM's arrival.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/99732)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run
melvin-bot commentedon Aug 28, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @brunovjk (
External)melvin-bot commentedon Aug 28, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
David-2002065 commentedon Aug 28, 2026
@brunovjk I traced this before it gets routed and wanted to add a couple of technical points, because I think the "expected behavior / just update the TC" framing is missing part of what's going on.
The core mechanism in the auto-proposal is right: the push handler runs the reliable-updates gap check in
applyOnyxUpdatesReliably, and when the client is behind it callsGetMissingOnyxMessages, which is force-failed while Force offline is on. The notified action then gets deferred and dropped byfinalizeUpdatesAndResumeQueue(clearDeferredOnyxUpdates()), so it only comes back on the next reconnect. Two corrections though:Force offline does not stop the Pusher connection or "stop receiving updates". The socket stays connected; incoming events are explicitly ignored by the
shouldForceOfflineguard (native L153-L157, web L181-L185). Minor, but it matters for the next point.The gap is not guaranteed. If the DM's
previousUpdateIDequals the client's watermark (client was caught up and nothing else landed in between),applyOnyxUpdatesReliablytakes the no-gap branch (L51-L52) and applies the payload locally — the message does appear while Force offline is on. So the real behavior is inconsistent: whether the notified message shows offline depends on whether an unrelated update happened to land between going offline and the DM arriving. That inconsistency is what reads as a bug to a tester and is why this is flaky to reproduce.On the fix — I don't think the only options are "risky blanket apply" vs "update the TC". The full
onyxDatafor the notified action is already in the push payload. A narrowly-scoped option is to apply just that report-action ADD optimistically without advancinglastUpdateIDAppliedToClient, so the message the user was just notified about surfaces; on reconnectGetMissingOnyxMessagesre-fetches and re-applies the same range idempotently and advances the watermark in order, so global ordering is preserved. Scoped to new-action ADDs (not edits/deletes or actions that depend on unseen state), this is idempotent and is arguably less confusing than a push banner with no matching in-app message. It's still a product call on whether a notified message should surface while offline.If you think it's worth pursuing rather than a TC update, I'm happy to put up a full proposal with automated tests. Otherwise I agree updating TC 41237593 is the right move.
melvin-bot commentedon Aug 31, 2026
@brunovjk Whoops! This issue is 2 days overdue. Let's get this updated quick!
brunovjk commentedon Sep 2, 2026
Not overdue, waiting for proposals.
Javokhiir commentedon Sep 2, 2026
Proposal
What is the root cause of that problem?
I found that the Airship payload already contains the Onyx updates needed to render the message.
applyOnyxDatapackages those updates and passes them to the reliable-update flow.App/src/libs/Notification/PushNotification/subscribeToPushNotifications.ts
Lines 90 to 117 in e897708
When Pusher is disabled by Force offline, the next push can expose an update-ID gap.
applyOnyxUpdatesReliablythen pauses the queue and fetches the missing range instead of applying the embedded Airship data.App/src/libs/actions/applyOnyxUpdatesReliably.ts
Lines 30 to 55 in e897708
That recovery request cannot succeed in this state because
HttpUtilsdeliberately rejects responses whileshouldForceOfflineis enabled.App/src/libs/HttpUtils.ts
Lines 137 to 143 in e897708
The deferred notification update is consequently never applied, so neither the report action nor its LHN report update reaches Onyx. This can also represent a real device receiving APNs while the Expensify API or Pusher connection is unavailable, so it should not be treated only as a debug-tool artifact.
What changes do you think we should make in order to solve the problem?
I think we should update
applyOnyxData. When the client is offline, the push has usableonyxData, and itspreviousUpdateIDreveals a gap, apply that embedded data withOnyx.updatewithout advancing the reliable-update watermark.A small illustrative change would be:
This uses the same operation already used by the normal Airship application path.
App/src/libs/actions/OnyxUpdates.ts
Lines 119 to 128 in e897708
The watermark must remain unchanged in this fallback. On reconnect, the normal missing-update flow will still fetch and apply the complete ordered range, including any updates absent from the notification. That preserves reliable-update recovery while allowing the self-contained message and LHN updates to render immediately.
Online notifications remain on the existing path. Contiguous offline notifications also keep the existing reliable application behavior, and payloads marked
hasPendingOnyxUpdatesremain unaffected because they contain no embedded data to apply.What alternative solutions did you explore? (Optional)
I considered allowing
GetMissingOnyxMessagesthrough Force offline, but that would make the toggle perform network synchronization and undermine the simulated-offline state. I also considered advancing the watermark after applying the push payload, but that could conceal updates missing before the notification. Applying the payload without moving the watermark gives the UI its available data while retaining the existing catch-up guarantee.mukhrr commentedon Sep 2, 2026
Proposal
What is the root cause of that problem?
I reproduced this on an iOS dev build and traced it end to end. The expectation in the test case turns out to be reachable for some notification payloads and impossible for others.
A contiguous payload applies fine while offline. With the client watermark at
15712972968and a push carryingpreviousUpdateID: 15712972968,doesClientNeedToBeUpdatedreturned false,applyAirshipOnyxUpdatesran, and I watched the action land:Onyx merge called for key: reportActions_616188078900122 ... hasChanged: true, watermark advancing to15712972969. No request was made at all.App/src/libs/actions/applyOnyxUpdatesReliably.ts
Lines 49 to 55 in e897708
What dead-ends is every payload that needs a catch-up fetch first, either a real gap or
hasPendingOnyxUpdates(the server omitsonyxDatawhen the push exceeds the payload budget). Both land infetchMissingUpdates. Here is the run with the client at15712972968and the push atpreviousUpdateID: 15712972973:[PushNotification] reliable onyx update receivedwith{"lastUpdateID":15712972974,"previousUpdateID":15712972973,"onyxDataCount":1}. The message content is right there in the payload.lastUpdateIDFromClient is less than the previousUpdateID received, client needs updating, then[applyOnyxUpdatesReliably] Fetching missing updatesand the queue pauses.GetMissingOnyxMessagesis sent and the force-offline throw discards the response:[Network] API request error: Failed to fetch.validateAndApplyDeferredUpdatesre-detects the same gap and sends a second one, which fails identically.[alrt] [DeferredUpdates] Aborting call to GetMissingOnyxMessages, repeated paramswith{"lastUpdateIDFromClient":15712972968,"latestMissingUpdateID":15712972973}. The loop guard trips because nothing moved the client.finalizeUpdatesAndResumeQueuerunsclearDeferredOnyxUpdates(), dropping the deferred entry holding the notification's ownonyxData, and the queue unpauses into[SequentialQueue] Unable to flush. We are offline.I checked the store afterwards:
reportActions_616188078900122never received the action and the watermark was still15712972968. Nothing for the chat or the LHN to render, which is the reported symptom.flowchart TD A["Force offline on, push previousUpdateID 15712972973, client at 15712972968"] -->|"client needs updating"| B["fetchMissingUpdates, SequentialQueue paused"] B -->|"response discarded by the shouldForceOffline throw"| C["GetMissingOnyxMessages, first attempt: Failed to fetch"] C -->|"validateAndApplyDeferredUpdates re-detects the gap"| D["GetMissingOnyxMessages, second attempt: Failed to fetch"] D -->|"Log.alert, repeated params"| E["Aborting call to GetMissingOnyxMessages"] E -->|"clearDeferredOnyxUpdates"| F["Payload discarded, reportActions never written"]The throw that turns both fetches into
Failed to fetch:App/src/libs/HttpUtils.ts
Lines 137 to 143 in e897708
And the discard on the way out:
App/src/libs/actions/OnyxUpdateManager/index.ts
Lines 157 to 166 in e897708
Nothing on this path checks the offline state before committing to a recovery that is entirely network-dependent. A push is delivered by the OS, so it is the one update source that can arrive while the app's own network layer is down, and the code treats it exactly like a Pusher event that came over a working connection. With the toggle on, Pusher events are dropped at the socket handler and never get this far:
App/src/libs/Pusher/index.native.ts
Lines 151 to 156 in e897708
Which payload shape the tester's run hit is decided server-side in
MobilePushNotifications.php, so I can't pin that one. Every fetch-requiring shape converges on the same dead end.I don't think the message itself can be rendered in that state. Doing so means applying
15712972974ahead of the updates between, the exact reordering reliable updates exists to prevent, so TC 41237593 needs amending too. Recovery already works: I turned the toggle off and the reconnect carried the client from15712972969to15713086673on its own.App/src/libs/NetworkState.ts
Lines 196 to 207 in e897708
What changes do you think we should make in order to solve the problem?
Two parts, one in code and one in the test case.
In code, stop starting a catch-up fetch we already know cannot complete.
applyOnyxUpdatesReliablygains an opt-in flag, and the push handler is the only caller that sets it, since it is the only one that can be invoked while the app is offline:const fetchMissingUpdates = () => { + // GetMissingOnyxMessages cannot succeed while we are offline, and the recovery it drives pauses the + // sequential queue and discards this payload on the way out. The reconnect refetches the same range. + if (shouldSkipFetchWhenOffline && getIsOffline()) { + Log.info('[applyOnyxUpdatesReliably] Not fetching missing updates while offline, the reconnect will close the gap'); + return Promise.resolve(); + } + Log.info('[applyOnyxUpdatesReliably] Fetching missing updates'); SequentialQueue.pause();App/src/libs/Notification/PushNotification/subscribeToPushNotifications.ts
Lines 110 to 118 in e897708
I replayed the identical push against the same client state with this in place. The two
GetMissingOnyxMessagesattempts became zero,[applyOnyxUpdatesReliably] Not fetching missing updates while offline, the reconnect will close the gapappeared in their place, the[DeferredUpdates] Aborting callalert was gone, and the sequential queue was no longer paused and unpaused for nothing. The contiguous payload still applied to Onyx and still advanced the watermark, which is the case worth protecting here. The regression test covers both and fails on current main withExpected: 0, Received: 2.One thing worth calling out:
getIsOffline()also covers the softer states,internetUnreachableandsustainedFailuresActive, where the network may actually be usable and the old code's fetch could occasionally have gone through. I don't think that costs anything. The reachability ping polls every 5s while unreachable, any successful request clears the hard stop, and the reconnect refetches the same range, so the message arrives on the same recovery it would have anyway, without a doomed request fired from a background notification handler.For the test case, TC 41237593 currently asserts the message appears while Force offline is enabled, which only holds when the payload happens to be contiguous:
What alternative solutions did you explore? (Optional)
I looked at applying the notification's
onyxDataanyway when the client is behind. Tempting, because the data is sitting right there in the payload,onyxDataCount: 1in the log above. But it writes15712972974over a client that has not seen the updates in between, and the later reconnect then replays those on top. That is the reordering the deferred-update machinery exists to stop.I also tried leaving the update in the deferred queue instead of discarding it, so it could apply once the gap closed. That keeps the sequential queue paused for the whole offline period, and the reconnect resupplies the same range anyway, so it buys nothing for a real cost.
yusufdeveloper2903 commentedon Sep 2, 2026
Proposal
What is the root cause of that problem?
I reproduced the mechanism as a runnable test on current
mainrather than inferring it. I had no iOS device build, so the device-level step — the Airship payload reachingapplyOnyxData— is read from code; every number below came out of a run I can hand over.The payload reaches
applyOnyxUpdatesReliably, which has two outcomes:App/src/libs/actions/applyOnyxUpdatesReliably.ts
Lines 46 to 55 in d693503
A contiguous payload applies while the app is offline. Driving one whose
previousUpdateIDequals the client watermark through that function gave mefetches=0 applyCalls=1 watermark=2: no request, message written. A payload needing a catch-up first (a real gap, orhasPendingOnyxUpdates) goes tofetchMissingUpdates, andGetMissingOnyxMessagescannot return anything while Force offline is on:App/src/libs/HttpUtils.ts
Lines 140 to 146 in d693503
The Reauthentication middleware swallows that throw for side-effect requests, so the fetch resolves with
undefined.escalateIfFetchStalleddeliberately steps aside for that case and hands it to "the existing retry paths":App/src/libs/actions/OnyxUpdateManager/index.ts
Lines 75 to 80 in d693503
No such path exists. Control falls into
validateAndApplyDeferredUpdatesunconditionally:App/src/libs/actions/OnyxUpdateManager/index.ts
Lines 146 to 155 in d693503
which re-detects the same gap from the same client state, fires the identical request a second time, and gives up at the 2024 loop-breaker:
App/src/libs/actions/OnyxUpdateManager/utils/index.ts
Lines 149 to 160 in d693503
My run on
mainprints exactly that:fetchArgs=[[1,4],[1,4]]— the same(updateIDFrom, updateIDTo)twice —applyCalls=0,watermark=1, and oneLog.alert("[DeferredUpdates] Aborting call to GetMissingOnyxMessages, repeated params"). The payload holding the message is then discarded:App/src/libs/actions/OnyxUpdateManager/index.ts
Lines 157 to 166 in d693503
Nothing is left for the chat or the LHN to render, which is the reported symptom.
Why the repro steps look like this
Force offline is in the steps because it is the only state that fails every response while the OS still delivers the push. It also manufactures the gap: the socket stays up and events are dropped at the callback, so the watermark falls behind while updates are still arriving.
App/src/libs/Pusher/index.native.ts
Lines 152 to 156 in d693503
And since a contiguous payload does apply, whether the tester sees the message depends on whether an unrelated update landed between the toggle and the DM. That is why this reproduces inconsistently, and why TC 41237593 asserts something that holds for only one of two payload shapes.
This is not confined to the debug toggle.
HttpUtilsthrows on every non-okstatus, so a 5xx, a timeout or a dropped connection resolve toundefinedtoo and reach the same two-requests-then-alert path.History, and why existing tests miss it
The loop-breaker landed as
Log.infoin #41700 (2024-05-06) and was correct as a loop guard.8fd34124733(2026-07-31) promoted it to a production alert, saying "Info stays on the device, so we could not tell whether that guard has ever fired in the field." It fires here — once per push received while the API is unreachable. Separately,b40d8305695(2026-07-06) added the!responseopt-out, pointing at retry paths that were never there.The suite has one failed-fetch test, and it reads the fetch count instead of asserting it, so the duplicate is invisible:
App/tests/unit/OnyxUpdateManagerTest.ts
Lines 625 to 639 in d693503
What changes do you think we should make in order to solve the problem?
React to the fetch's actual outcome instead of predicting it. In
OnyxUpdateManager/index.ts:if (escalateIfFetchStalled(response, lastUpdateIDFromClient)) { return; } - return validateAndApplyDeferredUpdates(clientLastUpdateID); + // No response and no movement means the fetch never reached the server, so the identical range + // would fail the same way. Apply what is already chained, then leave the gap to the reconnect. + const shouldRefetchMissingUpdates = !!response || getPersistedLastUpdateID() > lastUpdateIDFromClient; + return validateAndApplyDeferredUpdates(clientLastUpdateID, undefined, shouldRefetchMissingUpdates);and in
OnyxUpdateManager/utils/index.ts,validateAndApplyDeferredUpdatestakesshouldRefetchMissingUpdates = trueand honours it just above the loop-breaker:Neither half works alone: without the first the util never learns the fetch failed, without the second the manager cannot both apply the already-chained deferred updates and skip the refetch. I picked this over an early return in the manager for that second reason — an early return also drops deferred updates that arrived while the fetch was in flight and are applicable now.
The
getPersistedLastUpdateID()term keeps the old behaviour whenever the client did advance, which is why all 50 existing tests inOnyxUpdateManagerTest,OnyxUpdatesTestandNetworkTeststill pass.fetchMissingUpdateshas one caller,checkIfClientNeedsToBeUpdated, on both its branches; the util gains an optional third argument, so its other call sites are unchanged. The test mock currently drops extra arguments and needs to forward them.Regression safety, each checked against a run rather than reasoned about:
fetches=0 applyCalls=1 watermark=2).ReconnectAppand still alerts.!!responseis true.Tests, red on
mainand green with this change:GetMissingOnyxMessagescalled once with(1, 4); onmain, twice with the same arguments.Log.alertnot called; onmainthe loop-breaker alert fires.The limit, plainly: this stops a doomed request and a false production alert, it does not make the message appear. I do not think it can appear in that state without writing
lastUpdateIDN over a client that has not seen the range below it, which is what this machinery exists to prevent. So TC 41237593 should assert that the push arrives while offline, and that the message appears once Force offline is turned back off.What alternative solutions did you explore? (Optional)
Apply the notification's
onyxDataanyway without advancing the watermark. The data is right there, so it is tempting. But the skipped range can contain an edit or a delete of that same action, and the reconnect replays that range on top afterwards, so the user can be shown a message that was already edited or deleted.applyOnyxDatacannot tell a fresh ADD from either.Skip the fetch up front when
getIsOffline()is true. Cheaper, but it predicts the outcome from a heuristic.getIsOffline()also coversinternetUnreachableandsustainedFailuresActive, and the repo documents that requests succeed in exactly those states:App/src/libs/NetworkState.ts
Lines 35 to 39 in d693503
so it would suppress catch-up fetches that would have worked. It is also unreliable in the context this handler was written for: those flags come from an Onyx callback, and Android background pushes run where those callbacks do not.
App/src/libs/Notification/PushNotification/subscribeToPushNotifications.ts
Lines 110 to 117 in d693503
Keep the payload deferred instead of discarding it. The reconnect refetches the same range including this update, so it buys nothing and leaves state behind.
brunovjk commentedon Sep 3, 2026
Thank you everyone for the proposals, and sorry for the inconvenience. However, I’m starting to think this may be expected behavior rather than a bug. @deetergp could you please confirm please? Thank you.
🎀 👀 🎀 C+ reviewed
melvin-bot commentedon Sep 3, 2026
Triggered auto assignment to @deetergp, see https://stackoverflow.com/c/expensify/questions/7972 for more details.
deetergp commentedon Sep 4, 2026
@brunovjk Yes, I am pretty sure this is expected behavior. The payload for the message isn't contained in the push notification, and if user A was truly offline, they wouldn't get the push notification anyway.
melvin-bot commentedon Sep 7, 2026
@deetergp Whoops! This issue is 2 days overdue. Let's get this updated quick!
brunovjk commentedon Sep 8, 2026
Great, thanks a lot @deetergp. Should we close this out, or do we need to get someone else to confirm? Thanks.
deetergp commentedon Sep 8, 2026
Nah, let's close it.