Skip to content

Commit 3ef6912

Browse files
Fix codex comment
1 parent f768dc5 commit 3ef6912

2 files changed

Lines changed: 110 additions & 17 deletions

File tree

src/libs/actions/PersistedRequests.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ let isInitialized = false;
1515
// by other browser tabs (merge into memory).
1616
const knownRequestIDs = new Set<number>();
1717
let crossTabRequestsCallback: (() => void) | undefined;
18+
// Tracks the number of unresolved Onyx.set()/Onyx.multiSet() promises initiated
19+
// by this tab. While any own writes are pending, the Onyx callback must NOT
20+
// reconcile deletions, because the callback may have been triggered synchronously
21+
// by broadcastUpdate during our own Onyx.set() call — and that value may become
22+
// stale if a later Onyx.set() has already been called (Issue 4 protection).
23+
// When the counter is 0, any callback must be from a cross-tab storage event,
24+
// so it is safe to reconcile deletions from the leader tab.
25+
let pendingOnyxWrites = 0;
26+
27+
function trackOnyxWrite<T>(promise: Promise<T>): Promise<T> {
28+
pendingOnyxWrites++;
29+
return promise.finally(() => {
30+
pendingOnyxWrites--;
31+
});
32+
}
33+
1834
let initializationCallback: () => void;
1935
function triggerInitializationCallback() {
2036
if (typeof initializationCallback !== 'function') {
@@ -56,8 +72,31 @@ Onyx.connectWithoutView({
5672
}
5773
}
5874
persistedRequests = [...persistedRequests, ...newFromOtherTabs];
59-
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
75+
trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests));
6076
crossTabRequestsCallback?.();
77+
return;
78+
}
79+
80+
// Reconcile deletions from the leader tab. When pendingOnyxWrites > 0,
81+
// the callback may be a stale own-write (broadcastUpdate fires synchronously
82+
// inside Onyx.set) — skip to preserve Issue 4 protection. When counter is 0,
83+
// the callback is from a cross-tab storage event, safe to remove from memory
84+
// any requests the leader already processed.
85+
if (pendingOnyxWrites === 0) {
86+
const diskIDs = new Set<number>();
87+
for (const r of val) {
88+
if (r.requestID != null) {
89+
diskIDs.add(r.requestID);
90+
}
91+
}
92+
const previousLength = persistedRequests.length;
93+
persistedRequests = persistedRequests.filter((r) => r.requestID == null || diskIDs.has(r.requestID));
94+
if (persistedRequests.length !== previousLength) {
95+
Log.info('[PersistedRequests] Reconciled deletions from leader tab', false, {
96+
removedCount: previousLength - persistedRequests.length,
97+
remainingCount: persistedRequests.length,
98+
});
99+
}
61100
}
62101
return;
63102
}
@@ -92,7 +131,7 @@ Onyx.connectWithoutView({
92131
}
93132
const requests = [...persistedRequests, ...pendingSaveOperations];
94133
persistedRequests = requests;
95-
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
134+
trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests));
96135
pendingSaveOperations = [];
97136
}
98137

@@ -144,6 +183,7 @@ function clear() {
144183
persistedRequests = [];
145184
pendingSaveOperations = [];
146185
knownRequestIDs.clear();
186+
pendingOnyxWrites = 0;
147187
Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, null);
148188
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, []);
149189
}
@@ -183,7 +223,7 @@ function save<TKey extends OnyxKey>(requestToPersist: Request<TKey>): Promise<vo
183223
newQueueLength: requests.length,
184224
});
185225

186-
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests as AnyRequest[])
226+
return trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests as AnyRequest[]))
187227
.then(() => {
188228
Log.info('[PersistedRequests] Request successfully persisted to disk', false, {
189229
command: requestToPersist.command,
@@ -243,10 +283,12 @@ function endRequestAndRemoveFromQueue<TKey extends OnyxKey>(requestToRemove: Req
243283
newQueueLength: persistedRequests.length,
244284
});
245285

246-
Onyx.multiSet({
247-
[ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests,
248-
[ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: null,
249-
}).then(() => {
286+
trackOnyxWrite(
287+
Onyx.multiSet({
288+
[ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests,
289+
[ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: null,
290+
}),
291+
).then(() => {
250292
Log.info('[PersistedRequests] Successfully persisted request removal to disk', false, {
251293
command: requestToRemove.command,
252294
newQueueLength: getLength(),
@@ -262,7 +304,7 @@ function deleteRequestsByIndices(indices: number[]): Promise<void> {
262304
persistedRequests = persistedRequests.filter((_, index) => !indicesSet.has(index));
263305

264306
// Update the persisted requests in storage or state as necessary
265-
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests).then(() => {
307+
return trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests)).then(() => {
266308
Log.info(`Multiple (${indices.length}) requests removed from the queue. Queue length is ${persistedRequests.length}`);
267309
});
268310
}
@@ -276,7 +318,7 @@ function update<TKey extends OnyxKey>(oldRequestIndex: number, newRequest: Reque
276318
if (newRequest.requestID != null) {
277319
knownRequestIDs.add(newRequest.requestID);
278320
}
279-
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
321+
return trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests));
280322
}
281323

282324
function updateOngoingRequest<TKey extends OnyxKey>(newRequest: Request<TKey>) {
@@ -333,10 +375,12 @@ function processNextRequest(): AnyRequest | null {
333375
// native File objects (DataCloneError). These requests cannot survive a crash
334376
// anyway since File references are lost on restart.
335377
const hasNonSerializableData = ongoingRequest?.data && Object.values(ongoingRequest.data).some((v) => v instanceof File || v instanceof Blob);
336-
Onyx.multiSet({
337-
[ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests,
338-
...(ongoingRequest && !hasNonSerializableData ? {[ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: ongoingRequest} : {}),
339-
});
378+
trackOnyxWrite(
379+
Onyx.multiSet({
380+
[ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests,
381+
...(ongoingRequest && !hasNonSerializableData ? {[ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: ongoingRequest} : {}),
382+
}),
383+
);
340384

341385
// Return the local reference, not `ongoingRequest`. The Onyx.multiSet above
342386
// triggers a synchronous callback (Onyx 3.0.46+) that overwrites `ongoingRequest`
@@ -378,10 +422,12 @@ function rollbackOngoingRequest() {
378422

379423
// Persist both changes to disk so a crash after rollback doesn't lose
380424
// the rolled-back request or leave a stale ongoingRequest on disk.
381-
Onyx.multiSet({
382-
[ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests,
383-
[ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: null,
384-
});
425+
trackOnyxWrite(
426+
Onyx.multiSet({
427+
[ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests,
428+
[ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: null,
429+
}),
430+
);
385431
}
386432

387433
function getAll(): AnyRequest[] {

tests/unit/PersistedRequests.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,51 @@ describe('PersistedRequests persistence guarantees', () => {
242242
setMock.mockRestore();
243243
}
244244
});
245+
246+
it('Follower tab should reconcile processed requests from leader via cross-tab callback', async () => {
247+
PersistedRequests.clear();
248+
await waitForBatchedUpdates();
249+
expect(PersistedRequests.getAll()).toHaveLength(0);
250+
251+
const requestA: Request<'reportMetadata_1' | 'reportMetadata_2'> = {
252+
command: 'CommandA',
253+
successData: [{key: 'reportMetadata_1', onyxMethod: 'merge', value: {}}],
254+
failureData: [{key: 'reportMetadata_2', onyxMethod: 'merge', value: {}}],
255+
requestID: 20,
256+
};
257+
const requestB: Request<'reportMetadata_3' | 'reportMetadata_4'> = {
258+
command: 'CommandB',
259+
successData: [{key: 'reportMetadata_3', onyxMethod: 'merge', value: {}}],
260+
failureData: [{key: 'reportMetadata_4', onyxMethod: 'merge', value: {}}],
261+
requestID: 21,
262+
};
263+
264+
PersistedRequests.save(requestA);
265+
PersistedRequests.save(requestB);
266+
await waitForBatchedUpdates();
267+
expect(PersistedRequests.getAll()).toHaveLength(2);
268+
269+
// Simulate a cross-tab callback: leader processed requestA and removed it.
270+
// After waitForBatchedUpdates, pendingOnyxWrites is 0, so the callback
271+
// will reconcile deletions (requestA no longer on disk → removed from memory).
272+
await Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, [requestB]);
273+
await waitForBatchedUpdates();
274+
275+
// The follower should have reconciled: requestA removed from memory
276+
expect(PersistedRequests.getAll()).toHaveLength(1);
277+
expect(PersistedRequests.getAll().at(0)).toEqual(requestB);
278+
279+
// Save a new request — it should NOT re-add requestA to disk
280+
const requestC: Request<'reportMetadata_5' | 'reportMetadata_6'> = {
281+
command: 'CommandC',
282+
successData: [{key: 'reportMetadata_5', onyxMethod: 'merge', value: {}}],
283+
failureData: [{key: 'reportMetadata_6', onyxMethod: 'merge', value: {}}],
284+
requestID: 22,
285+
};
286+
PersistedRequests.save(requestC);
287+
await waitForBatchedUpdates();
288+
289+
expect(PersistedRequests.getAll()).toHaveLength(2);
290+
expect(PersistedRequests.getAll().map((r) => r.command)).toEqual(['CommandB', 'CommandC']);
291+
});
245292
});

0 commit comments

Comments
 (0)