Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lib/Onyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,17 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
}
mergeQueue[key] = [changes];

mergeQueuePromise[key] = OnyxUtils.get(key).then((existingValue) => {
mergeQueuePromise[key] = OnyxUtils.get(key).then((valueFromGet) => {
// Calls to Onyx.set after a merge will terminate the current merge process and clear the merge queue
if (mergeQueue[key] == null) {
return Promise.resolve();
}

// Other writers (notably Onyx.update's mergeCollection path, which doesn't participate in mergeQueue)
// can land between get() resolving and this callback running. Applying the delta on top of the value
// captured back then and broadcasting it would overwrite those writes wholesale, so re-read the cache.
const existingValue = cache.hasCacheForKey(key) ? (cache.get(key) as OnyxInput<TKey> | undefined) : valueFromGet;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve concurrent removals instead of stale reads

When the concurrent Onyx.update removes the same collection member while this merge's get() is outstanding, partialSetCollection/mergeCollectionWithPatches calls remove(), which only cache.drop()s the key, so hasCacheForKey(key) is false here. This then falls back to the stale valueFromGet and applyMerge() writes that old object back with the new delta, resurrecting a record that the update just deleted (the same class of race this change is trying to fix for deleted report actions).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's valid concern that Claude already raised to me, though it's a pre-existing behaviour – we are not introducing this issue here.
It can be further explored in a separate issue in my opinion

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fabioh8010 can you create an issue for that if you think its worth pursuing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


try {
const validChanges = mergeQueue[key].filter((change) => {
const {isCompatible, existingValueType, newValueType, isEmptyArrayCoercion} = utils.checkCompatibilityWithExistingValue(change, existingValue);
Expand Down
67 changes: 57 additions & 10 deletions tests/unit/onyxTest.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import {act} from '@testing-library/react-native';

import lodashClone from 'lodash/clone';
import lodashCloneDeep from 'lodash/cloneDeep';
import {act} from '@testing-library/react-native';
import Onyx from '../../lib';
import * as Logger from '../../lib/Logger';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import OnyxUtils from '../../lib/OnyxUtils';

import type OnyxCache from '../../lib/OnyxCache';
import StorageMock from '../../lib/storage';
import type {Connection} from '../../lib/OnyxConnectionManager';
import type {OnyxCollection, OnyxKey, OnyxUpdate} from '../../lib/types';
import type {GenericDeepRecord} from '../types';
import type GenericCollection from '../utils/GenericCollection';
import type {Connection} from '../../lib/OnyxConnectionManager';

import Onyx from '../../lib';
import createDeferredTask from '../../lib/createDeferredTask';
import * as Logger from '../../lib/Logger';
import OnyxUtils from '../../lib/OnyxUtils';
import StorageMock from '../../lib/storage';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';

const ONYX_KEYS = {
TEST_KEY: 'test',
Expand Down Expand Up @@ -2629,6 +2632,45 @@ describe('Onyx', () => {
expect(cache.get(collectionMemberKey)).toEqual({data: 'test'});
expect(await StorageMock.getItem(collectionMemberKey)).toBeNull();
});

describe('concurrency with Onyx.update', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('should apply the delta on top of an Onyx.update that landed after get() is resolved', async () => {
const member1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`;
const member2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`;

await Onyx.merge(member1, {itemA: {pendingAction: 'add'}, itemB: {name: 'b'}});
await Onyx.merge(member2, {itemC: {name: 'c'}});
await waitForPromisesToResolve();

const staleValue = lodashCloneDeep(cache.get(member1));

// Park merge()'s read so the update below is guaranteed to land first.
const deferredGet = createDeferredTask();
const originalGet = OnyxUtils.get;
jest.spyOn(OnyxUtils, 'get').mockImplementation(((key: OnyxKey) =>
key === member1 ? deferredGet.promise.then(() => staleValue) : originalGet(key)) as typeof OnyxUtils.get);

const mergePromise = Onyx.merge(member1, {itemA: {childID: '1'}});

// Two keys of the same collection, so this goes through mergeCollectionWithPatches.
await Onyx.update([
{onyxMethod: Onyx.METHOD.MERGE, key: member1, value: {itemA: {pendingAction: null}, itemB: null}},
{onyxMethod: Onyx.METHOD.MERGE, key: member2, value: {itemC: {touched: true}}},
]);
await waitForPromisesToResolve();

deferredGet.resolve();
await mergePromise;
await waitForPromisesToResolve();

expect(cache.get(member1)).toEqual({itemA: {childID: '1'}});
expect(await StorageMock.getItem(member1)).toEqual({itemA: {childID: '1'}});
});
});
});

describe('set', () => {
Expand Down Expand Up @@ -3495,13 +3537,20 @@ describe('RAM-only keys should not read from storage', () => {
describe('get() should prefer cache over stale storage', () => {
let cache: typeof OnyxCache;

// StorageMock.getItem is a plain jest.fn(), not a spy, so jest.restoreAllMocks() will not undo a
// mockImplementation() set on it. Capture the default now and put it back after each test, otherwise the
// override below leaks into every suite that runs afterwards.
const getItemMock = StorageMock.getItem as jest.Mock;
const defaultGetItem = getItemMock.getMockImplementation() as (key: OnyxKey) => Promise<unknown>;

beforeEach(() => {
Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask());
cache = require('../../lib/OnyxCache').default;
Onyx.init({keys: ONYX_KEYS});
});

afterEach(() => {
getItemMock.mockImplementation(defaultGetItem);
jest.restoreAllMocks();
return Onyx.clear();
});
Expand All @@ -3511,15 +3560,13 @@ describe('get() should prefer cache over stale storage', () => {
const member2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`;

// Delay getItem for member1 to simulate slow Native storage (returns null before the write lands)
const getItemMock = StorageMock.getItem as jest.Mock;
const originalGetItem = getItemMock.getMockImplementation()!;
getItemMock.mockImplementation((key: OnyxKey) => {
if (key === member1) {
return new Promise<undefined>((resolve) => {
setTimeout(() => resolve(undefined), 50);
});
}
return originalGetItem(key);
return defaultGetItem(key);
});

// 2+ collection keys get batched into mergeCollectionWithPatches (deferred cache write)
Expand Down
Loading