Skip to content
Open
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
11 changes: 11 additions & 0 deletions jest-test-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import JSDOMEnvironment from 'jest-environment-jsdom';

// We need this custom JSDOM environment implementation in order
// to support `structuredClone` in Jest, that is used by `fake-indexeddb` library.
// Reference: https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
super(...args);
this.global.structuredClone = structuredClone;
}
}
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ module.exports = {
__DEV__: true,
WebSocket: {},
},
testEnvironment: 'jsdom',
testEnvironment: './jest-test-environment.ts',
setupFiles: ['fake-indexeddb/auto'],
setupFilesAfterEnv: ['./jestSetup.js'],
testTimeout: 60000,
transformIgnorePatterns: ['node_modules/(?!((@)?react-native|@ngneat/falso|uuid)/)'],
Expand Down
1 change: 0 additions & 1 deletion jestSetup.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
jest.mock('./lib/storage');
jest.mock('./lib/storage/platforms/index.native', () => require('./lib/storage/__mocks__'));
jest.mock('./lib/storage/platforms/index', () => require('./lib/storage/__mocks__'));
jest.mock('./lib/storage/providers/IDBKeyValProvider', () => require('./lib/storage/__mocks__'));

jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => {}}));
jest.mock('react-native-nitro-sqlite', () => ({
Expand Down
2 changes: 1 addition & 1 deletion lib/storage/InstanceSync/index.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const InstanceSync = {
/**
* @param {Function} onStorageKeyChanged Storage synchronization mechanism keeping all opened tabs in sync
*/
init: (onStorageKeyChanged: OnStorageKeyChanged, store: StorageProvider) => {
init: (onStorageKeyChanged: OnStorageKeyChanged, store: StorageProvider<unknown>) => {
storage = store;

// This listener will only be triggered by events coming from other tabs
Expand Down
4 changes: 2 additions & 2 deletions lib/storage/__mocks__/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import MemoryOnlyProvider, {mockStore, mockSet, setMockStore} from '../providers/MemoryOnlyProvider';
import MemoryOnlyProvider, {mockStore, setMockStore} from '../providers/MemoryOnlyProvider';

const init = jest.fn(MemoryOnlyProvider.init);

Expand All @@ -18,7 +18,7 @@ const StorageMock = {
getAllKeys: jest.fn(MemoryOnlyProvider.getAllKeys),
getDatabaseSize: jest.fn(MemoryOnlyProvider.getDatabaseSize),
keepInstancesSync: jest.fn(),
mockSet,

getMockStore: jest.fn(() => mockStore),
setMockStore: jest.fn((data) => setMockStore(data)),
};
Expand Down
6 changes: 3 additions & 3 deletions lib/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import type StorageProvider from './providers/types';
import * as GlobalSettings from '../GlobalSettings';
import decorateWithMetrics from '../metrics';

let provider = PlatformStorage;
let provider = PlatformStorage as StorageProvider<unknown>;
let shouldKeepInstancesSync = false;
let finishInitalization: (value?: unknown) => void;
const initPromise = new Promise((resolve) => {
finishInitalization = resolve;
});

type Storage = {
getStorageProvider: () => StorageProvider;
} & Omit<StorageProvider, 'name'>;
getStorageProvider: () => StorageProvider<unknown>;
} & Omit<StorageProvider<unknown>, 'name' | 'store'>;

/**
* Degrade performance by removing the storage provider and only using cache
Expand Down
6 changes: 3 additions & 3 deletions lib/storage/providers/IDBKeyValProvider/createStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {promisifyRequest} from 'idb-keyval';
import * as IDB from 'idb-keyval';
import type {UseStore} from 'idb-keyval';
import {logInfo} from '../../../Logger';

Expand All @@ -12,7 +12,7 @@ function createStore(dbName: string, storeName: string): UseStore {
if (dbp) return dbp;
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
dbp = promisifyRequest(request);
dbp = IDB.promisifyRequest(request);

dbp.then(
(db) => {
Expand Down Expand Up @@ -49,7 +49,7 @@ function createStore(dbName: string, storeName: string): UseStore {
updatedDatabase.createObjectStore(storeName);
};

dbp = promisifyRequest(request);
dbp = IDB.promisifyRequest(request);
return dbp;
};

Expand Down
143 changes: 96 additions & 47 deletions lib/storage/providers/IDBKeyValProvider/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import type {UseStore} from 'idb-keyval';
import {set, keys, getMany, setMany, get, clear, del, delMany, promisifyRequest} from 'idb-keyval';
import * as IDB from 'idb-keyval';
import utils from '../../../utils';
import type StorageProvider from '../types';
import type {OnyxKey, OnyxValue} from '../../../types';
import createStore from './createStore';

// We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
// which might not be available in certain environments that load the bundle (e.g. electron main process).
let idbKeyValStore: UseStore;
const DB_NAME = 'OnyxDB';
const STORE_NAME = 'keyvaluepairs';

const provider: StorageProvider = {
const provider: StorageProvider<UseStore | undefined> = {
// We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
// which might not be available in certain environments that load the bundle (e.g. electron main process).
Copy link
Contributor

Choose a reason for hiding this comment

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

@VickyStash We have deprecated the App for Electron. So, do we still need this logic?

Copy link
Contributor

Choose a reason for hiding this comment

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

Considering starting this PR, all providers are initialized lazily (all providers got a new init method), so I think we would want to keep it this way even with deprecated electron.
@shubham1206agra What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

@VickyStash It's fine

store: undefined,
/**
* The name of the provider that can be printed to the logs
*/
Expand All @@ -25,71 +25,120 @@ const provider: StorageProvider = {
if (newIdbKeyValStore == null) {
throw Error('IDBKeyVal store could not be created');
}

idbKeyValStore = newIdbKeyValStore;
provider.store = newIdbKeyValStore;
},

setItem: (key, value) => {
setItem(key, value) {
if (!provider.store) {
throw new Error('Store not initialized!');
}

if (value === null) {
provider.removeItem(key);
return provider.removeItem(key);
}

return IDB.set(key, value, provider.store);
},
multiGet(keysParam) {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return set(key, value, idbKeyValStore);
return IDB.getMany(keysParam, provider.store).then((values) => values.map((value, index) => [keysParam[index], value]));
},
multiGet: (keysParam) => getMany(keysParam, idbKeyValStore).then((values) => values.map((value, index) => [keysParam[index], value])),
multiMerge: (pairs) =>
idbKeyValStore('readwrite', (store) => {
multiMerge(pairs) {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return provider.store('readwrite', (store) => {
// Note: we are using the manual store transaction here, to fit the read and update
// of the items in one transaction to achieve best performance.
const getValues = Promise.all(pairs.map(([key]) => promisifyRequest<OnyxValue<OnyxKey>>(store.get(key))));
const getValues = Promise.all(pairs.map(([key]) => IDB.promisifyRequest<OnyxValue<OnyxKey>>(store.get(key))));

return getValues.then((values) => {
const pairsWithoutNull = pairs.filter(([key, value]) => {
for (const [index, [key, value]] of pairs.entries()) {
if (value === null) {
provider.removeItem(key);
return false;
}

return true;
});
store.delete(key);
} else {
const newValue = utils.fastMerge(values[index] as Record<string, unknown>, value as Record<string, unknown>, {
shouldRemoveNestedNulls: true,
objectRemovalMode: 'replace',
}).result;

const upsertMany = pairsWithoutNull.map(([key, value], index) => {
const prev = values[index];
const newValue = utils.fastMerge(prev as Record<string, unknown>, value as Record<string, unknown>, {
shouldRemoveNestedNulls: true,
objectRemovalMode: 'replace',
}).result;
store.put(newValue, key);
}
}

return promisifyRequest(store.put(newValue, key));
});
return Promise.all(upsertMany);
return IDB.promisifyRequest(store.transaction);
});
}).then(() => undefined),
});
},
mergeItem(key, change) {
// Since Onyx already merged the existing value with the changes, we can just set the value directly.
return provider.multiMerge([[key, change]]);
},
multiSet: (pairs) => {
const pairsWithoutNull = pairs.filter(([key, value]) => {
if (value === null) {
provider.removeItem(key);
return false;
multiSet(pairs) {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return provider.store('readwrite', (store) => {
for (const [key, value] of pairs) {
if (value === null) {
store.delete(key);
} else {
store.put(value, key);
}
}

return true;
}) as Array<[IDBValidKey, unknown]>;
return IDB.promisifyRequest(store.transaction);
});
},
clear() {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return IDB.clear(provider.store);
},
getAllKeys() {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return IDB.keys(provider.store);
},
getItem(key) {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return setMany(pairsWithoutNull, idbKeyValStore);
return (
IDB.get(key, provider.store)
// idb-keyval returns undefined for missing items, but this needs to return null so that idb-keyval does the same thing as SQLiteStorage.
.then((val) => (val === undefined ? null : val))
);
},
removeItem(key) {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return IDB.del(key, provider.store);
},
removeItems(keysParam) {
if (!provider.store) {
throw new Error('Store not initialized!');
}

return IDB.delMany(keysParam, provider.store);
},
clear: () => clear(idbKeyValStore),
getAllKeys: () => keys(idbKeyValStore),
getItem: (key) =>
get(key, idbKeyValStore)
// idb-keyval returns undefined for missing items, but this needs to return null so that idb-keyval does the same thing as SQLiteStorage.
.then((val) => (val === undefined ? null : val)),
removeItem: (key) => del(key, idbKeyValStore),
removeItems: (keysParam) => delMany(keysParam, idbKeyValStore),
getDatabaseSize() {
if (!provider.store) {
throw new Error('Store is not initialized!');
}

if (!window.navigator || !window.navigator.storage) {
throw new Error('StorageManager browser API unavailable');
}
Expand Down
Loading