From f31c83cb786aeb3f802a1c151fe1b3e3237803bf Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 27 Feb 2026 20:48:24 +0000 Subject: [PATCH 1/4] Fix stale lastReadTime in offline ReadNewestAction causing reports to appear unread When messages are sent offline and then the report is read (also offline), ReadNewestAction captures lastReadTime at that moment. On reconnect, the server assigns later timestamps to the offline-sent messages, making lastVisibleActionCreated > lastReadTime, which incorrectly shows the report as unread. This fix refreshes lastReadTime at send time in SequentialQueue, but only when the same report had offline outgoing messages earlier in the flush cycle, avoiding auto-reading messages from other users. Co-authored-by: Situ Chandra Shil --- src/libs/Network/SequentialQueue.ts | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 5e5eda99a5377..802bf7f1cb312 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -18,6 +18,7 @@ import {flushQueue, isEmpty} from '@libs/actions/QueuedOnyxUpdates'; import {isClientTheLeader} from '@libs/ActiveClientManager'; import {WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; +import NetworkConnection from '@libs/NetworkConnection'; import {processWithMiddleware} from '@libs/Request'; import RequestThrottle from '@libs/RequestThrottle'; import CONST from '@src/CONST'; @@ -26,6 +27,13 @@ import type OnyxRequest from '@src/types/onyx/Request'; import type {AnyOnyxUpdate, AnyRequest, ConflictData} from '@src/types/onyx/Request'; import {isOffline, onReconnection} from './NetworkStore'; +// Commands that create visible report actions whose timestamps the server may reassign +const OUTGOING_MESSAGE_COMMANDS: ReadonlySet = new Set([WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]); + +// Tracks report IDs that had offline outgoing message commands processed during the current flush cycle. +// Used to detect when an offline ReadNewestAction has a stale lastReadTime that needs refreshing. +const reportsWithOfflineSentMessages = new Set(); + let shouldFailAllRequests: boolean; // Use connectWithoutView since this is for network data and don't affect to any UI Onyx.connectWithoutView({ @@ -189,6 +197,30 @@ function process(): Promise { }, }); + // When offline messages were sent for a report and then ReadNewestAction was queued while still offline, + // the lastReadTime captured at that offline moment becomes stale. The server assigns new timestamps to the + // offline-sent messages during replay, which end up later than the stale lastReadTime, causing the report + // to incorrectly appear as unread. Refresh lastReadTime to current time so it covers the server-assigned + // timestamps. We only do this when the same report had offline messages earlier in this flush cycle to + // avoid auto-reading messages from other users in reports where the user didn't send anything offline. + if ( + requestToProcess.command === WRITE_COMMANDS.READ_NEWEST_ACTION && + requestToProcess.initiatedOffline && + typeof requestToProcess.data?.reportID === 'string' && + reportsWithOfflineSentMessages.has(requestToProcess.data.reportID) + ) { + const refreshedLastReadTime = NetworkConnection.getDBTimeWithSkew(); + Log.info('[SequentialQueue] Refreshing lastReadTime for offline ReadNewestAction', false, { + reportID: requestToProcess.data.reportID, + originalLastReadTime: requestToProcess.data.lastReadTime, + refreshedLastReadTime, + }); + requestToProcess.data = { + ...requestToProcess.data, + lastReadTime: refreshedLastReadTime, + }; + } + // Set the current request to a promise awaiting its processing so that getCurrentRequest can be used to take some action after the current request has processed. currentRequestPromise = processWithMiddleware(requestToProcess, true) .then((response) => { @@ -211,6 +243,16 @@ function process(): Promise { }); endPersistedRequestAndRemoveFromQueue(requestToProcess); + // Track reports that had offline outgoing messages so we can refresh lastReadTime + // for any subsequent offline ReadNewestAction targeting the same report. + if ( + requestToProcess.initiatedOffline && + OUTGOING_MESSAGE_COMMANDS.has(requestToProcess.command) && + typeof requestToProcess.data?.reportID === 'string' + ) { + reportsWithOfflineSentMessages.add(requestToProcess.data.reportID); + } + if (requestToProcess.queueFlushedData) { Log.info('[SequentialQueue] Will store queueFlushedData.', false, { command: requestToProcess.command, @@ -364,6 +406,7 @@ function flush(shouldResetPromise = true) { }); isSequentialQueueRunning = true; + reportsWithOfflineSentMessages.clear(); if (shouldResetPromise) { // Reset the isReadyPromise so that the queue will be flushed as soon as the request is finished From 293b22c549deafabafc83278519b93b173dba2e0 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 27 Feb 2026 20:56:16 +0000 Subject: [PATCH 2/4] Fix: Apply Prettier formatting to SequentialQueue.ts Co-authored-by: Situ Chandra Shil --- src/libs/Network/SequentialQueue.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 802bf7f1cb312..7e2c124825c94 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -245,11 +245,7 @@ function process(): Promise { // Track reports that had offline outgoing messages so we can refresh lastReadTime // for any subsequent offline ReadNewestAction targeting the same report. - if ( - requestToProcess.initiatedOffline && - OUTGOING_MESSAGE_COMMANDS.has(requestToProcess.command) && - typeof requestToProcess.data?.reportID === 'string' - ) { + if (requestToProcess.initiatedOffline && OUTGOING_MESSAGE_COMMANDS.has(requestToProcess.command) && typeof requestToProcess.data?.reportID === 'string') { reportsWithOfflineSentMessages.add(requestToProcess.data.reportID); } From 156b04a7b417b86234d8078bb15e9f54179e36ea Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 27 Feb 2026 21:08:32 +0000 Subject: [PATCH 3/4] Fix circular dependency causing Jest test failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use lazy require for NetworkConnection in SequentialQueue to break the circular dependency chain: SequentialQueue → NetworkConnection → DateUtils → Localize → memoize → … → SequentialQueue that caused TypeError: (0, _memoize.default) is not a function in tests. Co-authored-by: Situ Chandra Shil --- src/libs/Network/SequentialQueue.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 7e2c124825c94..e3f496c03e83a 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -18,7 +18,6 @@ import {flushQueue, isEmpty} from '@libs/actions/QueuedOnyxUpdates'; import {isClientTheLeader} from '@libs/ActiveClientManager'; import {WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; -import NetworkConnection from '@libs/NetworkConnection'; import {processWithMiddleware} from '@libs/Request'; import RequestThrottle from '@libs/RequestThrottle'; import CONST from '@src/CONST'; @@ -209,7 +208,10 @@ function process(): Promise { typeof requestToProcess.data?.reportID === 'string' && reportsWithOfflineSentMessages.has(requestToProcess.data.reportID) ) { - const refreshedLastReadTime = NetworkConnection.getDBTimeWithSkew(); + // Lazy-require to avoid circular dependency: SequentialQueue → NetworkConnection → DateUtils → Localize → memoize → … → SequentialQueue + // eslint-disable-next-line @typescript-eslint/no-require-imports + const NetworkConnectionModule = require('@libs/NetworkConnection') as {default: {getDBTimeWithSkew: (timestamp?: string | number) => string}}; + const refreshedLastReadTime = NetworkConnectionModule.default.getDBTimeWithSkew(); Log.info('[SequentialQueue] Refreshing lastReadTime for offline ReadNewestAction', false, { reportID: requestToProcess.data.reportID, originalLastReadTime: requestToProcess.data.lastReadTime, From b4436cd49d6e2374f782c14148b8066b0cda653c Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 27 Feb 2026 21:31:17 +0000 Subject: [PATCH 4/4] Fix: Replace dynamic require with inline time-skew logic to fix test instability The dynamic require('@libs/NetworkConnection') inside process() was triggering heavy module-level side effects (NetInfo subscriptions, Onyx connections) when loaded mid-test, causing random test failures across different suites each run. Instead, read networkTimeSkew from the existing ONYXKEYS.NETWORK Onyx subscription and inline the simple DB-time formatting, avoiding the circular dependency without dynamic require. Co-authored-by: Situ Chandra Shil --- src/libs/Network/SequentialQueue.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index e3f496c03e83a..b6992e6ac74f0 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -34,6 +34,7 @@ const OUTGOING_MESSAGE_COMMANDS: ReadonlySet = new Set([WRITE_COMMANDS.A const reportsWithOfflineSentMessages = new Set(); let shouldFailAllRequests: boolean; +let networkTimeSkew = 0; // Use connectWithoutView since this is for network data and don't affect to any UI Onyx.connectWithoutView({ key: ONYXKEYS.NETWORK, @@ -42,6 +43,7 @@ Onyx.connectWithoutView({ return; } shouldFailAllRequests = !!network.shouldFailAllRequests; + networkTimeSkew = network?.timeSkew ?? 0; }, }); @@ -208,10 +210,12 @@ function process(): Promise { typeof requestToProcess.data?.reportID === 'string' && reportsWithOfflineSentMessages.has(requestToProcess.data.reportID) ) { - // Lazy-require to avoid circular dependency: SequentialQueue → NetworkConnection → DateUtils → Localize → memoize → … → SequentialQueue - // eslint-disable-next-line @typescript-eslint/no-require-imports - const NetworkConnectionModule = require('@libs/NetworkConnection') as {default: {getDBTimeWithSkew: (timestamp?: string | number) => string}}; - const refreshedLastReadTime = NetworkConnectionModule.default.getDBTimeWithSkew(); + // Inline the DB-time-with-skew logic here to avoid importing NetworkConnection, + // which triggers heavy module-level side effects (NetInfo, Onyx subscriptions) and + // causes a circular dependency: SequentialQueue → NetworkConnection → DateUtils → … → SequentialQueue. + // networkTimeSkew is already read from the ONYXKEYS.NETWORK subscription above. + const now = networkTimeSkew > 0 ? new Date(Date.now() + networkTimeSkew) : new Date(); + const refreshedLastReadTime = now.toISOString().replace('T', ' ').replace('Z', ''); Log.info('[SequentialQueue] Refreshing lastReadTime for offline ReadNewestAction', false, { reportID: requestToProcess.data.reportID, originalLastReadTime: requestToProcess.data.lastReadTime,