diff --git a/.eslintignore b/.eslintignore index d1b71675e..eadb211a1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,6 @@ *.d.ts dist node_modules -*.config.js \ No newline at end of file +*.config.js +# tests/types catalog is not type checked with the rest of the project, so we need to ignore it in eslint +tests/types/**/*.ts \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa13e65f1..de38cf64a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,3 +30,5 @@ jobs: - run: npm run test env: CI: true + + - run: npm run test:types \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index d904a33c4..947c4caba 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,7 +4,7 @@ module.exports = { transform: { '\\.[jt]sx?$': 'babel-jest', }, - testPathIgnorePatterns: ['/node_modules/', '/tests/unit/mocks/', '/tests/e2e/'], + testPathIgnorePatterns: ['/node_modules/', '/tests/unit/mocks/', '/tests/e2e/', '/tests/types/'], testMatch: ['**/tests/unit/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], globals: { __DEV__: true, diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 6752e82e4..a6ece914e 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -4,8 +4,6 @@ import Storage from './storage'; import utils from './utils'; import DevTools, {initDevTools} from './DevTools'; import type { - Collection, - CollectionKey, CollectionKeyBase, ConnectOptions, InitOptions, @@ -15,6 +13,7 @@ import type { MixedOperationsQueue, OnyxKey, OnyxMergeCollectionInput, + OnyxSetCollectionInput, OnyxMergeInput, OnyxMultiSetInput, OnyxSetInput, @@ -374,7 +373,7 @@ function merge(key: TKey, changes: OnyxMergeInput): * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` * @param collection Object collection keyed by individual collection member keys and values */ -function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { +function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { return OnyxUtils.mergeCollectionWithPatches(collectionKey, collection, undefined, true); } @@ -545,7 +544,7 @@ function update(data: OnyxUpdate[]): Promise { [OnyxUtils.METHOD.SET]: enqueueSetOperation, [OnyxUtils.METHOD.MERGE]: enqueueMergeOperation, [OnyxUtils.METHOD.MERGE_COLLECTION]: () => { - const collection = value as Collection; + const collection = value as OnyxMergeCollectionInput; if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) { Logger.logInfo('mergeCollection enqueued within update() with invalid or empty value. Skipping this operation.'); return; @@ -558,7 +557,7 @@ function update(data: OnyxUpdate[]): Promise { collectionKeys.forEach((collectionKey) => enqueueMergeOperation(collectionKey, mergedCollection[collectionKey])); } }, - [OnyxUtils.METHOD.SET_COLLECTION]: (k, v) => promises.push(() => setCollection(k, v as Collection)), + [OnyxUtils.METHOD.SET_COLLECTION]: (k, v) => promises.push(() => setCollection(k, v as OnyxSetCollectionInput)), [OnyxUtils.METHOD.MULTI_SET]: (k, v) => Object.entries(v as Partial).forEach(([entryKey, entryValue]) => enqueueSetOperation(entryKey, entryValue)), [OnyxUtils.METHOD.CLEAR]: () => { clearPromise = clear(); @@ -611,14 +610,14 @@ function update(data: OnyxUpdate[]): Promise { promises.push(() => OnyxUtils.mergeCollectionWithPatches( collectionKey, - batchedCollectionUpdates.merge as Collection, + batchedCollectionUpdates.merge as OnyxMergeCollectionInput, batchedCollectionUpdates.mergeReplaceNullPatches, true, ), ); } if (!utils.isEmptyObject(batchedCollectionUpdates.set)) { - promises.push(() => OnyxUtils.partialSetCollection(collectionKey, batchedCollectionUpdates.set as Collection)); + promises.push(() => OnyxUtils.partialSetCollection(collectionKey, batchedCollectionUpdates.set as OnyxSetCollectionInput)); } }); @@ -655,7 +654,7 @@ function update(data: OnyxUpdate[]): Promise { * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` * @param collection Object collection keyed by individual collection member keys and values */ -function setCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { +function setCollection(collectionKey: TKey, collection: OnyxSetCollectionInput): Promise { let resultCollection: OnyxInputKeyValueMapping = collection; let resultCollectionKeys = Object.keys(resultCollection); diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 88d3b1c3a..e2e711778 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -29,6 +29,7 @@ import type { OnyxUpdate, OnyxValue, Selector, + OnyxSetCollectionInput, } from './types'; import type {FastMergeOptions, FastMergeResult} from './utils'; import utils from './utils'; @@ -1035,7 +1036,7 @@ function initializeWithDefaultKeyStates(): Promise { /** * Validate the collection is not empty and has a correct type before applying mergeCollection() */ -function isValidNonEmptyCollectionForMerge(collection: OnyxMergeCollectionInput): boolean { +function isValidNonEmptyCollectionForMerge(collection: OnyxMergeCollectionInput): boolean { return typeof collection === 'object' && !Array.isArray(collection) && !utils.isEmptyObject(collection); } @@ -1241,9 +1242,9 @@ function updateSnapshots(data: OnyxUpdate[], mergeFn: typeof Onyx.merge): Array< * @param mergeReplaceNullPatches Record where the key is a collection member key and the value is a list of * tuples that we'll use to replace the nested objects of that collection member record with something else. */ -function mergeCollectionWithPatches( +function mergeCollectionWithPatches( collectionKey: TKey, - collection: OnyxMergeCollectionInput, + collection: OnyxMergeCollectionInput, mergeReplaceNullPatches?: MultiMergeReplaceNullPatches, isProcessingCollectionUpdate = false, ): Promise { @@ -1350,7 +1351,7 @@ function mergeCollectionWithPatches( }); return Promise.all(promises) - .catch((error) => evictStorageAndRetry(error, mergeCollectionWithPatches, collectionKey, resultCollection)) + .catch((error) => evictStorageAndRetry(error, mergeCollectionWithPatches, collectionKey, resultCollection as OnyxMergeCollectionInput)) .then(() => { sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection); return promiseUpdate; @@ -1366,7 +1367,7 @@ function mergeCollectionWithPatches( * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` * @param collection Object collection keyed by individual collection member keys and values */ -function partialSetCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { +function partialSetCollection(collectionKey: TKey, collection: OnyxSetCollectionInput): Promise { let resultCollection: OnyxInputKeyValueMapping = collection; let resultCollectionKeys = Object.keys(resultCollection); diff --git a/lib/index.ts b/lib/index.ts index ee6cb3cb7..bb6df0e0c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -16,6 +16,7 @@ import type { OnyxMultiSetInput, OnyxMergeInput, OnyxMergeCollectionInput, + OnyxSetCollectionInput, } from './types'; import type {FetchStatus, ResultMetadata, UseOnyxResult, UseOnyxOptions} from './useOnyx'; import type {Connection} from './OnyxConnectionManager'; @@ -40,6 +41,7 @@ export type { OnyxMultiSetInput, OnyxMergeInput, OnyxMergeCollectionInput, + OnyxSetCollectionInput, OnyxUpdate, OnyxValue, ResultMetadata, diff --git a/lib/types.ts b/lib/types.ts index f037ef7c3..7b9ddb2aa 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,4 @@ import type {Merge} from 'type-fest'; -import type {BuiltIns} from 'type-fest/source/internal'; import type OnyxUtils from './OnyxUtils'; import type {OnyxMethod} from './OnyxUtils'; import type {FastMergeReplaceNullPatch} from './utils'; @@ -157,6 +156,10 @@ type OnyxValue = string extends TKey ? unknown : TKey exte /** Utility type to extract `TOnyxValue` from `OnyxCollection` */ type ExtractOnyxCollectionValue = TOnyxCollection extends NonNullable> ? U : never; +type Primitive = null | undefined | string | number | boolean | symbol | bigint; + +type BuiltIns = Primitive | void | Date | RegExp; + type NonTransformableTypes = | BuiltIns // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -205,13 +208,7 @@ type NullishObjectDeep = { * Also, the `TMap` type is inferred automatically in `mergeCollection()` method and represents * the object of collection keys/values specified in the second parameter of the method. */ -type Collection = { - [MapK in keyof TMap]: MapK extends `${TKey}${string}` - ? MapK extends `${TKey}` - ? never // forbids empty id - : TValue - : never; -}; +type Collection = Record<`${TKey}${string}`, TValue> & {[P in TKey]?: never}; /** Represents the base options used in `Onyx.connect()` method. */ // NOTE: Any changes to this type like adding or removing options must be accounted in OnyxConnectionManager's `generateConnectionID()` method! @@ -322,48 +319,58 @@ type OnyxMergeInput = OnyxInput; /** * This represents the value that can be passed to `Onyx.merge` and to `Onyx.update` with the method "MERGE" */ -type OnyxMergeCollectionInput = Collection>, TMap>; +type OnyxMergeCollectionInput = Collection>>; -type OnyxMethodMap = typeof OnyxUtils.METHOD; +/** + * This represents the value that can be passed to `Onyx.setCollection` and to `Onyx.update` with the method "SET_COLLECTION" + */ +type OnyxSetCollectionInput = Collection>; -// Maps onyx methods to their corresponding value types -type OnyxMethodValueMap = { - [OnyxUtils.METHOD.SET]: { - key: OnyxKey; - value: OnyxSetInput; - }; - [OnyxUtils.METHOD.MULTI_SET]: { - key: OnyxKey; - value: OnyxMultiSetInput; - }; - [OnyxUtils.METHOD.MERGE]: { - key: OnyxKey; - value: OnyxMergeInput; - }; - [OnyxUtils.METHOD.CLEAR]: { - key: OnyxKey; - value?: undefined; - }; - [OnyxUtils.METHOD.MERGE_COLLECTION]: { - key: CollectionKeyBase; - value: OnyxMergeCollectionInput; - }; - [OnyxUtils.METHOD.SET_COLLECTION]: { - key: CollectionKeyBase; - value: OnyxMergeCollectionInput; - }; -}; +type OnyxMethodMap = typeof OnyxUtils.METHOD; /** * OnyxUpdate type includes all onyx methods used in OnyxMethodValueMap. * If a new method is added to OnyxUtils.METHOD constant, it must be added to OnyxMethodValueMap type. * Otherwise it will show static type errors. */ -type OnyxUpdate = { - [Method in OnyxMethod]: { - onyxMethod: Method; - } & OnyxMethodValueMap[Method]; -}[OnyxMethod]; +type OnyxUpdate = + // ⚠️ DO NOT CHANGE THIS TYPE, UNLESS YOU KNOW WHAT YOU ARE DOING. ⚠️ + | { + [TKey in OnyxKey]: + | { + onyxMethod: typeof OnyxUtils.METHOD.SET; + key: TKey; + value: OnyxSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; + key: TKey; + value: OnyxMultiSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE; + key: TKey; + value: OnyxMergeInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.CLEAR; + key: TKey; + value?: undefined; + }; + }[OnyxKey] + | { + [TKey in CollectionKeyBase]: + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; + key: TKey; + value: OnyxMergeCollectionInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.SET_COLLECTION; + key: TKey; + value: OnyxSetCollectionInput; + }; + }[CollectionKeyBase]; /** * Represents the options used in `Onyx.set()` method. @@ -474,6 +481,7 @@ export type { OnyxMultiSetInput, OnyxMergeInput, OnyxMergeCollectionInput, + OnyxSetCollectionInput, OnyxMethod, OnyxMethodMap, OnyxUpdate, diff --git a/package.json b/package.json index 22fc58a65..07c278617 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "jest", + "test:types": "npm run build && tsc --noEmit --project tsconfig.test.json", "perf-test": "npx reassure", "build": "tsc -p tsconfig.build.json", "build:watch": "nodemon --watch lib --ext js,json,ts,tsx --exec \"npm run build && npm pack\"", diff --git a/tests/types/OnyxUpdate.ts b/tests/types/OnyxUpdate.ts new file mode 100644 index 000000000..e1b8567ce --- /dev/null +++ b/tests/types/OnyxUpdate.ts @@ -0,0 +1,59 @@ +import type {OnyxUpdate} from '../../dist/types'; +import ONYX_KEYS from './setup'; + +const onyxUpdate: OnyxUpdate = { + onyxMethod: 'set', + key: ONYX_KEYS.TEST_KEY, + value: 'string', +}; + +const onyxUpdateError: OnyxUpdate = { + onyxMethod: 'set', + key: ONYX_KEYS.TEST_KEY, + // @ts-expect-error TEST_KEY is a string, not a number + value: 2, +}; + +const onyxUpdateCollection: OnyxUpdate = { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: { + str: 'test', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: { + str: 'test2', + }, + }, +}; + +// @ts-expect-error COLLECTION.TEST_KEY is an object, not a number +const onyxUpdateCollectionError: OnyxUpdate = { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]: 2, + }, +}; + +const onyxUpdateCollectionError2: OnyxUpdate = { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]: { + // @ts-expect-error nonExistingKey is not a valid key + nonExistingKey: 'test2', + }, + }, +}; + +// @ts-expect-error COLLECTION.TEST_KEY is invalid key, it is missing the suffix +const onyxUpdateCollectionError3: OnyxUpdate = { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: { + [ONYX_KEYS.COLLECTION.TEST_KEY]: { + str: 'test2', + }, + }, +}; diff --git a/tests/types/mergeCollection.ts b/tests/types/mergeCollection.ts new file mode 100644 index 000000000..5d07fd217 --- /dev/null +++ b/tests/types/mergeCollection.ts @@ -0,0 +1,26 @@ +import Onyx from '../../dist/Onyx'; +import ONYX_KEYS from './setup'; + +Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: { + str: 'test3', + }, + test_2: { + str: 'test4', + }, + test_3: { + str: 'test5', + }, +}); + +Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + // @ts-expect-error COLLECTION.TEST_KEY is invalid key, it is missing the suffix + test_: { + str: 'test3', + }, + test_2: { + str: 'test4', + }, + // @ts-expect-error COLLECTION.TEST_KEY is object, not a number + test_3: 2, +}); diff --git a/tests/types/setup.ts b/tests/types/setup.ts new file mode 100644 index 000000000..df3bcb9b3 --- /dev/null +++ b/tests/types/setup.ts @@ -0,0 +1,26 @@ +import '../../dist/types'; + +const ONYX_KEYS = { + TEST_KEY: 'test', + COLLECTION: { + TEST_KEY: 'test_', + }, +} as const; + +type OnyxValues = { + [ONYX_KEYS.TEST_KEY]: string; +}; + +type OnyxCollectionValues = { + [ONYX_KEYS.COLLECTION.TEST_KEY]: {str: string}; +}; + +declare module '../../dist/types' { + interface CustomTypeOptions { + keys: keyof OnyxValues; + collectionKeys: keyof OnyxCollectionValues; + values: OnyxValues & OnyxCollectionValues; + } +} + +export default ONYX_KEYS; diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index 425d14a0e..46b86adc3 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -25,10 +25,10 @@ const initialKeyStates = { [ONYX_KEYS.OBJECT_KEY]: {id: 42}, }; -const exampleCollection: GenericCollection = { +const exampleCollection = { [`${ONYX_KEYS.COLLECTION.NUM_KEY}1`]: 1, [`${ONYX_KEYS.COLLECTION.NUM_KEY}2`]: 2, -}; +} as GenericCollection; const exampleObject = {name: 'Pedro'}; diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index 1014eef8f..e0968fade 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -117,10 +117,10 @@ describe('OnyxConnectionManager', () => { it('should connect two times to the same key but with different options, and fire the callbacks differently', async () => { const obj1 = {id: 'entry1_id', name: 'entry1_name'}; const obj2 = {id: 'entry2_id', name: 'entry2_name'}; - const collection: GenericCollection = { + const collection = { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: obj2, - }; + } as GenericCollection; await StorageMock.multiSet([ [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, obj1], [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, obj2], diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 04fdd0ac8..066f4513f 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -899,7 +899,7 @@ describe('Onyx', () => { ID: 345, value: 'three', }, - }, + } as GenericCollection, }, ]); }) @@ -1268,7 +1268,7 @@ describe('Onyx', () => { Onyx.update([ {onyxMethod: Onyx.METHOD.SET, key: ONYX_KEYS.TEST_KEY, value: 'taco'}, {onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.OTHER_TEST, value: 'pizza'}, - {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}}}, + {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}} as GenericCollection}, ]).then(() => { expect(collectionCallback).toHaveBeenCalledTimes(2); expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined, undefined); @@ -1630,7 +1630,7 @@ describe('Onyx', () => { 0: 'Bed', }, }, - }, + } as GenericCollection, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1729,7 +1729,7 @@ describe('Onyx', () => { value: { [cat]: {age: 5, size: 'S'}, [dog]: {size: 'M'}, - }, + } as GenericCollection, }, {onyxMethod: Onyx.METHOD.SET, key: cat, value: {age: 3}}, {onyxMethod: Onyx.METHOD.MERGE, key: cat, value: {sound: 'meow'}}, @@ -2214,7 +2214,7 @@ describe('Onyx', () => { value: { [routeA]: {name: 'New Route A'}, [routeB]: {name: 'New Route B'}, - }, + } as GenericCollection, }, ]); }) @@ -2265,7 +2265,7 @@ describe('Onyx', () => { key: ONYX_KEYS.COLLECTION.ROUTES, value: { [routeA]: {name: 'Final Route A'}, - }, + } as GenericCollection, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -2319,7 +2319,7 @@ describe('Onyx', () => { value: { [key1]: {id: '1', name: 'Updated Item 1'}, [key2]: {id: '2', name: 'Updated Item 2'}, - }, + } as GenericCollection, }, ]); @@ -2663,8 +2663,9 @@ describe('Onyx', () => { } as GenericCollection); await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { + // @ts-expect-error invalidRoute is not a valid key [invalidRoute]: {name: 'Invalid Route'}, - } as GenericCollection); + }); expect(result).toEqual({ [routeA]: {name: 'Route A'}, diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 9b120a580..25f6d6d38 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -210,8 +210,9 @@ describe('OnyxUtils', () => { } as GenericCollection); await OnyxUtils.partialSetCollection(ONYXKEYS.COLLECTION.ROUTES, { + // @ts-expect-error invalidRoute is not a valid key [invalidRoute]: {name: 'Invalid Route'}, - } as GenericCollection); + }); expect(result).toEqual({ [routeA]: {name: 'Route A'}, diff --git a/tests/utils/GenericCollection.ts b/tests/utils/GenericCollection.ts index b00fce6dc..a0a613a1d 100644 --- a/tests/utils/GenericCollection.ts +++ b/tests/utils/GenericCollection.ts @@ -1,5 +1,4 @@ -import type {Collection} from '../../lib/types'; - -type GenericCollection = Collection; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GenericCollection = Record; export default GenericCollection; diff --git a/tsconfig.json b/tsconfig.json index f8b58a305..d920b8700 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "declaration": true, "outDir": "./dist" }, - "exclude": ["**/node_modules/**/*", "**/dist/**/*"] + "exclude": ["**/node_modules/**/*", "**/dist/**/*", "tests/types/**/*.ts"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 000000000..6c4f1b954 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["tests/types/**/*.ts"], + "exclude": ["**/node_modules/**/*", "lib/**/*"] +}