Skip to content

Notifications-Push notification is received, but the message is not shown in chat #99732

Closed

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


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:

  1. As User A in the main testing device, navigate to the "Account settings" > "Troubleshoot"
  2. Enable the "Force offline" toggle
  3. As User B, send a direct message (DM) to User A
  4. As User A, mote that push notification is received on the device
  5. Observe the chat message in LHN
  6. 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

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

Bug7244715_1787873769393.Message_offline.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Mita Rachim
Bug ID: 7244715

Issue OwnerCurrent Issue Owner: @deetergp

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Aug 28, 2026
  2. applause-bot commented on Aug 28, 2026

    @applause-bot
    Author

    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

  3. MelvinBot commented on Aug 28, 2026

    @MelvinBot
    Contributor

    Proposal

    ℹ️ 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.


    view run

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Aug 28, 2026
  5. melvin-bot commented on Aug 28, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @brunovjk (External)

  6. melvin-bot commented on Aug 28, 2026

    @melvin-bot

    Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.

  7. David-2002065 commented on Aug 28, 2026

    @David-2002065
    Contributor

    @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:

    1. 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.

    2. 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.

  8. melvin-bot commented on Aug 31, 2026

    @melvin-bot

    @brunovjk Whoops! This issue is 2 days overdue. Let's get this updated quick!

  9. brunovjk commented on Sep 2, 2026

    @brunovjk
    Contributor

    Not overdue, waiting for proposals.

  10. Javokhiir commented on Sep 2, 2026

    @Javokhiir

    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. applyOnyxData packages those updates and passes them to the reliable-update flow.

    } else {
    const isDataMissing = !lastUpdateID || !onyxData || !previousUpdateID;
    logMissingOnyxDataInfo(isDataMissing);
    if (isDataMissing) {
    return Promise.resolve();
    }
    updates = {
    type: CONST.ONYX_UPDATE_TYPES.AIRSHIP,
    lastUpdateID,
    previousUpdateID,
    updates: [
    {
    eventType: '', // This is only needed for Pusher events
    data: onyxData,
    },
    ],
    };
    }
    /**
    * When this callback runs in the background on Android (via Headless JS), no other Onyx.connect callbacks will run. This means that
    * lastUpdateIDAppliedToClient will NOT be populated in other libs. To workaround this, we manually read the value here
    * and pass it as a param
    */
    return getLastUpdateIDAppliedToClient()
    .then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient}))
    .then(() => NativeModules.PushNotificationBridge?.finishBackgroundProcessing());

    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.

    const fetchMissingUpdates = () => {
    Log.info('[applyOnyxUpdatesReliably] Fetching missing updates');
    // If we got here, that means we are missing some updates on our local storage. To
    // guarantee that we're not fetching more updates before our local data is up to date,
    // let's stop the sequential queue from running until we're done catching up.
    SequentialQueue.pause();
    if (shouldRunSync) {
    return handleMissingOnyxUpdates(updates, clientLastUpdateID);
    }
    return Promise.resolve(saveUpdateInformation(updates));
    };
    // If a pendingLastUpdateID is was provided, it means that the backend didn't send updates because the payload was too big.
    // In this case, we need to fetch the missing updates up to the pendingLastUpdateID.
    if (updates.shouldFetchPendingUpdates) {
    return fetchMissingUpdates();
    }
    const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID;
    if (!doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) {
    return onyxApply(updates).then();
    }
    return fetchMissingUpdates();

    That recovery request cannot succeed in this state because HttpUtils deliberately rejects responses while shouldForceOffline is enabled.

    App/src/libs/HttpUtils.ts

    Lines 137 to 143 in e897708

    .then((response) => {
    // Test mode where all requests will succeed in the server, but fail to return a response
    if (shouldFailAllRequests || shouldForceOffline) {
    throw new HttpsError({
    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.

    A small illustrative change would be:

    + if (getIsOffline() && onyxData && doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID: lastUpdateIDAppliedToClient})) {
    +     return Onyx.update(onyxData);
    + }
      return applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient});

    This uses the same operation already used by the normal Airship application path.

    function applyAirshipOnyxUpdates<TKey extends OnyxKey>(updates: Array<OnyxUpdateEvent<TKey>>, lastUpdateID: number) {
    airshipEventsPromise = airshipEventsPromise.then(() => {
    Log.info('[OnyxUpdateManager] Applying Airship updates', false, {lastUpdateID});
    });
    airshipEventsPromise = updates
    .reduce((promise, update) => promise.then(() => Onyx.update(update.data as Array<OnyxUpdate<TKey>>)), airshipEventsPromise)
    .then(() => {
    Log.info('[OnyxUpdateManager] Done applying Airship updates', false, {lastUpdateID});
    });

    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.

  11. mukhrr commented on Sep 2, 2026

    @mukhrr
    Contributor

    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 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.

    const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID;
    if (!doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) {
    return onyxApply(updates).then();
    }
    return fetchMissingUpdates();

    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:

    App/src/libs/HttpUtils.ts

    Lines 137 to 143 in e897708

    .then((response) => {
    // Test mode where all requests will succeed in the server, but fail to return a response
    if (shouldFailAllRequests || shouldForceOffline) {
    throw new HttpsError({
    message: CONST.ERROR.FAILED_TO_FETCH,
    });
    }

    And the discard on the way out:

    // This function will reset the query variables, unpause the SequentialQueue and log an info to the user.
    function finalizeUpdatesAndResumeQueue() {
    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:

    const chunkedDataEvents: Record<string, ChunkedDataEvents> = {};
    const callback = (eventData: EventData<EventName>) => {
    if (shouldForceOffline) {
    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).
    */
    function setForceOffline(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();

    /**
    * When this callback runs in the background on Android (via Headless JS), no other Onyx.connect callbacks will run. This means that
    * lastUpdateIDAppliedToClient will NOT be populated in other libs. To workaround this, we manually read the value here
    * and pass it as a param
    */
    return getLastUpdateIDAppliedToClient()
    .then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient}))
    .then(() => NativeModules.PushNotificationBridge?.finishBackgroundProcessing());
    }

    -.then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient}))
    +.then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient, shouldSkipFetchWhenOffline: true}))

    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.

  12. yusufdeveloper2903 commented on Sep 2, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

    What is the root cause of that problem?

    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:

    if (updates.shouldFetchPendingUpdates) {
    return fetchMissingUpdates();
    }
    const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID;
    if (!doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) {
    return onyxApply(updates).then();
    }
    return fetchMissingUpdates();

    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:

    App/src/libs/HttpUtils.ts

    Lines 140 to 146 in d693503

    .then((response) => {
    // Test mode where all requests will succeed in the server, but fail to return a response
    if (shouldFailAllRequests || shouldForceOffline) {
    throw new HttpsError({
    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":

    function escalateIfFetchStalled(response: Awaited<ReturnType<typeof getMissingOnyxUpdates>>, lastUpdateIDFromClient: number): boolean {
    // A failed or unanswered fetch proves nothing about the server's ability to serve the range,
    // so leave it to the existing retry paths.
    if (!response || response.jsonCode !== 200) {
    return false;
    }

    No such path exists. Control falls into validateAndApplyDeferredUpdates unconditionally:

    function fetchMissingUpdates(lastUpdateIDFromClient: number, updateIDTo: number | string | undefined, clientLastUpdateID?: number) {
    setMissingOnyxUpdatesQueryPromise(
    getMissingOnyxUpdates(lastUpdateIDFromClient, updateIDTo).then((response) => {
    if (escalateIfFetchStalled(response, lastUpdateIDFromClient)) {
    return;
    }
    return validateAndApplyDeferredUpdates(clientLastUpdateID);
    }),
    );
    }

    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:

    // Prevent info loops of calls to GetMissingOnyxMessages
    if (previousParams?.newLastUpdateIDFromClient === newLastUpdateIDFromClient && previousParams?.latestMissingUpdateID === latestMissingUpdateID) {
    Log.alert('[DeferredUpdates] Aborting call to GetMissingOnyxMessages, repeated params', {lastUpdateIDFromClient, latestMissingUpdateID, previousParams});
    resolve(undefined);
    return;
    }
    // Then we can fetch the missing updates and apply them
    getMissingOnyxUpdates(newLastUpdateIDFromClient, latestMissingUpdateID)
    .then(() => validateAndApplyDeferredUpdates(undefined, {newLastUpdateIDFromClient, latestMissingUpdateID}))
    .then(() => resolve(undefined))
    .catch(reject);

    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.
    function finalizeUpdatesAndResumeQueue() {
    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.

    const callback = (eventData: EventData<EventName>) => {
    if (shouldForceOffline) {
    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:

    it('should not escalate to ReconnectApp when the gap fetch fails', async () => {
    // A failed fetch proves nothing about the server's ability to serve the range, so it must neither
    // escalate nor latch the back-off: the next gap push retries the fetch as before.
    App.mockValues.missingOnyxUpdatesResponse = {jsonCode: 407};
    OnyxUpdateManager.handleMissingOnyxUpdates(update3);
    await OnyxUpdateManager.queryPromise;
    expect(App.reconnectApp).not.toHaveBeenCalled();
    const fetchCallsAfterFailure = App.getMissingOnyxUpdates.mock.calls.length;
    OnyxUpdateManager.handleMissingOnyxUpdates(update5);
    await OnyxUpdateManager.queryPromise;
    expect(App.getMissingOnyxUpdates.mock.calls.length).toBeGreaterThan(fetchCallsAfterFailure);
    expect(App.reconnectApp).not.toHaveBeenCalled();
    });

    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:

    1. Gap payload plus a fetch that resolves without a response → GetMissingOnyxMessages called once with (1, 4); on main, twice with the same arguments.
    2. Same case → Log.alert not called; on main the loop-breaker alert fires.
    3. 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:

    // Wire FailureTracker → NetworkState so sustained failures trigger offline state and a
    // successful request clears the INTERNET_UNREACHABLE hard stop. Without the latter only
    // the Ping could clear it. Reads and side-effect commands bypass the paused queue, so the
    // app could keep succeeding at real requests while staying stuck offline.
    onSustainedFailureChange((active) => setSustainedFailures(active));

    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.

    /**
    * When this callback runs in the background on Android (via Headless JS), no other Onyx.connect callbacks will run. This means that
    * lastUpdateIDAppliedToClient will NOT be populated in other libs. To workaround this, we manually read the value here
    * and pass it as a param
    */
    return getLastUpdateIDAppliedToClient()
    .then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient}))
    .then(() => NativeModules.PushNotificationBridge?.finishBackgroundProcessing());

    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.

  13. brunovjk commented on Sep 3, 2026

    @brunovjk
    Contributor

    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

  14. melvin-bot commented on Sep 3, 2026

    @melvin-bot

    Triggered auto assignment to @deetergp, see https://stackoverflow.com/c/expensify/questions/7972 for more details.

  15. deetergp commented on Sep 4, 2026

    @deetergp
    Contributor

    @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.

  16. melvin-bot commented on Sep 7, 2026

    @melvin-bot

    @deetergp Whoops! This issue is 2 days overdue. Let's get this updated quick!

  17. brunovjk commented on Sep 8, 2026

    @brunovjk
    Contributor

    @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.

  18. deetergp commented on Sep 8, 2026

    @deetergp
    Contributor

    Nah, let's close it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions