From f5642daa45e66e31dc34dd23f3272d76022b6da8 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 19 Jun 2026 11:14:04 -0700 Subject: [PATCH 01/20] Upgrade eslint-config-expensify to 4.0.5 Adopt modular v4 flat config with eslint-seatbelt to grandfather type-checked violations while removing the bespoke TypeScript plugin setup and App-specific rulesdir overrides from the old monolithic config. Co-authored-by: Cursor --- eslint.config.mjs | 146 +- eslint.seatbelt.tsv | 167 ++ lib/Logger.ts | 1 - package-lock.json | 2389 +++++++---------- package.json | 15 +- .../OnyxConnectionManager.perf-test.ts | 22 +- tests/unit/DevToolsTest.ts | 58 +- tests/unit/OnyxConnectionManagerTest.ts | 176 +- 8 files changed, 1436 insertions(+), 1538 deletions(-) create mode 100644 eslint.seatbelt.tsv diff --git a/eslint.config.mjs b/eslint.config.mjs index 99fb430ab..3941f98de 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,50 +1,43 @@ -import expensify from 'eslint-config-expensify'; -import tsPlugin from '@typescript-eslint/eslint-plugin'; -import tsParser from '@typescript-eslint/parser'; +import browserConfig from 'eslint-config-expensify/browser'; +import reactConfig from 'eslint-config-expensify/react'; +import scriptsConfig from 'eslint-config-expensify/scripts'; +import tsExpensifyConfig from 'eslint-config-expensify/typescript'; +import jestConfig from 'eslint-config-expensify/jest'; import prettierConfig from 'eslint-config-prettier'; +import seatbelt from 'eslint-seatbelt'; +import rulesdir from 'eslint-plugin-rulesdir'; +import {defineConfig, globalIgnores} from 'eslint/config'; +import globals from 'globals'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; -export default [ - ...expensify, - prettierConfig, - { - ignores: ['dist/**', 'node_modules/**', '.github/**', '*.d.ts', '*.config.js', '*.config.cjs', 'tests/types/**/*.ts'], - }, +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); +rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); + +export default defineConfig([ + seatbelt.configs.enable, + globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**']), + ...browserConfig, + ...reactConfig, + ...tsExpensifyConfig, + ...jestConfig, + ...scriptsConfig, { - // Overwriting this for now because web-e will conflict with this - files: ['**/*.js', '**/*.jsx'], + plugins: { + rulesdir, + }, settings: { - 'import/resolver': { - node: { - extensions: ['.js', '.website.js', '.desktop.js', '.native.js', '.ios.js', '.android.js', '.config.js', '.ts', '.tsx'], - }, + seatbelt: { + seatbeltFile: path.join(dirname, 'eslint.seatbelt.tsv'), + threadsafe: true, }, }, - rules: { - 'react/jsx-filename-extension': [1, {extensions: ['.js']}], - 'rulesdir/no-multiple-onyx-in-file': 'off', - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', - }, - ], - }, }, { - files: ['**/*.ts', '**/*.tsx'], - plugins: { - '@typescript-eslint': tsPlugin, - }, - languageOptions: { - parser: tsParser, - parserOptions: { - project: './tsconfig.json', - }, - }, + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], settings: { 'import/resolver': { typescript: { @@ -54,74 +47,22 @@ export default [ }, }, rules: { - ...tsPlugin.configs.recommended.rules, - ...tsPlugin.configs.stylistic.rules, - 'rulesdir/prefer-underscore-method': 'off', - 'react/jsx-props-no-spreading': 'off', + 'class-methods-use-this': 'off', + 'es/no-nullish-coalescing-operators': 'off', + 'es/no-optional-chaining': 'off', + 'react/prop-types': 'off', 'react/require-default-props': 'off', - 'react/jsx-filename-extension': ['error', {extensions: ['.tsx', '.jsx']}], - 'import/no-unresolved': 'error', - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'no-use-before-define': 'off', - '@typescript-eslint/no-use-before-define': 'off', - '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_', caughtErrors: 'none'}], - '@typescript-eslint/consistent-type-imports': ['error', {prefer: 'type-imports'}], - '@typescript-eslint/consistent-type-exports': ['error', {fixMixedExportsWithInlineTypeSpecifier: false}], - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], - '@typescript-eslint/consistent-type-definitions': 'off', - '@typescript-eslint/no-empty-object-type': 'off', + 'react/jsx-props-no-spreading': 'off', + + // Onyx is this package; multiple instances are intentional in tests and mocks. 'rulesdir/no-multiple-onyx-in-file': 'off', - 'valid-jsdoc': 'off', - 'rulesdir/prefer-import-module-contents': 'off', - 'es/no-optional-chaining': 'off', - 'es/no-nullish-coalescing-operators': 'off', - // Disable JSDoc type rules for TypeScript files (TypeScript provides the types) - 'jsdoc/require-param': 'off', - 'jsdoc/require-param-type': 'off', - 'jsdoc/check-param-names': 'off', - 'jsdoc/check-tag-names': 'off', - 'jsdoc/check-types': 'off', - 'no-func-assign': 'off', - 'no-loop-func': 'off', - 'no-redeclare': 'off', - '@typescript-eslint/no-redeclare': 'error', - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', - }, - ], - 'rulesdir/prefer-onyx-connect-in-libs': 'off', }, }, { - files: ['tests/**/*.{js,jsx,ts,tsx}', 'jestSetup.js', 'lib/**/__mocks__/**/*.{js,ts}'], - languageOptions: { - globals: { - jest: 'readonly', - describe: 'readonly', - it: 'readonly', - test: 'readonly', - expect: 'readonly', - beforeEach: 'readonly', - afterEach: 'readonly', - beforeAll: 'readonly', - afterAll: 'readonly', - fail: 'readonly', - }, - }, + files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], rules: { - '@lwc/lwc/no-async-await': 'off', - 'no-await-in-loop': 'off', - 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], 'import/extensions': 'off', - '@typescript-eslint/no-require-imports': 'off', - 'no-restricted-imports': 'off', + 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], }, }, { @@ -134,8 +75,9 @@ export default [ files: ['lib/storage/providers/MemoryOnlyProvider.ts'], languageOptions: { globals: { - jest: 'readonly', + ...globals.jest, }, }, }, -]; + prettierConfig, +]); diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv new file mode 100644 index 000000000..a1a48b3c9 --- /dev/null +++ b/eslint.seatbelt.tsv @@ -0,0 +1,167 @@ +# eslint-seatbelt temporarily allowed errors +# docs: https://github.com/justjake/eslint-seatbelt#readme + +".prettierrc.cjs" "no-undef" 1 +"buildDocs.ts" "import/no-extraneous-dependencies" 1 +"jest-sequencer.js" "no-undef" 2 +"jest-test-environment.ts" "import/no-extraneous-dependencies" 1 +"jestSetup.js" "no-undef" 2 +"lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-assignment" 1 +"lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-member-access" 1 +"lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"lib/Onyx.ts" "@typescript-eslint/no-loop-func" 1 +"lib/Onyx.ts" "@typescript-eslint/no-redundant-type-constituents" 2 +"lib/Onyx.ts" "@typescript-eslint/no-unnecessary-type-assertion" 3 +"lib/Onyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"lib/Onyx.ts" "@typescript-eslint/restrict-template-expressions" 1 +"lib/Onyx.ts" "rulesdir/prefer-at" 2 +"lib/OnyxCache.ts" "@typescript-eslint/no-redundant-type-constituents" 2 +"lib/OnyxCache.ts" "import/no-extraneous-dependencies" 1 +"lib/OnyxConnectionManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 +"lib/OnyxConnectionManager.ts" "@typescript-eslint/non-nullable-type-assertion-style" 3 +"lib/OnyxConnectionManager.ts" "import/no-extraneous-dependencies" 1 +"lib/OnyxKeys.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"lib/OnyxKeys.ts" "@typescript-eslint/restrict-template-expressions" 1 +"lib/OnyxMerge/index.native.ts" "@typescript-eslint/no-redundant-type-constituents" 2 +"lib/OnyxMerge/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"lib/OnyxMerge/index.ts" "@typescript-eslint/no-redundant-type-constituents" 2 +"lib/OnyxMerge/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"lib/OnyxMerge/types.ts" "@typescript-eslint/no-redundant-type-constituents" 2 +"lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-non-null-assertion" 2 +"lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"lib/OnyxSnapshotCache.ts" "@typescript-eslint/prefer-nullish-coalescing" 2 +"lib/OnyxUtils.ts" "@typescript-eslint/no-redundant-type-constituents" 7 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-argument" 6 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-assignment" 6 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-call" 2 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-member-access" 6 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 35 +"lib/OnyxUtils.ts" "@typescript-eslint/no-use-before-define" 28 +"lib/OnyxUtils.ts" "@typescript-eslint/non-nullable-type-assertion-style" 2 +"lib/OnyxUtils.ts" "@typescript-eslint/prefer-nullish-coalescing" 4 +"lib/OnyxUtils.ts" "@typescript-eslint/restrict-template-expressions" 3 +"lib/OnyxUtils.ts" "rulesdir/prefer-at" 1 +"lib/Str.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"lib/storage/InstanceSync/index.ts" "import/no-extraneous-dependencies" 1 +"lib/storage/InstanceSync/index.web.ts" "jsdoc/no-types" 2 +"lib/storage/__mocks__/index.ts" "@typescript-eslint/no-unsafe-argument" 1 +"lib/storage/index.ts" "@typescript-eslint/restrict-template-expressions" 1 +"lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-return" 1 +"lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"lib/storage/providers/IDBKeyValProvider/index.ts" "rulesdir/prefer-at" 2 +"lib/storage/providers/MemoryOnlyProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unused-vars" 1 +"lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/naming-convention" 3 +"lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-return" 3 +"lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"lib/types.ts" "@typescript-eslint/consistent-type-definitions" 1 +"lib/types.ts" "@typescript-eslint/no-empty-object-type" 1 +"lib/types.ts" "@typescript-eslint/no-restricted-types" 2 +"lib/useLiveRef.ts" "@typescript-eslint/no-deprecated" 1 +"lib/useLiveRef.ts" "react-hooks/refs" 1 +"lib/useOnyx.ts" "@typescript-eslint/no-deprecated" 1 +"lib/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"lib/utils.ts" "@typescript-eslint/naming-convention" 2 +"lib/utils.ts" "@typescript-eslint/no-redundant-type-constituents" 2 +"lib/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 +"tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/restrict-template-expressions" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-assignment" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/non-nullable-type-assertion-style" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "rulesdir/prefer-at" 13 +"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/dot-notation" 2 +"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/non-nullable-type-assertion-style" 1 +"tests/perf-test/OnyxConnectionManager.perf-test.ts" "rulesdir/prefer-at" 6 +"tests/perf-test/OnyxSnapshotCache.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"tests/perf-test/OnyxSnapshotCache.perf-test.ts" "rulesdir/prefer-at" 1 +"tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-misused-promises" 1 +"tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 +"tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"tests/perf-test/useOnyx.perf-test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"tests/perf-test/utils.perf-test.ts" "@typescript-eslint/no-unsafe-call" 1 +"tests/perf-test/utils.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 3 +"tests/unit/DevToolsTest.ts" "@typescript-eslint/dot-notation" 8 +"tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 +"tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 +"tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 +"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/dot-notation" 3 +"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 +"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-return" 1 +"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"tests/unit/OnyxConnectionManagerTest.ts" "rulesdir/prefer-at" 1 +"tests/unit/OnyxKeysTest.ts" "@typescript-eslint/naming-convention" 16 +"tests/unit/OnyxSnapshotCacheTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 +"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-argument" 2 +"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-assignment" 6 +"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-member-access" 9 +"tests/unit/collectionHydrationTest.ts" "rulesdir/prefer-at" 4 +"tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/naming-convention" 1 +"tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 +"tests/unit/mocks/sqliteMock.ts" "rulesdir/prefer-at" 2 +"tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-non-null-assertion" 3 +"tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-unsafe-assignment" 7 +"tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-unsafe-member-access" 6 +"tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 +"tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 +"tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/naming-convention" 12 +"tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 4 +"tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 +"tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 +"tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/naming-convention" 16 +"tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 +"tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 +"tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 +"tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"tests/unit/onyxTest.ts" "@typescript-eslint/naming-convention" 122 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-deprecated" 2 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-non-null-assertion" 1 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 27 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-assignment" 9 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-call" 2 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-member-access" 26 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-return" 3 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"tests/unit/onyxTest.ts" "import/no-extraneous-dependencies" 2 +"tests/unit/onyxTest.ts" "rulesdir/prefer-at" 4 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/await-thenable" 6 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 21 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-assignment" 10 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-call" 2 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-member-access" 20 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-return" 1 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"tests/unit/onyxUtilsTest.ts" "rulesdir/prefer-at" 4 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unsafe-member-access" 2 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/unbound-method" 2 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "rulesdir/prefer-at" 7 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 7 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-member-access" 2 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-return" 1 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "rulesdir/prefer-at" 4 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-non-null-assertion" 1 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-assignment" 18 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/unbound-method" 10 +"tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 +"tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 +"tests/unit/useOnyxTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 21 +"tests/unit/useOnyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 +"tests/unit/useOnyxTest.ts" "@typescript-eslint/prefer-nullish-coalescing" 1 +"tests/unit/useOnyxTest.ts" "rulesdir/prefer-at" 1 +"tests/unit/utilsTest.ts" "@typescript-eslint/no-unsafe-member-access" 3 +"tests/unit/utilsTest.ts" "rulesdir/prefer-at" 2 +"tests/utils/alternateLists.ts" "rulesdir/prefer-at" 2 +"tests/utils/collections/reportActions.ts" "@typescript-eslint/restrict-template-expressions" 1 diff --git a/lib/Logger.ts b/lib/Logger.ts index 922b1dd0b..615853e25 100644 --- a/lib/Logger.ts +++ b/lib/Logger.ts @@ -7,7 +7,6 @@ type LogData = { }; type LoggerCallback = (data: LogData) => void; -// eslint-disable-next-line @typescript-eslint/no-empty-function let logger: LoggerCallback = () => {}; /** diff --git a/package-lock.json b/package-lock.json index 5bc3ffb70..8f1b8fa58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,9 @@ "devDependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.1", + "@eslint/eslintrc": "^3.3.1", "@jest/globals": "^29.7.0", - "@lwc/eslint-plugin-lwc": "^3.3.0", "@ngneat/falso": "^7.3.0", - "@react-native-community/eslint-config": "^3.2.0", "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", "@testing-library/react-native": "^13.2.0", @@ -35,18 +34,14 @@ "@types/react": "^18.2.14", "@types/react-native": "^0.70.0", "@types/underscore": "^1.11.15", - "@typescript-eslint/eslint-plugin": "^8.51.0", - "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", "better-sqlite3": "^12.10.0", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "2.0.108", - "eslint-config-prettier": "^9.1.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.5", + "eslint-config-expensify": "4.0.5", + "eslint-config-prettier": "^10.1.5", + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-seatbelt": "^0.1.3", "fake-indexeddb": "^6.2.5", "idb-keyval": "^6.2.1", "jest": "^29.7.0", @@ -164,13 +159,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -179,9 +174,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -239,14 +234,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -256,27 +251,27 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -286,18 +281,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", - "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.5", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -308,14 +303,14 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "engines": { @@ -326,26 +321,26 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" + "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -353,43 +348,43 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -399,22 +394,22 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -422,15 +417,15 @@ } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -440,15 +435,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -458,23 +453,23 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -482,9 +477,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -492,9 +487,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -502,15 +497,15 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -531,13 +526,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -547,15 +542,15 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -565,14 +560,14 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -582,14 +577,32 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -599,16 +612,16 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -618,15 +631,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", - "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -821,14 +834,14 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -838,13 +851,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1040,13 +1053,13 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1056,15 +1069,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", - "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1074,15 +1087,15 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", - "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1092,14 +1105,14 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1109,13 +1122,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz", - "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1125,14 +1138,14 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1142,15 +1155,15 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", - "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1160,18 +1173,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", - "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.4" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1181,14 +1194,14 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1198,14 +1211,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1215,15 +1228,15 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", - "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1233,14 +1246,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1250,15 +1263,15 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1268,14 +1281,14 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1285,15 +1298,15 @@ } }, "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", - "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1303,14 +1316,14 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", - "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1320,14 +1333,14 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1354,14 +1367,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1371,15 +1384,15 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1389,14 +1402,14 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", - "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1406,13 +1419,13 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1422,13 +1435,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz", - "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1438,14 +1451,14 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1455,15 +1468,15 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1473,14 +1486,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1490,17 +1503,17 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", - "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.5" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1510,15 +1523,15 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1528,14 +1541,14 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1545,14 +1558,14 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1562,13 +1575,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1578,13 +1591,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1594,17 +1607,17 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", - "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.4" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1614,15 +1627,15 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1632,13 +1645,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1648,14 +1661,14 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz", - "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1665,13 +1678,13 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1681,14 +1694,14 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1698,15 +1711,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1716,14 +1729,14 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1801,13 +1814,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", - "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1817,15 +1830,15 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", - "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1835,14 +1848,14 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1873,13 +1886,13 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1889,14 +1902,14 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1906,13 +1919,13 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1922,14 +1935,14 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1939,14 +1952,14 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1976,14 +1989,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1993,15 +2006,15 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", - "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2011,14 +2024,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2028,15 +2041,15 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", - "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2046,82 +2059,83 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz", - "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.27.1", - "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.0", - "@babel/plugin-transform-async-to-generator": "^7.27.1", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.5", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-class-static-block": "^7.28.3", - "@babel/plugin-transform-classes": "^7.28.4", - "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.27.1", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.0", - "@babel/plugin-transform-exponentiation-operator": "^7.28.5", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.27.1", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.5", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-modules-systemjs": "^7.28.5", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.28.4", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.27.1", - "@babel/plugin-transform-private-property-in-object": "^7.27.1", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.4", - "@babel/plugin-transform-regexp-modifiers": "^7.27.1", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.27.1", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.27.1", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "engines": { @@ -2131,6 +2145,21 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/preset-flow": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", @@ -2251,33 +2280,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -2305,14 +2334,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2495,9 +2524,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -3337,25 +3366,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@lwc/eslint-plugin-lwc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-3.3.0.tgz", - "integrity": "sha512-qTcRGGTdBAfuPlJTL2sTk65skQHqnthz8TRx/1IasFZb+aljSn+CamnaPyzq8s2xAzQsS+NZJQoWlrZjyzzCzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-xml-parser": "^4.5.1", - "globals": "~15.14.0", - "minimatch": "~9.0.4" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "@babel/eslint-parser": "^7", - "eslint": "^9" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3390,44 +3400,6 @@ "eslint-scope": "5.1.1" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -3591,318 +3563,25 @@ } }, "node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@react-native-community/eslint-config": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz", - "integrity": "sha512-ZjGvoeiBtCbd506hQqwjKmkWPgynGUoJspG8/MuV/EfKnkjCtBmeJvq2n+sWbWEvL9LWXDp2GJmPzmvU5RSvKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.14.0", - "@babel/eslint-parser": "^7.18.2", - "@react-native-community/eslint-plugin": "^1.1.0", - "@typescript-eslint/eslint-plugin": "^5.30.5", - "@typescript-eslint/parser": "^5.30.5", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-eslint-comments": "^3.2.0", - "eslint-plugin-ft-flow": "^2.0.1", - "eslint-plugin-jest": "^26.5.3", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "^7.30.1", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-native": "^4.0.0" - }, - "peerDependencies": { - "eslint": ">=8", - "prettier": ">=2" - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/parser": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/eslint-config-prettier": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", - "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/eslint-plugin-jest": { - "version": "26.9.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-26.9.0.tgz", - "integrity": "sha512-TWJxWGp1J628gxh2KhaH1H1paEdgE2J61BBF1I59c6xWeL5+D1BzMxGDN/nXAfX+aSkR5u80K+XhskK6Gwq9ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^5.10.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@react-native-community/eslint-config/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" } }, - "node_modules/@react-native-community/eslint-plugin": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@react-native-community/eslint-plugin/-/eslint-plugin-1.3.0.tgz", - "integrity": "sha512-+zDZ20NUnSWghj7Ku5aFphMzuM9JulqCW+aPXT6IfIXFbb8tzYTTOSeRFOtuekJ99ibW2fUCSsjuKNlwDIbHFg==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } }, "node_modules/@react-native/assets-registry": { "version": "0.76.3", @@ -4507,13 +4186,6 @@ "@types/react": "*" } }, - "node_modules/@types/semver": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", - "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4553,20 +4225,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", - "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/type-utils": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4576,9 +4248,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.51.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -4592,17 +4264,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", - "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4612,20 +4284,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", - "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.51.0", - "@typescript-eslint/types": "^8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4635,18 +4307,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", - "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4657,9 +4329,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", - "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -4670,21 +4342,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", - "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.2.0" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4694,14 +4366,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", - "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -4713,21 +4385,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", - "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.51.0", - "@typescript-eslint/tsconfig-utils": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4737,13 +4409,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -4754,16 +4465,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", - "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4773,19 +4484,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", - "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4796,13 +4507,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5415,16 +5126,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -5721,14 +5422,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { @@ -5750,13 +5451,13 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -5871,6 +5572,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -5984,9 +5698,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -6004,10 +5718,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -6203,9 +5918,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -6724,13 +6439,13 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", - "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.25.1" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", @@ -7184,19 +6899,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dmd": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/dmd/-/dmd-6.2.3.tgz", @@ -7278,9 +6980,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.180", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", - "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, @@ -7654,28 +7356,29 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.108", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.108.tgz", - "integrity": "sha512-J+tW0s5rli+ewOdY3fyf8Hk3G8WJho4YPNlTeSyeS+zD4JsQcqpOFF9wKzQFr8Tl/2E9f/4gxcYo2xiHolONLA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.5.tgz", + "integrity": "sha512-vFozo1iwu0elMxwZgh/Jkrsi/iXz4q9Js/E4Geee7TaeuaGeywnQLWdy7PAW7s1K+tEF+cEww0D29OeaCT95gQ==", "dev": true, "license": "ISC", "dependencies": { "@babel/eslint-parser": "^7.28.4", - "@lwc/eslint-plugin-lwc": "^3.2.0", "@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/utils": "^8.44.1", "confusing-browser-globals": "^1.0.11", "eslint": "^9.36.0", "eslint-plugin-es": "^4.1.0", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jest": "^29.0.1", "eslint-plugin-jsdoc": "^60.2.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-rulesdir": "^0.2.2", "eslint-plugin-unicorn": "^61.0.2", "globals": "^15.14.0", "lodash": "^4.17.21", + "typescript-eslint": "^8.61.0", "underscore": "^1.13.6" } }, @@ -7696,9 +7399,9 @@ } }, "node_modules/eslint-config-expensify/node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -7712,7 +7415,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-config-expensify/node_modules/eslint-plugin-unicorn": { @@ -7848,14 +7551,17 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", - "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, "peerDependencies": { "eslint": ">=7.0.0" } @@ -7908,9 +7614,9 @@ } }, "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", "dev": true, "license": "ISC", "dependencies": { @@ -7990,54 +7696,6 @@ "eslint": ">=4.19.1" } }, - "node_modules/eslint-plugin-eslint-comments": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz", - "integrity": "sha512-0jkOl0hfojIHHmEHgmNdqv4fmh7300NdpA9FFpF7zaoLvB/QeXOGNLIo86oAveJFrfB1p05kC8hpEMHM8DwWVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5", - "ignore": "^5.0.5" - }, - "engines": { - "node": ">=6.5.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=4.19.1" - } - }, - "node_modules/eslint-plugin-eslint-comments/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/eslint-plugin-ft-flow": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-ft-flow/-/eslint-plugin-ft-flow-2.0.3.tgz", - "integrity": "sha512-Vbsd/b+LYA99jUbsL6viEUWShFaYQt2YQs3QN3f+aeszOhh2sgdcU0mjzDyD4yyBvMc8qy2uwvBBWfMzEX06tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "string-natural-compare": "^3.0.1" - }, - "engines": { - "node": ">=12.22.0" - }, - "peerDependencies": { - "@babel/eslint-parser": "^7.12.0", - "eslint": "^8.1.0" - } - }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -8119,6 +7777,36 @@ "node": "*" } }, + "node_modules/eslint-plugin-jest": { + "version": "29.15.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.15.2.tgz", + "integrity": "sha512-kEN4r9RZl1xcsb4arGq89LrcVdOUFII/JSCwtTPJyv16mDwmPrcuEQwpxqZHeINvcsd7oK5O/rhdGlxFRaZwvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <7.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/eslint-plugin-jsdoc": { "version": "60.8.3", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-60.8.3.tgz", @@ -8213,28 +7901,6 @@ "node": "*" } }, - "node_modules/eslint-plugin-prettier": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", - "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "eslint": ">=7.28.0", - "prettier": ">=2.0.0" - }, - "peerDependenciesMeta": { - "eslint-config-prettier": { - "optional": true - } - } - }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -8268,39 +7934,6 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react-native": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-native/-/eslint-plugin-react-native-4.1.0.tgz", - "integrity": "sha512-QLo7rzTBOl43FvVqDdq5Ql9IoElIuTdjrz9SKAXCvULvBoRZ44JGSkx9z4999ZusCsb4rK3gjS8gOGyeYqZv2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-plugin-react-native-globals": "^0.1.1" - }, - "peerDependencies": { - "eslint": "^3.17.0 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react-native-globals": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-native-globals/-/eslint-plugin-react-native-globals-0.1.2.tgz", - "integrity": "sha512-9aEPf1JEpiTjcFAmmyw8eiIXmcNZOqaZyHO77wgm0/dWfT/oxC1SrIq8ET38pMxHYrcB6Uew+TzUVsBeczF88g==", - "dev": true, - "license": "MIT" - }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -8390,6 +8023,31 @@ "node": ">=4.0" } }, + "node_modules/eslint-seatbelt": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/eslint-seatbelt/-/eslint-seatbelt-0.1.3.tgz", + "integrity": "sha512-S8vot6z0Sr4wN1hGVmh3ex8hb7pSm0YNwZNS2Kkqd7CbtwjWQkqXIpoqZSEEW7PaCDMK6G6RBoXXRW/JygxTJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-command-line-args": "^2.5.1" + }, + "bin": { + "eslint-seatbelt": "dist/command.js" + }, + "peerDependencies": { + "@types/eslint": "*", + "eslint": "*" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint": { + "optional": true + } + } + }, "node_modules/eslint-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", @@ -8675,49 +8333,12 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/fast-equals": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8732,35 +8353,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^1.1.1" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -9438,9 +9030,9 @@ } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", "engines": { @@ -9467,27 +9059,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9506,14 +9077,7 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/handlebars": { "version": "4.7.8", @@ -12008,16 +11572,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/metro": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/metro/-/metro-0.81.5.tgz", @@ -12623,13 +12177,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true, - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -12774,11 +12321,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nodemon": { "version": "3.1.10", @@ -13369,16 +12919,6 @@ "dev": true, "license": "ISC" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13562,19 +13102,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -13731,27 +13258,6 @@ "inherits": "~2.0.3" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -14239,9 +13745,9 @@ "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "dev": true, "license": "MIT", "dependencies": { @@ -14303,23 +13809,36 @@ } }, "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", + "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", + "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" + "unicode-match-property-value-ecmascript": "^2.2.1" }, "engines": { "node": ">=4" } }, + "node_modules/regexpu-core/node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", @@ -14381,13 +13900,14 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -14454,17 +13974,6 @@ "node": ">=10" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -14482,30 +13991,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -15295,6 +14780,13 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", + "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", + "dev": true, + "license": "WTFPL OR MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -15309,13 +14801,6 @@ "node": ">=10" } }, - "node_modules/string-natural-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", - "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", - "dev": true, - "license": "MIT" - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -15547,19 +15032,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -15906,9 +15378,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", - "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -15918,6 +15390,176 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-command-line-args": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/ts-command-line-args/-/ts-command-line-args-2.5.1.tgz", + "integrity": "sha512-H69ZwTw3rFHb5WYpQya40YAX2/w7Ut75uUECbgBIsLmM+BNuYnxsltfyyLMxy6sEeKxgijLTnQtLd0nKd6+IYw==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.1.0", + "command-line-args": "^5.1.1", + "command-line-usage": "^6.1.0", + "string-format": "^2.0.0" + }, + "bin": { + "write-markdown": "dist/write-markdown.js" + } + }, + "node_modules/ts-command-line-args/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-command-line-args/node_modules/array-back": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", + "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-command-line-args/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ts-command-line-args/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-command-line-args/node_modules/command-line-usage": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.3.tgz", + "integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^4.0.2", + "chalk": "^2.4.2", + "table-layout": "^1.0.2", + "typical": "^5.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ts-command-line-args/node_modules/command-line-usage/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-command-line-args/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ts-command-line-args/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-command-line-args/node_modules/reduce-flatten": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz", + "integrity": "sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-command-line-args/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-command-line-args/node_modules/table-layout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-1.0.2.tgz", + "integrity": "sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^4.0.1", + "deep-extend": "~0.6.0", + "typical": "^5.2.0", + "wordwrapjs": "^4.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ts-command-line-args/node_modules/typical": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", + "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-command-line-args/node_modules/wordwrapjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.1.tgz", + "integrity": "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==", + "dev": true, + "license": "MIT", + "dependencies": { + "reduce-flatten": "^2.0.0", + "typical": "^5.2.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ts-markdown-builder": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/ts-markdown-builder/-/ts-markdown-builder-0.4.1.tgz", @@ -16025,29 +15667,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -16348,6 +15967,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/typical": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/typical/-/typical-2.6.1.tgz", @@ -16453,9 +16096,9 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "dev": true, "license": "MIT", "engines": { @@ -16463,9 +16106,9 @@ } }, "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "dev": true, "license": "MIT", "engines": { @@ -16535,9 +16178,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 18b5cf97c..794e83eb5 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,8 @@ "@actions/core": "^1.10.1", "@actions/github": "^6.0.1", "@jest/globals": "^29.7.0", - "@lwc/eslint-plugin-lwc": "^3.3.0", + "@eslint/eslintrc": "^3.3.1", "@ngneat/falso": "^7.3.0", - "@react-native-community/eslint-config": "^3.2.0", "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", "@testing-library/react-native": "^13.2.0", @@ -69,18 +68,14 @@ "@types/react": "^18.2.14", "@types/react-native": "^0.70.0", "@types/underscore": "^1.11.15", - "@typescript-eslint/eslint-plugin": "^8.51.0", - "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", "better-sqlite3": "^12.10.0", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "2.0.108", - "eslint-config-prettier": "^9.1.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.5", + "eslint-config-expensify": "4.0.5", + "eslint-config-prettier": "^10.1.5", + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-seatbelt": "^0.1.3", "fake-indexeddb": "^6.2.5", "idb-keyval": "^6.2.1", "jest": "^29.7.0", diff --git a/tests/perf-test/OnyxConnectionManager.perf-test.ts b/tests/perf-test/OnyxConnectionManager.perf-test.ts index fc3e5c519..752fbbb51 100644 --- a/tests/perf-test/OnyxConnectionManager.perf-test.ts +++ b/tests/perf-test/OnyxConnectionManager.perf-test.ts @@ -29,9 +29,7 @@ const mockedReportActionsKeys = Object.keys(mockedReportActionsMap); // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -// eslint-disable-next-line dot-notation const generateConnectionID = connectionManager['generateConnectionID']; -// eslint-disable-next-line dot-notation const fireCallbacks = connectionManager['fireCallbacks']; const resetConectionManagerAfterEachMeasure = () => { @@ -66,9 +64,15 @@ describe('OnyxConnectionManager', () => { await measureFunction(() => fireCallbacks(connectionID), { beforeEach: async () => { - connectionID = connectionManager.connect({key: mockedReportActionsKeys[0], callback: jest.fn()}).id; + connectionID = connectionManager.connect({ + key: mockedReportActionsKeys[0], + callback: jest.fn(), + }).id; for (let i = 0; i < 9999; i++) { - connectionManager.connect({key: mockedReportActionsKeys[0], callback: jest.fn()}); + connectionManager.connect({ + key: mockedReportActionsKeys[0], + callback: jest.fn(), + }); } }, afterEach: async () => { @@ -112,7 +116,10 @@ describe('OnyxConnectionManager', () => { }, { beforeEach: async () => { - connection = connectionManager.connect({key: mockedReportActionsKeys[0], callback: jest.fn()}); + connection = connectionManager.connect({ + key: mockedReportActionsKeys[0], + callback: jest.fn(), + }); }, afterEach: async () => { resetConectionManagerAfterEachMeasure(); @@ -128,7 +135,10 @@ describe('OnyxConnectionManager', () => { await measureFunction(() => connectionManager.disconnectAll(), { beforeEach: async () => { for (let i = 0; i < 10000; i++) { - connectionManager.connect({key: mockedReportActionsKeys[0], callback: jest.fn()}); + connectionManager.connect({ + key: mockedReportActionsKeys[0], + callback: jest.fn(), + }); } }, afterEach: async () => { diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index 60664c5b3..fe2960e04 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -1,4 +1,3 @@ -/* eslint-disable dot-notation */ import Onyx from '../../lib'; import {getDevToolsInstance} from '../../lib/DevTools'; import type {DevtoolsConnection, RealDevTools as RealDevToolsType} from '../../lib/DevTools'; @@ -32,7 +31,10 @@ const exampleObject = {name: 'Pedro'}; const mergedCollection = {...initialKeyStates, ...exampleCollection}; -const mergedObject = {...initialKeyStates, [ONYX_KEYS.OBJECT_KEY]: {...exampleObject, id: 42}}; +const mergedObject = { + ...initialKeyStates, + [ONYX_KEYS.OBJECT_KEY]: {...exampleObject, id: 42}, +}; describe('DevTools', () => { let initMock: jest.Mock; @@ -45,7 +47,10 @@ describe('DevTools', () => { // Mock the connectViaExtension method to return our mock connection // This needs to happen before RealDevTools is instantiated - const mockConnection = {init: initMock, send: sendMock} as unknown as DevtoolsConnection; + const mockConnection = { + init: initMock, + send: sendMock, + } as unknown as DevtoolsConnection; jest.spyOn(RealDevTools.prototype, 'connectViaExtension').mockReturnValue(mockConnection); Onyx.init({ @@ -76,19 +81,34 @@ describe('DevTools', () => { describe('Set', () => { it('Sends the set state correctly to the extension', async () => { await Onyx.set(ONYX_KEYS.SOME_KEY, 3); - expect(sendMock).toHaveBeenCalledWith({payload: 3, type: utils.formatActionName(Onyx.METHOD.SET, ONYX_KEYS.SOME_KEY)}, {...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3}); + expect(sendMock).toHaveBeenCalledWith( + { + payload: 3, + type: utils.formatActionName(Onyx.METHOD.SET, ONYX_KEYS.SOME_KEY), + }, + {...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3}, + ); }); it('Sets the internal state correctly', async () => { await Onyx.set(ONYX_KEYS.SOME_KEY, 3); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3}); + expect(devToolsInstance['state']).toEqual({ + ...initialKeyStates, + [ONYX_KEYS.SOME_KEY]: 3, + }); }); }); describe('Merge', () => { it('Sends the merged state correctly to the extension', async () => { await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); - expect(sendMock).toHaveBeenCalledWith({payload: exampleObject, type: utils.formatActionName(Onyx.METHOD.MERGE, ONYX_KEYS.OBJECT_KEY)}, mergedObject); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleObject, + type: utils.formatActionName(Onyx.METHOD.MERGE, ONYX_KEYS.OBJECT_KEY), + }, + mergedObject, + ); }); it('Sets the internal state correctly', async () => { await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); @@ -100,7 +120,13 @@ describe('DevTools', () => { describe('MergeCollection', () => { it('Sends the mergecollection state correctly to the extension', async () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); - expect(sendMock).toHaveBeenCalledWith({payload: exampleCollection, type: utils.formatActionName(Onyx.METHOD.MERGE_COLLECTION)}, mergedCollection); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleCollection, + type: utils.formatActionName(Onyx.METHOD.MERGE_COLLECTION), + }, + mergedCollection, + ); }); it('Sets the internal state correctly', async () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); @@ -112,7 +138,13 @@ describe('DevTools', () => { describe('MultiSet', () => { it('Sends the multiset state correctly to the extension', async () => { await Onyx.multiSet(exampleCollection); - expect(sendMock).toHaveBeenCalledWith({payload: exampleCollection, type: utils.formatActionName(Onyx.METHOD.MULTI_SET)}, mergedCollection); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleCollection, + type: utils.formatActionName(Onyx.METHOD.MULTI_SET), + }, + mergedCollection, + ); }); it('Sets the internal state correctly', async () => { await Onyx.multiSet(exampleCollection); @@ -135,14 +167,20 @@ describe('DevTools', () => { await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); await Onyx.clear([ONYX_KEYS.NUM_KEY]); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2}); + expect(devToolsInstance['state']).toEqual({ + ...initialKeyStates, + [ONYX_KEYS.NUM_KEY]: 2, + }); }); it('Preserves collection member keys when a collection key is passed to keysToPreserve', async () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({...initialKeyStates, ...exampleCollection}); + expect(devToolsInstance['state']).toEqual({ + ...initialKeyStates, + ...exampleCollection, + }); }); }); }); diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index 543dc23c1..36b826b6a 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -8,11 +8,8 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -// eslint-disable-next-line dot-notation const connectionsMap = connectionManager['connectionsMap']; -// eslint-disable-next-line dot-notation const generateConnectionID = connectionManager['generateConnectionID']; -// eslint-disable-next-line dot-notation const getSessionID = () => connectionManager['sessionID']; const ONYXKEYS = { @@ -43,13 +40,22 @@ describe('OnyxConnectionManager', () => { }); it("should generate a stable connection ID regardless of the order which the option's properties were passed", async () => { - const connectionID = generateConnectionID({key: ONYXKEYS.TEST_KEY, waitForCollectionCallback: true}); + const connectionID = generateConnectionID({ + key: ONYXKEYS.TEST_KEY, + waitForCollectionCallback: true, + }); expect(connectionID).toEqual(`onyxKey=${ONYXKEYS.TEST_KEY},waitForCollectionCallback=true,sessionID=${getSessionID()}`); }); it('should generate unique connection IDs if certain options are passed', async () => { - const connectionID1 = generateConnectionID({key: ONYXKEYS.TEST_KEY, reuseConnection: false}); - const connectionID2 = generateConnectionID({key: ONYXKEYS.TEST_KEY, reuseConnection: false}); + const connectionID1 = generateConnectionID({ + key: ONYXKEYS.TEST_KEY, + reuseConnection: false, + }); + const connectionID2 = generateConnectionID({ + key: ONYXKEYS.TEST_KEY, + reuseConnection: false, + }); expect(connectionID1.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},waitForCollectionCallback=false,sessionID=${getSessionID()},uniqueID=`)).toBeTruthy(); expect(connectionID2.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},waitForCollectionCallback=false,sessionID=${getSessionID()},uniqueID=`)).toBeTruthy(); expect(connectionID1).not.toEqual(connectionID2); @@ -69,7 +75,10 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); const callback1 = jest.fn(); - const connection = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + const connection = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); expect(connectionsMap.has(connection.id)).toBeTruthy(); @@ -87,10 +96,16 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); const callback1 = jest.fn(); - const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); const callback2 = jest.fn(); - const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback2}); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); expect(connection1.id).toEqual(connection2.id); expect(connectionsMap.size).toEqual(1); @@ -122,10 +137,17 @@ describe('OnyxConnectionManager', () => { ]); const callback1 = jest.fn(); - const connection1 = connectionManager.connect({key: ONYXKEYS.COLLECTION.TEST_KEY, callback: callback1}); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: callback1, + }); const callback2 = jest.fn(); - const connection2 = connectionManager.connect({key: ONYXKEYS.COLLECTION.TEST_KEY, callback: callback2, waitForCollectionCallback: true}); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: callback2, + waitForCollectionCallback: true, + }); expect(connection1.id).not.toEqual(connection2.id); expect(connectionsMap.size).toEqual(2); @@ -151,7 +173,10 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); const callback1 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); await act(async () => waitForPromisesToResolve()); @@ -159,13 +184,22 @@ describe('OnyxConnectionManager', () => { expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); const callback2 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback2}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); const callback3 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback3}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback3, + }); const callback4 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback4}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback4, + }); await act(async () => waitForPromisesToResolve()); @@ -181,7 +215,10 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); const callback1 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); await act(async () => waitForPromisesToResolve()); @@ -208,10 +245,17 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); const callback1 = jest.fn(); - const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); const callback2 = jest.fn(); - const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, reuseConnection: false, callback: callback2}); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + reuseConnection: false, + callback: callback2, + }); expect(connection1.id).not.toEqual(connection2.id); expect(connectionsMap.size).toEqual(2); @@ -221,9 +265,18 @@ describe('OnyxConnectionManager', () => { it("should create a separate connection to the same key when it's a collection one and waitForCollectionCallback is undefined/false", async () => { const collection = { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: { + id: 'entry3_id', + name: 'entry3_name', + }, }; Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, collection as GenericCollection); @@ -231,7 +284,11 @@ describe('OnyxConnectionManager', () => { await act(async () => waitForPromisesToResolve()); const callback1 = jest.fn(); - const connection1 = connectionManager.connect({key: ONYXKEYS.COLLECTION.TEST_KEY, waitForCollectionCallback: undefined, callback: callback1}); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + waitForCollectionCallback: undefined, + callback: callback1, + }); await act(async () => waitForPromisesToResolve()); @@ -241,7 +298,11 @@ describe('OnyxConnectionManager', () => { expect(callback1).toHaveBeenNthCalledWith(3, collection[`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`], `${ONYXKEYS.COLLECTION.TEST_KEY}entry3`); const callback2 = jest.fn(); - const connection2 = connectionManager.connect({key: ONYXKEYS.COLLECTION.TEST_KEY, waitForCollectionCallback: false, callback: callback2}); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + waitForCollectionCallback: false, + callback: callback2, + }); expect(connection1.id).not.toEqual(connection2.id); expect(connectionsMap.size).toEqual(2); @@ -264,7 +325,10 @@ describe('OnyxConnectionManager', () => { }).not.toThrow(); expect(() => { - connectionManager.disconnect({id: 'connectionID1', callbackID: 'callbackID1'}); + connectionManager.disconnect({ + id: 'connectionID1', + callbackID: 'callbackID1', + }); }).not.toThrow(); }); @@ -272,7 +336,10 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); const callback1 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); expect(connectionsMap.size).toEqual(1); await act(async () => waitForPromisesToResolve()); @@ -288,10 +355,16 @@ describe('OnyxConnectionManager', () => { callback1.mockReset(); const callback2 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback2}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); const callback3 = jest.fn(); - connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback3}); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback3, + }); // We expect to have two connections for ONYXKEYS.TEST_KEY, one for the first subscription before Onyx.clear(), // and the other for the two subscriptions with the same key after Onyx.clear(). @@ -323,8 +396,16 @@ describe('OnyxConnectionManager', () => { it('should clean up the correct subscription ID from lastConnectionCallbackData on disconnect', async () => { const deleteSpy = jest.spyOn(Map.prototype, 'delete'); - const connectionA = Onyx.connect({key: ONYXKEYS.TEST_KEY, callback: jest.fn(), reuseConnection: false}); - Onyx.connect({key: ONYXKEYS.TEST_KEY, callback: jest.fn(), reuseConnection: false}); + const connectionA = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); + Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); await act(async () => waitForPromisesToResolve()); const subscriptionIdA = connectionsMap.get(connectionA.id)?.subscriptionID; @@ -344,8 +425,16 @@ describe('OnyxConnectionManager', () => { it('should remove the subscription ID from onyxKeyToSubscriptionIDs on disconnect', async () => { const setSpy = jest.spyOn(Map.prototype, 'set'); - const connectionA = Onyx.connect({key: ONYXKEYS.TEST_KEY, callback: jest.fn(), reuseConnection: false}); - const connectionB = Onyx.connect({key: ONYXKEYS.TEST_KEY, callback: jest.fn(), reuseConnection: false}); + const connectionA = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); + const connectionB = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); await act(async () => waitForPromisesToResolve()); const subscriptionIdA = connectionsMap.get(connectionA.id)?.subscriptionID; @@ -372,13 +461,22 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY_2, 'test2'); const callback1 = jest.fn(); - const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); const callback2 = jest.fn(); - const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback2}); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); const callback3 = jest.fn(); - const connection3 = connectionManager.connect({key: ONYXKEYS.TEST_KEY_2, callback: callback3}); + const connection3 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY_2, + callback: callback3, + }); expect(connection1.id).toEqual(connection2.id); expect(connectionsMap.size).toEqual(2); @@ -398,13 +496,19 @@ describe('OnyxConnectionManager', () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); await StorageMock.setItem(ONYXKEYS.TEST_KEY_2, 'test2'); - const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: jest.fn()}); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + }); expect(connectionsMap.size).toEqual(1); connectionManager.refreshSessionID(); - const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: jest.fn()}); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + }); expect(connectionsMap.size).toEqual(2); expect(connectionsMap.has(connection1.id)).toBeTruthy(); From bd03a87165bae2f048a3c9c0df83fe29cc4caa92 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 19 Jun 2026 11:45:32 -0700 Subject: [PATCH 02/20] Remove redundant es/* operator overrides from ESLint config Base config already allows ?? and ?. in TypeScript and bans them only in JavaScript via file-scoped rules in es6.js. Co-authored-by: Cursor --- eslint.config.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 3941f98de..bd07f1ca9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,8 +48,6 @@ export default defineConfig([ }, rules: { 'class-methods-use-this': 'off', - 'es/no-nullish-coalescing-operators': 'off', - 'es/no-optional-chaining': 'off', 'react/prop-types': 'off', 'react/require-default-props': 'off', 'react/jsx-props-no-spreading': 'off', From ec40a224b34a611bf462f8272b48d3af5455f100 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 19 Jun 2026 12:27:32 -0700 Subject: [PATCH 03/20] Upgrade eslint-config-expensify to 4.0.6 Drop the local class-methods-use-this override now that react.js no longer re-enables the rule disabled in style.js. Co-authored-by: Cursor --- eslint.config.mjs | 155 ++++++++++++++++++++++++++-------------------- package-lock.json | 8 +-- package.json | 2 +- 3 files changed, 92 insertions(+), 73 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index bd07f1ca9..369f875c1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,81 +1,100 @@ -import browserConfig from 'eslint-config-expensify/browser'; -import reactConfig from 'eslint-config-expensify/react'; -import scriptsConfig from 'eslint-config-expensify/scripts'; -import tsExpensifyConfig from 'eslint-config-expensify/typescript'; -import jestConfig from 'eslint-config-expensify/jest'; -import prettierConfig from 'eslint-config-prettier'; -import seatbelt from 'eslint-seatbelt'; -import rulesdir from 'eslint-plugin-rulesdir'; -import {defineConfig, globalIgnores} from 'eslint/config'; -import globals from 'globals'; -import path from 'node:path'; -import {createRequire} from 'node:module'; -import {fileURLToPath} from 'node:url'; +import browserConfig from "eslint-config-expensify/browser"; +import reactConfig from "eslint-config-expensify/react"; +import scriptsConfig from "eslint-config-expensify/scripts"; +import tsExpensifyConfig from "eslint-config-expensify/typescript"; +import jestConfig from "eslint-config-expensify/jest"; +import prettierConfig from "eslint-config-prettier"; +import seatbelt from "eslint-seatbelt"; +import rulesdir from "eslint-plugin-rulesdir"; +import { defineConfig, globalIgnores } from "eslint/config"; +import globals from "globals"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); -const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); -rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); +const expensifyConfigDirectory = path.dirname( + require.resolve("eslint-config-expensify"), +); +rulesdir.RULES_DIR = path.resolve( + expensifyConfigDirectory, + "eslint-plugin-expensify", +); export default defineConfig([ - seatbelt.configs.enable, - globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**']), - ...browserConfig, - ...reactConfig, - ...tsExpensifyConfig, - ...jestConfig, - ...scriptsConfig, - { - plugins: { - rulesdir, - }, - settings: { - seatbelt: { - seatbeltFile: path.join(dirname, 'eslint.seatbelt.tsv'), - threadsafe: true, - }, - }, + seatbelt.configs.enable, + globalIgnores([ + "dist/**", + "node_modules/**", + "**/*.d.ts", + "**/*.config.js", + "**/*.config.cjs", + "tests/types/**/*.ts", + "cpp/**", + "bench-results/**", + ".github/**", + ]), + ...browserConfig, + ...reactConfig, + ...tsExpensifyConfig, + ...jestConfig, + ...scriptsConfig, + { + plugins: { + rulesdir, }, - { - files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], - settings: { - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - }, - }, + settings: { + seatbelt: { + seatbeltFile: path.join(dirname, "eslint.seatbelt.tsv"), + threadsafe: true, + }, + }, + }, + { + files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], + settings: { + "import/resolver": { + typescript: { + alwaysTryTypes: true, + project: "./tsconfig.json", }, - rules: { - 'class-methods-use-this': 'off', - 'react/prop-types': 'off', - 'react/require-default-props': 'off', - 'react/jsx-props-no-spreading': 'off', + }, + }, + rules: { + "react/prop-types": "off", + "react/require-default-props": "off", + "react/jsx-props-no-spreading": "off", - // Onyx is this package; multiple instances are intentional in tests and mocks. - 'rulesdir/no-multiple-onyx-in-file': 'off', - }, + // Onyx is this package; multiple instances are intentional in tests and mocks. + "rulesdir/no-multiple-onyx-in-file": "off", }, - { - files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], - rules: { - 'import/extensions': 'off', - 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], - }, + }, + { + files: ["tests/**/*", "jestSetup.js", "lib/**/__mocks__/**/*"], + rules: { + "import/extensions": "off", + "no-restricted-syntax": [ + "error", + "ForInStatement", + "LabeledStatement", + "WithStatement", + ], }, - { - files: ['**/*.native.ts', '**/*.native.tsx'], - rules: { - '@typescript-eslint/no-require-imports': 'off', - }, + }, + { + files: ["**/*.native.ts", "**/*.native.tsx"], + rules: { + "@typescript-eslint/no-require-imports": "off", }, - { - files: ['lib/storage/providers/MemoryOnlyProvider.ts'], - languageOptions: { - globals: { - ...globals.jest, - }, - }, + }, + { + files: ["lib/storage/providers/MemoryOnlyProvider.ts"], + languageOptions: { + globals: { + ...globals.jest, + }, }, - prettierConfig, + }, + prettierConfig, ]); diff --git a/package-lock.json b/package-lock.json index 8f1b8fa58..f6569d0d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "better-sqlite3": "^12.10.0", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "4.0.5", + "eslint-config-expensify": "4.0.6", "eslint-config-prettier": "^10.1.5", "eslint-import-resolver-typescript": "^4.4.5", "eslint-seatbelt": "^0.1.3", @@ -7356,9 +7356,9 @@ } }, "node_modules/eslint-config-expensify": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.5.tgz", - "integrity": "sha512-vFozo1iwu0elMxwZgh/Jkrsi/iXz4q9Js/E4Geee7TaeuaGeywnQLWdy7PAW7s1K+tEF+cEww0D29OeaCT95gQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.6.tgz", + "integrity": "sha512-pBRNfKhN5MAN4QFz0LPzaLO+bzd+Zbz/W3Xs7GxtwikS4lTmA8hp37Zd75N68XVtf7g8xUX91kET3mJNlQJWyg==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 794e83eb5..d1bac7d1a 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "better-sqlite3": "^12.10.0", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "4.0.5", + "eslint-config-expensify": "4.0.6", "eslint-config-prettier": "^10.1.5", "eslint-import-resolver-typescript": "^4.4.5", "eslint-seatbelt": "^0.1.3", From 285686c1eee797360cdc88d709ff24516e809167 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 19 Jun 2026 12:30:35 -0700 Subject: [PATCH 04/20] Run prettier on eslint.config.mjs Co-authored-by: Cursor --- eslint.config.mjs | 154 ++++++++++++++++++++-------------------------- 1 file changed, 67 insertions(+), 87 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 369f875c1..fa5b7476f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,100 +1,80 @@ -import browserConfig from "eslint-config-expensify/browser"; -import reactConfig from "eslint-config-expensify/react"; -import scriptsConfig from "eslint-config-expensify/scripts"; -import tsExpensifyConfig from "eslint-config-expensify/typescript"; -import jestConfig from "eslint-config-expensify/jest"; -import prettierConfig from "eslint-config-prettier"; -import seatbelt from "eslint-seatbelt"; -import rulesdir from "eslint-plugin-rulesdir"; -import { defineConfig, globalIgnores } from "eslint/config"; -import globals from "globals"; -import path from "node:path"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; +import browserConfig from 'eslint-config-expensify/browser'; +import reactConfig from 'eslint-config-expensify/react'; +import scriptsConfig from 'eslint-config-expensify/scripts'; +import tsExpensifyConfig from 'eslint-config-expensify/typescript'; +import jestConfig from 'eslint-config-expensify/jest'; +import prettierConfig from 'eslint-config-prettier'; +import seatbelt from 'eslint-seatbelt'; +import rulesdir from 'eslint-plugin-rulesdir'; +import {defineConfig, globalIgnores} from 'eslint/config'; +import globals from 'globals'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; const dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); -const expensifyConfigDirectory = path.dirname( - require.resolve("eslint-config-expensify"), -); -rulesdir.RULES_DIR = path.resolve( - expensifyConfigDirectory, - "eslint-plugin-expensify", -); +const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); +rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); export default defineConfig([ - seatbelt.configs.enable, - globalIgnores([ - "dist/**", - "node_modules/**", - "**/*.d.ts", - "**/*.config.js", - "**/*.config.cjs", - "tests/types/**/*.ts", - "cpp/**", - "bench-results/**", - ".github/**", - ]), - ...browserConfig, - ...reactConfig, - ...tsExpensifyConfig, - ...jestConfig, - ...scriptsConfig, - { - plugins: { - rulesdir, - }, - settings: { - seatbelt: { - seatbeltFile: path.join(dirname, "eslint.seatbelt.tsv"), - threadsafe: true, - }, - }, - }, - { - files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], - settings: { - "import/resolver": { - typescript: { - alwaysTryTypes: true, - project: "./tsconfig.json", + seatbelt.configs.enable, + globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**']), + ...browserConfig, + ...reactConfig, + ...tsExpensifyConfig, + ...jestConfig, + ...scriptsConfig, + { + plugins: { + rulesdir, + }, + settings: { + seatbelt: { + seatbeltFile: path.join(dirname, 'eslint.seatbelt.tsv'), + threadsafe: true, + }, }, - }, }, - rules: { - "react/prop-types": "off", - "react/require-default-props": "off", - "react/jsx-props-no-spreading": "off", + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + }, + }, + }, + rules: { + 'react/prop-types': 'off', + 'react/require-default-props': 'off', + 'react/jsx-props-no-spreading': 'off', - // Onyx is this package; multiple instances are intentional in tests and mocks. - "rulesdir/no-multiple-onyx-in-file": "off", + // Onyx is this package; multiple instances are intentional in tests and mocks. + 'rulesdir/no-multiple-onyx-in-file': 'off', + }, }, - }, - { - files: ["tests/**/*", "jestSetup.js", "lib/**/__mocks__/**/*"], - rules: { - "import/extensions": "off", - "no-restricted-syntax": [ - "error", - "ForInStatement", - "LabeledStatement", - "WithStatement", - ], + { + files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], + rules: { + 'import/extensions': 'off', + 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], + }, }, - }, - { - files: ["**/*.native.ts", "**/*.native.tsx"], - rules: { - "@typescript-eslint/no-require-imports": "off", + { + files: ['**/*.native.ts', '**/*.native.tsx'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, }, - }, - { - files: ["lib/storage/providers/MemoryOnlyProvider.ts"], - languageOptions: { - globals: { - ...globals.jest, - }, + { + files: ['lib/storage/providers/MemoryOnlyProvider.ts'], + languageOptions: { + globals: { + ...globals.jest, + }, + }, }, - }, - prettierConfig, + prettierConfig, ]); From cc190681724bb07580a82a1a4a6c0b47c2e182c7 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 26 Jun 2026 11:09:56 -0700 Subject: [PATCH 05/20] Remove redundant eslint.config.mjs overrides - Drop no-restricted-syntax from tests block (browserConfig already sets the same 3 restrictions globally) - Drop react/jsx-props-no-spreading: off (not enabled by any upstream config; was a no-op) - Drop rulesdir/no-multiple-onyx-in-file: off (not enabled by any upstream config; was a no-op) - Drop @typescript-eslint/no-require-imports: off for .native.ts (tsExpensifyConfig already turns it off for all *.ts/*.tsx globally) Co-authored-by: Cursor --- eslint.config.mjs | 11 ----------- eslint.seatbelt.tsv | 4 ++-- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index fa5b7476f..0ab076a26 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,23 +49,12 @@ export default defineConfig([ rules: { 'react/prop-types': 'off', 'react/require-default-props': 'off', - 'react/jsx-props-no-spreading': 'off', - - // Onyx is this package; multiple instances are intentional in tests and mocks. - 'rulesdir/no-multiple-onyx-in-file': 'off', }, }, { files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], rules: { 'import/extensions': 'off', - 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], - }, - }, - { - files: ['**/*.native.ts', '**/*.native.tsx'], - rules: { - '@typescript-eslint/no-require-imports': 'off', }, }, { diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv index a1a48b3c9..052c2fec6 100644 --- a/eslint.seatbelt.tsv +++ b/eslint.seatbelt.tsv @@ -151,9 +151,9 @@ "tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "tests/unit/storage/providers/SQLiteProviderTest.ts" "rulesdir/prefer-at" 4 "tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-non-null-assertion" 1 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 4 "tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-assignment" 18 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/unbound-method" 10 "tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 "tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 From bea5370a262206114559ed55924b42863a31f9f5 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 26 Jun 2026 11:32:30 -0700 Subject: [PATCH 06/20] Apply autofix for mechanical lint rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autofixes applied: - rulesdir/prefer-at: arr[N] → arr.at(N) - @typescript-eslint/dot-notation: obj['prop'] → obj.prop - @typescript-eslint/non-nullable-type-assertion-style: as NonNullable → ! - @typescript-eslint/no-unnecessary-type-assertion: remove redundant casts - @typescript-eslint/prefer-nullish-coalescing: || → ?? where applicable - jsdoc/no-types: remove JSDoc type annotations in TS files Follow-up fixes for autofix side-effects: - Remove GenericCollection/OnyxInput unused imports made orphaned by the removal of type assertions that were their only usage - Add no-non-null-assertion seatbelt entries for the four files where non-nullable-type-assertion-style converted as NonNullable → ! - Update seatbelt for newly surfaced unbound-method and unsafe-* counts in OnyxConnectionManager and OnyxCache perf-test files Co-authored-by: Cursor --- eslint.seatbelt.tsv | 60 ++++-------------- lib/Onyx.ts | 34 +++++++--- lib/OnyxConnectionManager.ts | 6 +- lib/OnyxUtils.ts | 10 +-- lib/storage/InstanceSync/index.web.ts | 4 +- .../providers/IDBKeyValProvider/index.ts | 4 +- lib/types.ts | 2 +- tests/perf-test/Onyx.perf-test.ts | 9 +-- tests/perf-test/OnyxCache.perf-test.ts | 31 +++++----- .../OnyxConnectionManager.perf-test.ts | 18 +++--- .../perf-test/OnyxSnapshotCache.perf-test.ts | 2 +- tests/perf-test/OnyxUtils.perf-test.ts | 15 ++--- tests/unit/DevToolsTest.ts | 19 +++--- tests/unit/OnyxConnectionManagerTest.ts | 13 ++-- tests/unit/collectionHydrationTest.ts | 8 +-- tests/unit/mocks/sqliteMock.ts | 4 +- tests/unit/onyxClearWebStorageTest.ts | 9 ++- tests/unit/onyxMultiMergeWebStorageTest.ts | 11 ++-- tests/unit/onyxTest.ts | 62 +++++++++---------- tests/unit/onyxUtilsTest.ts | 51 ++++++++------- .../providers/IDBKeyvalProviderTest.ts | 18 +++--- .../storage/providers/SQLiteProviderTest.ts | 18 +++--- .../unit/storage/providers/createStoreTest.ts | 8 +-- tests/unit/useOnyxTest.ts | 44 ++++++------- tests/unit/utilsTest.ts | 2 +- tests/utils/alternateLists.ts | 4 +- 26 files changed, 215 insertions(+), 251 deletions(-) diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv index 052c2fec6..41c5563cc 100644 --- a/eslint.seatbelt.tsv +++ b/eslint.seatbelt.tsv @@ -11,14 +11,12 @@ "lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/Onyx.ts" "@typescript-eslint/no-loop-func" 1 "lib/Onyx.ts" "@typescript-eslint/no-redundant-type-constituents" 2 -"lib/Onyx.ts" "@typescript-eslint/no-unnecessary-type-assertion" 3 "lib/Onyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "lib/Onyx.ts" "@typescript-eslint/restrict-template-expressions" 1 -"lib/Onyx.ts" "rulesdir/prefer-at" 2 "lib/OnyxCache.ts" "@typescript-eslint/no-redundant-type-constituents" 2 "lib/OnyxCache.ts" "import/no-extraneous-dependencies" 1 -"lib/OnyxConnectionManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 -"lib/OnyxConnectionManager.ts" "@typescript-eslint/non-nullable-type-assertion-style" 3 +"lib/OnyxConnectionManager.ts" "@typescript-eslint/no-non-null-assertion" 3 +"lib/OnyxConnectionManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "lib/OnyxConnectionManager.ts" "import/no-extraneous-dependencies" 1 "lib/OnyxKeys.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/OnyxKeys.ts" "@typescript-eslint/restrict-template-expressions" 1 @@ -30,33 +28,28 @@ "lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-non-null-assertion" 2 "lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "lib/OnyxSnapshotCache.ts" "@typescript-eslint/prefer-nullish-coalescing" 2 +"lib/OnyxUtils.ts" "@typescript-eslint/no-non-null-assertion" 2 "lib/OnyxUtils.ts" "@typescript-eslint/no-redundant-type-constituents" 7 -"lib/OnyxUtils.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-argument" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-assignment" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-call" 2 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-member-access" 6 -"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 35 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 32 "lib/OnyxUtils.ts" "@typescript-eslint/no-use-before-define" 28 -"lib/OnyxUtils.ts" "@typescript-eslint/non-nullable-type-assertion-style" 2 "lib/OnyxUtils.ts" "@typescript-eslint/prefer-nullish-coalescing" 4 "lib/OnyxUtils.ts" "@typescript-eslint/restrict-template-expressions" 3 -"lib/OnyxUtils.ts" "rulesdir/prefer-at" 1 "lib/Str.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/storage/InstanceSync/index.ts" "import/no-extraneous-dependencies" 1 -"lib/storage/InstanceSync/index.web.ts" "jsdoc/no-types" 2 "lib/storage/__mocks__/index.ts" "@typescript-eslint/no-unsafe-argument" 1 "lib/storage/index.ts" "@typescript-eslint/restrict-template-expressions" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-return" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"lib/storage/providers/IDBKeyValProvider/index.ts" "rulesdir/prefer-at" 2 "lib/storage/providers/MemoryOnlyProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unused-vars" 1 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/naming-convention" 3 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-return" 3 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"lib/types.ts" "@typescript-eslint/consistent-type-definitions" 1 "lib/types.ts" "@typescript-eslint/no-empty-object-type" 1 "lib/types.ts" "@typescript-eslint/no-restricted-types" 2 "lib/useLiveRef.ts" "@typescript-eslint/no-deprecated" 1 @@ -66,45 +59,31 @@ "lib/utils.ts" "@typescript-eslint/naming-convention" 2 "lib/utils.ts" "@typescript-eslint/no-redundant-type-constituents" 2 "lib/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 -"tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 "tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/restrict-template-expressions" 1 -"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 -"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-assignment" 1 -"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 1 -"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/non-nullable-type-assertion-style" 1 -"tests/perf-test/OnyxCache.perf-test.ts" "rulesdir/prefer-at" 13 -"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/dot-notation" 2 -"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/non-nullable-type-assertion-style" 1 -"tests/perf-test/OnyxConnectionManager.perf-test.ts" "rulesdir/prefer-at" 6 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-non-null-assertion" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-assignment" 2 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 2 +"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/no-non-null-assertion" 1 +"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/unbound-method" 2 "tests/perf-test/OnyxSnapshotCache.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"tests/perf-test/OnyxSnapshotCache.perf-test.ts" "rulesdir/prefer-at" 1 "tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-misused-promises" 1 -"tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 "tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "tests/perf-test/useOnyx.perf-test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "tests/perf-test/utils.perf-test.ts" "@typescript-eslint/no-unsafe-call" 1 "tests/perf-test/utils.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 3 -"tests/unit/DevToolsTest.ts" "@typescript-eslint/dot-notation" 8 -"tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 "tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 "tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 -"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/dot-notation" 3 -"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-return" 1 +"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/unbound-method" 1 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"tests/unit/OnyxConnectionManagerTest.ts" "rulesdir/prefer-at" 1 "tests/unit/OnyxKeysTest.ts" "@typescript-eslint/naming-convention" 16 "tests/unit/OnyxSnapshotCacheTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-argument" 2 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-assignment" 6 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-member-access" 9 -"tests/unit/collectionHydrationTest.ts" "rulesdir/prefer-at" 4 "tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/naming-convention" 1 "tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 -"tests/unit/mocks/sqliteMock.ts" "rulesdir/prefer-at" 2 "tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-non-null-assertion" 3 "tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-unsafe-assignment" 7 "tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-unsafe-member-access" 6 @@ -113,55 +92,40 @@ "tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/naming-convention" 12 -"tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 4 "tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 "tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/naming-convention" 16 -"tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 "tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 "tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "tests/unit/onyxTest.ts" "@typescript-eslint/naming-convention" 122 "tests/unit/onyxTest.ts" "@typescript-eslint/no-deprecated" 2 "tests/unit/onyxTest.ts" "@typescript-eslint/no-non-null-assertion" 1 -"tests/unit/onyxTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 27 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-assignment" 9 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-call" 2 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-member-access" 26 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-return" 3 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "tests/unit/onyxTest.ts" "import/no-extraneous-dependencies" 2 -"tests/unit/onyxTest.ts" "rulesdir/prefer-at" 4 "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/await-thenable" 6 -"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 21 "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-assignment" 10 "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-call" 2 "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-member-access" 20 "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-return" 1 "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"tests/unit/onyxUtilsTest.ts" "rulesdir/prefer-at" 4 -"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 "tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unsafe-member-access" 2 "tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/unbound-method" 2 -"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "rulesdir/prefer-at" 7 -"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 7 "tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-member-access" 2 "tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-return" 1 -"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 -"tests/unit/storage/providers/SQLiteProviderTest.ts" "rulesdir/prefer-at" 4 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-non-null-assertion" 1 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 4 "tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-assignment" 18 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/unbound-method" 10 "tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 "tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 -"tests/unit/useOnyxTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 21 "tests/unit/useOnyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 "tests/unit/useOnyxTest.ts" "@typescript-eslint/prefer-nullish-coalescing" 1 -"tests/unit/useOnyxTest.ts" "rulesdir/prefer-at" 1 "tests/unit/utilsTest.ts" "@typescript-eslint/no-unsafe-member-access" 3 -"tests/unit/utilsTest.ts" "rulesdir/prefer-at" 2 -"tests/utils/alternateLists.ts" "rulesdir/prefer-at" 2 "tests/utils/collections/reportActions.ts" "@typescript-eslint/restrict-template-expressions" 1 diff --git a/lib/Onyx.ts b/lib/Onyx.ts index a8f8f2d4f..f8dd445c0 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -18,7 +18,6 @@ import type { OnyxSetInput, OnyxUpdate, OnyxValue, - OnyxInput, OnyxMethodMap, SetOptions, } from './types'; @@ -64,7 +63,7 @@ function init({ // Setting isProcessingCollectionUpdate=true prevents triggering collection callbacks for each individual update const isKeyCollectionMember = OnyxKeys.isCollectionMember(key); - OnyxUtils.keyChanged(key, value as OnyxValue, undefined, isKeyCollectionMember); + OnyxUtils.keyChanged(key, value, undefined, isKeyCollectionMember); }); } @@ -239,7 +238,7 @@ function merge(key: TKey, changes: OnyxMergeInput): Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'merge', existingValueType, newValueType)); } return isCompatible; - }) as Array>; + }); // Clean up the write queue, so we don't apply these changes again. delete mergeQueue[key]; @@ -284,7 +283,13 @@ function merge(key: TKey, changes: OnyxMergeInput): * @param collection Object collection keyed by individual collection member keys and values */ function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { - return OnyxUtils.afterInit(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true})); + return OnyxUtils.afterInit(() => + OnyxUtils.mergeCollectionWithPatches({ + collectionKey, + collection, + isProcessingCollectionUpdate: true, + }), + ); } /** @@ -323,7 +328,10 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { // because the notification process needs the old values in cache but at that point they will be already removed from it. const keyValuesToResetAsCollection: Record< OnyxKey, - {oldValues: Record; newValues: Record} + { + oldValues: Record; + newValues: Record; + } > = {}; const allKeys = new Set([...cachedKeys, ...initialKeys]); @@ -352,7 +360,10 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { if (collectionKey) { if (!keyValuesToResetAsCollection[collectionKey]) { - keyValuesToResetAsCollection[collectionKey] = {oldValues: {}, newValues: {}}; + keyValuesToResetAsCollection[collectionKey] = { + oldValues: {}, + newValues: {}, + }; } keyValuesToResetAsCollection[collectionKey].oldValues[key] = oldValue; keyValuesToResetAsCollection[collectionKey].newValues[key] = newValue ?? undefined; @@ -499,7 +510,7 @@ function update(data: Array>): Promise(data: Array>): Promise OnyxUtils.partialSetCollection({collectionKey, collection: batchedCollectionUpdates.set as OnyxSetCollectionInput})); + promises.push(() => + OnyxUtils.partialSetCollection({ + collectionKey, + collection: batchedCollectionUpdates.set, + }), + ); } } for (const [key, operations] of Object.entries(updateQueue)) { - if (operations[0] === null) { + if (operations.at(0) === null) { const batchedChanges = OnyxUtils.mergeChanges(operations).result; promises.push(() => set(key, batchedChanges)); continue; diff --git a/lib/OnyxConnectionManager.ts b/lib/OnyxConnectionManager.ts index 5b0f32d01..1714b97c0 100644 --- a/lib/OnyxConnectionManager.ts +++ b/lib/OnyxConnectionManager.ts @@ -143,9 +143,9 @@ class OnyxConnectionManager { for (const callback of connection.callbacks.values()) { if (connection.waitForCollectionCallback) { - (callback as CollectionConnectCallback)(connection.cachedCallbackValue as Record, connection.cachedCallbackKey as OnyxKey, connection.sourceValue); + (callback as CollectionConnectCallback)(connection.cachedCallbackValue as Record, connection.cachedCallbackKey!, connection.sourceValue); } else { - (callback as DefaultConnectCallback)(connection.cachedCallbackValue, connection.cachedCallbackKey as OnyxKey); + (callback as DefaultConnectCallback)(connection.cachedCallbackValue, connection.cachedCallbackKey!); } } } @@ -204,7 +204,7 @@ class OnyxConnectionManager { // Defer the callback execution to the next tick of the event loop. // This ensures that the current execution flow completes and the result connection object is available when the callback fires. Promise.resolve().then(() => { - (connectOptions as DefaultConnectOptions).callback?.(connectionMetadata.cachedCallbackValue, connectionMetadata.cachedCallbackKey as OnyxKey); + (connectOptions as DefaultConnectOptions).callback?.(connectionMetadata.cachedCallbackValue, connectionMetadata.cachedCallbackKey!); }); } diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index a8c4e510d..9bdfbbb42 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -349,7 +349,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise { for (const [index, value] of values.entries()) { - dataMap.set(pendingKeys[index], value); + dataMap.set(pendingKeys.at(index), value); } return Promise.resolve(); @@ -391,7 +391,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise); - temp[key] = value as OnyxValue; + temp[key] = value; } cache.merge(temp); return dataMap; @@ -759,7 +759,7 @@ function sendDataToConnection(mapping: CallbackToStateMapp return; } - (mapping as DefaultConnectOptions).callback?.(value, matchedKey as TKey); + (mapping as DefaultConnectOptions).callback?.(value, matchedKey!); } /** @@ -1014,7 +1014,7 @@ function mergeInternal | undefined, TChange ex // If we have anything else we can't merge it so we'll // simply return the last value that was queued - return {result: lastChange as TChange, replaceNullPatches: []}; + return {result: lastChange!, replaceNullPatches: []}; } /** @@ -1475,7 +1475,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom // Skip on retry — already notified on attempt 0 (see same-reason comment above). if (!retryAttempt) { for (const [collectionKey, batch] of collectionBatches) { - keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous); + keysChanged(collectionKey, batch.partial, batch.previous); } } diff --git a/lib/storage/InstanceSync/index.web.ts b/lib/storage/InstanceSync/index.web.ts index cb1a3c5bb..4351e8e1d 100644 --- a/lib/storage/InstanceSync/index.web.ts +++ b/lib/storage/InstanceSync/index.web.ts @@ -12,7 +12,7 @@ const SYNC_ONYX = 'SYNC_ONYX'; /** * Raise an event through `localStorage` to let other tabs know a value changed - * @param {String} onyxKey + * @param onyxKey */ function raiseStorageSyncEvent(onyxKey: OnyxKey) { global.localStorage.setItem(SYNC_ONYX, onyxKey); @@ -30,7 +30,7 @@ let storage = NoopProvider; const InstanceSync = { shouldBeUsed: true, /** - * @param {Function} onStorageKeyChanged Storage synchronization mechanism keeping all opened tabs in sync + * @param onStorageKeyChanged Storage synchronization mechanism keeping all opened tabs in sync */ init: (onStorageKeyChanged: OnStorageKeyChanged, store: StorageProvider) => { storage = store; diff --git a/lib/storage/providers/IDBKeyValProvider/index.ts b/lib/storage/providers/IDBKeyValProvider/index.ts index 09fcbb1f2..fcbcee52b 100644 --- a/lib/storage/providers/IDBKeyValProvider/index.ts +++ b/lib/storage/providers/IDBKeyValProvider/index.ts @@ -68,7 +68,7 @@ const provider: StorageProvider = { throw new Error('Store not initialized!'); } - return IDB.getMany(keysParam, provider.store).then((values) => values.map((value, index) => [keysParam[index], value])); + return IDB.getMany(keysParam, provider.store).then((values) => values.map((value, index) => [keysParam.at(index), value])); }, multiMerge(pairs) { if (!provider.store) { @@ -85,7 +85,7 @@ const provider: StorageProvider = { if (value === null) { store.delete(key); } else { - const newValue = utils.fastMerge(values[index] as Record, value as Record, { + const newValue = utils.fastMerge(values.at(index) as Record, value as Record, { shouldRemoveNestedNulls: true, objectRemovalMode: 'replace', }).result; diff --git a/lib/types.ts b/lib/types.ts index 039130df9..f0cb86d22 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -88,7 +88,7 @@ type TypeOptions = Merge< * } * ``` */ -interface CustomTypeOptions {} +type CustomTypeOptions = {}; /** * Represents a string union of all Onyx normal keys. diff --git a/tests/perf-test/Onyx.perf-test.ts b/tests/perf-test/Onyx.perf-test.ts index 196f728b6..83f4f2489 100644 --- a/tests/perf-test/Onyx.perf-test.ts +++ b/tests/perf-test/Onyx.perf-test.ts @@ -2,7 +2,6 @@ import {measureAsyncFunction} from 'reassure'; import type {OnyxKey, OnyxUpdate} from '../../lib'; import Onyx from '../../lib'; import createRandomReportAction, {getRandomReportActions} from '../utils/collections/reportActions'; -import type GenericCollection from '../utils/GenericCollection'; import OnyxUtils from '../../lib/OnyxUtils'; import createDeferredTask from '../../lib/createDeferredTask'; import alternateLists from '../utils/alternateLists'; @@ -72,9 +71,7 @@ describe('Onyx', () => { describe('mergeCollection', () => { test('one call with 10k heavy objects', async () => { - const changedReportActions = Object.fromEntries( - Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; + const changedReportActions = Object.fromEntries(Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const)); await measureAsyncFunction(() => Onyx.mergeCollection(collectionKey, changedReportActions), { beforeEach: async () => { await Onyx.multiSet(mockedReportActionsMap); @@ -86,9 +83,7 @@ describe('Onyx', () => { describe('setCollection', () => { test('one call with 10k heavy objects', async () => { - const changedReportActions = Object.fromEntries( - Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; + const changedReportActions = Object.fromEntries(Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const)); await measureAsyncFunction(() => Onyx.setCollection(collectionKey, changedReportActions), { beforeEach: async () => { await Onyx.multiSet(mockedReportActionsMap); diff --git a/tests/perf-test/OnyxCache.perf-test.ts b/tests/perf-test/OnyxCache.perf-test.ts index a100a4382..4c05a41c5 100644 --- a/tests/perf-test/OnyxCache.perf-test.ts +++ b/tests/perf-test/OnyxCache.perf-test.ts @@ -1,7 +1,6 @@ import {measureAsyncFunction, measureFunction} from 'reassure'; import type OnyxCache from '../../lib/OnyxCache'; import createRandomReportAction, {getRandomReportActions} from '../utils/collections/reportActions'; -import type GenericCollection from '../utils/GenericCollection'; import {TASK} from '../../lib/OnyxCache'; const ONYXKEYS = { @@ -55,7 +54,7 @@ describe('OnyxCache', () => { describe('addKey', () => { test('one call adding one key', async () => { - await measureFunction(() => cache.addKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.addKey(mockedReportActionsKeys.at(0)), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -63,7 +62,7 @@ describe('OnyxCache', () => { describe('addNullishStorageKey', () => { test('one call adding one key', async () => { - await measureFunction(() => cache.addNullishStorageKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.addNullishStorageKey(mockedReportActionsKeys.at(0)), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -71,7 +70,7 @@ describe('OnyxCache', () => { describe('hasNullishStorageKey', () => { test('one call checking one key among 10k ones', async () => { - await measureFunction(() => cache.hasNullishStorageKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.hasNullishStorageKey(mockedReportActionsKeys.at(0)), { beforeEach: async () => { resetCacheBeforeEachMeasure(); cache.setAllKeys(mockedReportActionsKeys); @@ -93,7 +92,7 @@ describe('OnyxCache', () => { describe('hasCacheForKey', () => { test('one call checking one key among 10k ones', async () => { - await measureFunction(() => cache.hasCacheForKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.hasCacheForKey(mockedReportActionsKeys.at(0)), { beforeEach: async () => { resetCacheBeforeEachMeasure(); cache.setAllKeys(mockedReportActionsKeys); @@ -104,7 +103,7 @@ describe('OnyxCache', () => { describe('get', () => { test('one call getting one key among 10k ones', async () => { - await measureFunction(() => cache.get(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.get(mockedReportActionsKeys.at(0)), { beforeEach: async () => { resetCacheBeforeEachMeasure(); for (const [k, v] of Object.entries(mockedReportActionsMap)) cache.set(k, v); @@ -116,7 +115,7 @@ describe('OnyxCache', () => { describe('set', () => { test('one call setting one key', async () => { const value = mockedReportActionsMap[mockedReportActionsKeys[0]]; - await measureFunction(() => cache.set(mockedReportActionsKeys[0], value), { + await measureFunction(() => cache.set(mockedReportActionsKeys.at(0), value), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -124,7 +123,7 @@ describe('OnyxCache', () => { describe('drop', () => { test('one call dropping one key among 10k ones', async () => { - await measureFunction(() => cache.drop(mockedReportActionsKeys[1000]), { + await measureFunction(() => cache.drop(mockedReportActionsKeys.at(1000)), { beforeEach: async () => { resetCacheBeforeEachMeasure(); for (const [k, v] of Object.entries(mockedReportActionsMap)) cache.set(k, v); @@ -135,9 +134,7 @@ describe('OnyxCache', () => { describe('merge', () => { test('one call merging 10k keys', async () => { - const changedReportActions = Object.fromEntries( - Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; + const changedReportActions = Object.fromEntries(Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const)); await measureFunction(() => cache.merge(changedReportActions), { beforeEach: async () => { @@ -150,10 +147,10 @@ describe('OnyxCache', () => { describe('hasPendingTask', () => { test('one call checking one task', async () => { - await measureFunction(() => cache.hasPendingTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`), { + await measureFunction(() => cache.hasPendingTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`), { beforeEach: async () => { resetCacheBeforeEachMeasure(); - cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()); + cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`, Promise.resolve()); }, }); }); @@ -161,10 +158,10 @@ describe('OnyxCache', () => { describe('getTaskPromise', () => { test('one call checking one task', async () => { - await measureAsyncFunction(() => cache.getTaskPromise(`${TASK.GET}:${mockedReportActionsKeys[0]}`) as Promise, { + await measureAsyncFunction(() => cache.getTaskPromise(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`)!, { beforeEach: async () => { resetCacheBeforeEachMeasure(); - cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()); + cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`, Promise.resolve()); }, }); }); @@ -172,14 +169,14 @@ describe('OnyxCache', () => { describe('captureTask', () => { test('one call capturing one task', async () => { - await measureAsyncFunction(() => cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()), { + await measureAsyncFunction(() => cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`, Promise.resolve()), { beforeEach: resetCacheBeforeEachMeasure, }); }); }); describe('hasValueChanged', () => { - const key = mockedReportActionsKeys[0]; + const key = mockedReportActionsKeys.at(0); const reportAction = mockedReportActionsMap[key]; const changedReportAction = createRandomReportAction(Number(reportAction.reportActionID)); diff --git a/tests/perf-test/OnyxConnectionManager.perf-test.ts b/tests/perf-test/OnyxConnectionManager.perf-test.ts index 752fbbb51..4e93dc30a 100644 --- a/tests/perf-test/OnyxConnectionManager.perf-test.ts +++ b/tests/perf-test/OnyxConnectionManager.perf-test.ts @@ -29,8 +29,8 @@ const mockedReportActionsKeys = Object.keys(mockedReportActionsMap); // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -const generateConnectionID = connectionManager['generateConnectionID']; -const fireCallbacks = connectionManager['fireCallbacks']; +const generateConnectionID = connectionManager.generateConnectionID; +const fireCallbacks = connectionManager.fireCallbacks; const resetConectionManagerAfterEachMeasure = () => { connectionManager.disconnectAll(); @@ -52,7 +52,7 @@ describe('OnyxConnectionManager', () => { describe('generateConnectionID', () => { test('one call', async () => { - await measureFunction(() => generateConnectionID({key: mockedReportActionsKeys[0]}), { + await measureFunction(() => generateConnectionID({key: mockedReportActionsKeys.at(0)}), { afterEach: resetConectionManagerAfterEachMeasure, }); }); @@ -65,12 +65,12 @@ describe('OnyxConnectionManager', () => { await measureFunction(() => fireCallbacks(connectionID), { beforeEach: async () => { connectionID = connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: mockedReportActionsKeys.at(0), callback: jest.fn(), }).id; for (let i = 0; i < 9999; i++) { connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: mockedReportActionsKeys.at(0), callback: jest.fn(), }); } @@ -89,7 +89,7 @@ describe('OnyxConnectionManager', () => { async () => { const callback = createDeferredTask(); connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: mockedReportActionsKeys.at(0), callback: () => { callback.resolve?.(); }, @@ -112,12 +112,12 @@ describe('OnyxConnectionManager', () => { await measureFunction( () => { - connectionManager.disconnect(connection as Connection); + connectionManager.disconnect(connection!); }, { beforeEach: async () => { connection = connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: mockedReportActionsKeys.at(0), callback: jest.fn(), }); }, @@ -136,7 +136,7 @@ describe('OnyxConnectionManager', () => { beforeEach: async () => { for (let i = 0; i < 10000; i++) { connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: mockedReportActionsKeys.at(0), callback: jest.fn(), }); } diff --git a/tests/perf-test/OnyxSnapshotCache.perf-test.ts b/tests/perf-test/OnyxSnapshotCache.perf-test.ts index 151ce4668..ae119224a 100644 --- a/tests/perf-test/OnyxSnapshotCache.perf-test.ts +++ b/tests/perf-test/OnyxSnapshotCache.perf-test.ts @@ -183,7 +183,7 @@ describe('OnyxSnapshotCache', () => { // Pre-populate cache with 1000 entries for (let i = 0; i < 1000; i++) { const key = `test_key_${i}`; - const result = mockResults[i]; + const result = mockResults.at(i); cache.setCachedResult(key, `cache_key_${i}`, result); } // Set our target entry diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index ae0df52e2..7436ac26c 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -7,7 +7,6 @@ import StorageMock from '../../lib/storage'; import OnyxCache from '../../lib/OnyxCache'; import OnyxKeys from '../../lib/OnyxKeys'; import OnyxUtils, {clearOnyxUtilsInternals} from '../../lib/OnyxUtils'; -import type GenericCollection from '../utils/GenericCollection'; import type {OnyxUpdate} from '../../lib/Onyx'; import createDeferredTask from '../../lib/createDeferredTask'; import type {OnyxEntry, OnyxInputKeyValueMapping, OnyxKey, RetriableOnyxOperation} from '../../lib/types'; @@ -288,10 +287,10 @@ describe('OnyxUtils', () => { test('one call with 10k heavy objects', async () => { const changedReportActions = Object.fromEntries( Object.entries(mockedReportActionsMap).map(([k, v]) => [k, randBoolean() ? v : createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; + ); await measureAsyncFunction(() => OnyxUtils.partialSetCollection({collectionKey, collection: changedReportActions}), { beforeEach: async () => { - await Onyx.setCollection(collectionKey, mockedReportActionsMap as GenericCollection); + await Onyx.setCollection(collectionKey, mockedReportActionsMap); }, afterEach: clearOnyxAfterEachMeasure, }); @@ -302,9 +301,7 @@ describe('OnyxUtils', () => { test('one call with 10k heavy objects to update 10k subscribers', async () => { const subscriptionMap = new Map(); - const changedReportActions = Object.fromEntries( - Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; + const changedReportActions = Object.fromEntries(Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const)); await measureFunction(() => OnyxUtils.keysChanged(collectionKey, changedReportActions, mockedReportActionsMap), { beforeEach: async () => { @@ -519,9 +516,7 @@ describe('OnyxUtils', () => { describe('initializeWithDefaultKeyStates', () => { test('one call initializing 10k heavy objects', async () => { - const changedReportActions = Object.fromEntries( - Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const), - ) as GenericCollection; + const changedReportActions = Object.fromEntries(Object.entries(mockedReportActionsMap).map(([k, v]) => [k, createRandomReportAction(Number(v.reportActionID))] as const)); await measureAsyncFunction(() => OnyxUtils.initializeWithDefaultKeyStates(), { beforeEach: async () => { @@ -575,7 +570,7 @@ describe('OnyxUtils', () => { describe('isValidNonEmptyCollectionForMerge', () => { test('one call', async () => { - await measureFunction(() => OnyxUtils.isValidNonEmptyCollectionForMerge(mockedReportActionsMap as GenericCollection)); + await measureFunction(() => OnyxUtils.isValidNonEmptyCollectionForMerge(mockedReportActionsMap)); }); }); diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index fe2960e04..6c5e0333c 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -3,7 +3,6 @@ import {getDevToolsInstance} from '../../lib/DevTools'; import type {DevtoolsConnection, RealDevTools as RealDevToolsType} from '../../lib/DevTools'; import RealDevTools from '../../lib/DevTools/RealDevTools'; import utils from '../../lib/utils'; -import type GenericCollection from '../utils/GenericCollection'; const ONYX_KEYS = { NUM_KEY: 'numKey', @@ -25,7 +24,7 @@ const initialKeyStates = { const exampleCollection = { [`${ONYX_KEYS.COLLECTION.NUM_KEY}1`]: 1, [`${ONYX_KEYS.COLLECTION.NUM_KEY}2`]: 2, -} as GenericCollection; +}; const exampleObject = {name: 'Pedro'}; @@ -70,11 +69,11 @@ describe('DevTools', () => { }); it('Sets the default state correctly', () => { const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['defaultState']).toEqual(initialKeyStates); + expect(devToolsInstance.defaultState).toEqual(initialKeyStates); }); it('Sets the internal state correctly', () => { const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(initialKeyStates); + expect(devToolsInstance.state).toEqual(initialKeyStates); }); }); @@ -92,7 +91,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.set(ONYX_KEYS.SOME_KEY, 3); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({ + expect(devToolsInstance.state).toEqual({ ...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3, }); @@ -113,7 +112,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(mergedObject); + expect(devToolsInstance.state).toEqual(mergedObject); }); }); @@ -131,7 +130,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(mergedCollection); + expect(devToolsInstance.state).toEqual(mergedCollection); }); }); @@ -149,7 +148,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.multiSet(exampleCollection); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(mergedCollection); + expect(devToolsInstance.state).toEqual(mergedCollection); }); }); @@ -167,7 +166,7 @@ describe('DevTools', () => { await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); await Onyx.clear([ONYX_KEYS.NUM_KEY]); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({ + expect(devToolsInstance.state).toEqual({ ...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2, }); @@ -177,7 +176,7 @@ describe('DevTools', () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({ + expect(devToolsInstance.state).toEqual({ ...initialKeyStates, ...exampleCollection, }); diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index 36b826b6a..e7ebc8673 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -3,14 +3,13 @@ import Onyx from '../../lib'; import type {Connection} from '../../lib/OnyxConnectionManager'; import connectionManager from '../../lib/OnyxConnectionManager'; import StorageMock from '../../lib/storage'; -import type GenericCollection from '../utils/GenericCollection'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -const connectionsMap = connectionManager['connectionsMap']; -const generateConnectionID = connectionManager['generateConnectionID']; -const getSessionID = () => connectionManager['sessionID']; +const connectionsMap = connectionManager.connectionsMap; +const generateConnectionID = connectionManager.generateConnectionID; +const getSessionID = () => connectionManager.sessionID; const ONYXKEYS = { TEST_KEY: 'test', @@ -130,7 +129,7 @@ describe('OnyxConnectionManager', () => { 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], @@ -279,7 +278,7 @@ describe('OnyxConnectionManager', () => { }, }; - Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, collection as GenericCollection); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, collection); await act(async () => waitForPromisesToResolve()); @@ -446,7 +445,7 @@ describe('OnyxConnectionManager', () => { const setCallsForKey = setSpy.mock.calls.filter((call) => call[0] === ONYXKEYS.TEST_KEY); expect(setCallsForKey.length).toBeGreaterThan(0); - const updatedIDs = setCallsForKey[setCallsForKey.length - 1][1] as number[]; + const updatedIDs = setCallsForKey.at(setCallsForKey.length - 1)[1] as number[]; expect(updatedIDs).not.toContain(subscriptionIdA); expect(updatedIDs).toContain(subscriptionIdB); diff --git a/tests/unit/collectionHydrationTest.ts b/tests/unit/collectionHydrationTest.ts index 64de44412..443cb60b8 100644 --- a/tests/unit/collectionHydrationTest.ts +++ b/tests/unit/collectionHydrationTest.ts @@ -42,7 +42,7 @@ describe('Collection hydration with connect() followed by immediate set()', () = // The subscriber should eventually receive ALL collection members. // The async hydration reads test_2 and test_3 from storage. - const lastCall = mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + const lastCall = mockCallback.mock.calls.at(mockCallback.mock.calls.length - 1)[0]; expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`); expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`); expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`); @@ -102,7 +102,7 @@ describe('Collection hydration with connect() followed by immediate set()', () = await waitForPromisesToResolve(); // The LAST value delivered to the subscriber must be the fresh one, not the stale storage value - const lastValue = mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + const lastValue = mockCallback.mock.calls.at(mockCallback.mock.calls.length - 1)[0]; expect(lastValue).toEqual({title: 'new'}); }); @@ -120,7 +120,7 @@ describe('Collection hydration with connect() followed by immediate set()', () = await waitForPromisesToResolve(); - const lastCall = mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + const lastCall = mockCallback.mock.calls.at(mockCallback.mock.calls.length - 1)[0]; // The final collection snapshot must have the fresh value, not the stale storage one expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]).toEqual({id: 1, title: 'Freshly Updated'}); @@ -159,7 +159,7 @@ describe('Collection hydration with connect() followed by immediate set()', () = // Verify the updated value is present (not stale) by finding the last call for key 1 const key1Calls = mockCallback.mock.calls.filter((call) => call[1] === `${ONYX_KEYS.COLLECTION.TEST_KEY}1`); - const lastKey1Value = key1Calls[key1Calls.length - 1][0]; + const lastKey1Value = key1Calls.at(key1Calls.length - 1)[0]; expect(lastKey1Value).toEqual({id: 1, title: 'Updated Test One'}); }); }); diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index 8b5cd1d45..0ff0c2003 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -49,7 +49,7 @@ function extractNamedParameterOrder(sql: string): string[] | null { function wrapRows(rowsArray: TRow[]): NitroSQLiteQueryResultRows { return { _array: rowsArray, - item: (index: number) => rowsArray[index], + item: (index: number) => rowsArray.at(index), length: rowsArray.length, }; } @@ -134,7 +134,7 @@ function makeConnection(name: string): Pick = {}; for (let index = 0; index < namedOrder.length; index++) { - bindings[namedOrder[index]] = row[index]; + bindings[namedOrder[index]] = row.at(index); } const info = statement.run(bindings); total += info.changes; diff --git a/tests/unit/onyxClearWebStorageTest.ts b/tests/unit/onyxClearWebStorageTest.ts index a9629ab3c..f9440da16 100644 --- a/tests/unit/onyxClearWebStorageTest.ts +++ b/tests/unit/onyxClearWebStorageTest.ts @@ -2,7 +2,6 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import StorageMock from '../../lib/storage'; import Onyx from '../../lib/Onyx'; import type OnyxCache from '../../lib/OnyxCache'; -import type GenericCollection from '../utils/GenericCollection'; import type {Connection} from '../../lib/OnyxConnectionManager'; const ONYX_KEYS = { @@ -136,7 +135,7 @@ describe('Set data while storage is clearing', () => { Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST, { [collectionItemKey1]: {id: 1, name: 'first'}, [collectionItemKey2]: {id: 2, name: 'second'}, - } as GenericCollection) + }) // When clear is called with the collection prefix as a key to preserve .then(() => Onyx.clear([ONYX_KEYS.COLLECTION.TEST])) .then(() => waitForPromisesToResolve()) @@ -165,7 +164,7 @@ describe('Set data while storage is clearing', () => { // Given that Onyx has both a collection item and a regular key set return ( - Promise.all([Onyx.set(ONYX_KEYS.REGULAR_KEY, SET_VALUE), Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST, {[collectionItemKey1]: 'value'} as GenericCollection)]) + Promise.all([Onyx.set(ONYX_KEYS.REGULAR_KEY, SET_VALUE), Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST, {[collectionItemKey1]: 'value'})]) // When clear is called preserving only the collection .then(() => Onyx.clear([ONYX_KEYS.COLLECTION.TEST])) .then(() => waitForPromisesToResolve()) @@ -189,7 +188,7 @@ describe('Set data while storage is clearing', () => { // Given that Onyx has a collection item and a regular key set return ( - Promise.all([Onyx.set(ONYX_KEYS.REGULAR_KEY, SET_VALUE), Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST, {[collectionItemKey1]: 'value'} as GenericCollection)]) + Promise.all([Onyx.set(ONYX_KEYS.REGULAR_KEY, SET_VALUE), Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST, {[collectionItemKey1]: 'value'})]) // When clear is called preserving both the collection and the regular key .then(() => Onyx.clear([ONYX_KEYS.COLLECTION.TEST, ONYX_KEYS.REGULAR_KEY])) .then(() => waitForPromisesToResolve()) @@ -224,7 +223,7 @@ describe('Set data while storage is clearing', () => { [`${ONYX_KEYS.COLLECTION.TEST}2`]: 2, [`${ONYX_KEYS.COLLECTION.TEST}3`]: 3, [`${ONYX_KEYS.COLLECTION.TEST}4`]: 4, - } as GenericCollection), + }), ) // When onyx is cleared diff --git a/tests/unit/onyxMultiMergeWebStorageTest.ts b/tests/unit/onyxMultiMergeWebStorageTest.ts index 84ea6c40d..cdd7b042b 100644 --- a/tests/unit/onyxMultiMergeWebStorageTest.ts +++ b/tests/unit/onyxMultiMergeWebStorageTest.ts @@ -3,7 +3,6 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import Storage from '../../lib/storage'; import type MockedStorage from '../../lib/storage/__mocks__'; import type OnyxInstance from '../../lib/Onyx'; -import type GenericCollection from '../utils/GenericCollection'; const StorageMock = Storage as unknown as typeof MockedStorage; @@ -54,7 +53,7 @@ describe('Onyx.mergeCollection() and WebStorage', () => { test_1: additionalDataOne, test_2: additionalDataOne, test_3: additionalDataOne, - } as GenericCollection); + }); // And call again consecutively with different data const additionalDataTwo = {d: 'd', e: [2]}; @@ -62,7 +61,7 @@ describe('Onyx.mergeCollection() and WebStorage', () => { test_1: additionalDataTwo, test_2: additionalDataTwo, test_3: additionalDataTwo, - } as GenericCollection); + }); return waitForPromisesToResolve().then(() => { const finalObject = { @@ -102,7 +101,7 @@ describe('Onyx.mergeCollection() and WebStorage', () => { test_1: data, test_2: data, test_3: data, - } as GenericCollection); + }); return waitForPromisesToResolve() .then(() => { @@ -124,7 +123,7 @@ describe('Onyx.mergeCollection() and WebStorage', () => { test_1: additionalData, test_2: additionalData, test_3: additionalData, - } as GenericCollection); + }); return waitForPromisesToResolve(); }) @@ -163,7 +162,7 @@ describe('Onyx.mergeCollection() and WebStorage', () => { // 2nd call Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { test_1: {d: 'd', e: 'e'}, - } as GenericCollection); + }); // Last call Onyx.merge('test_1', {f: 'f'}); diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 5ff5d6f96..95c13529d 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -614,7 +614,7 @@ describe('Onyx', () => { waypoints: undefined, }, [workRoute]: undefined, - } as GenericCollection).then(() => { + }).then(() => { expect(result).toEqual({ [routineRoute]: { waypoints: { @@ -684,7 +684,7 @@ describe('Onyx', () => { ID: 345, value: 'three', }, - } as GenericCollection) + }) .then(() => // 2 key values to update and 2 new keys to add. // MergeCollection will perform a mix of multiSet and multiMerge @@ -706,7 +706,7 @@ describe('Onyx', () => { ID: 567, value: 'one', }, - } as GenericCollection), + }), ) .then(() => { // 3 items on the first mergeCollection + 4 items the next mergeCollection @@ -733,7 +733,7 @@ describe('Onyx', () => { callback: (data, key) => (valuesReceived[key] = data), }); - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {test_1: {ID: 123}, notMyTest: {beep: 'boop'}} as GenericCollection).then(() => { + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {test_1: {ID: 123}, notMyTest: {beep: 'boop'}}).then(() => { expect(valuesReceived).toEqual({}); }); }); @@ -763,7 +763,7 @@ describe('Onyx', () => { ID: 234, value: 'two', }, - } as GenericCollection), + }), ) .then(() => { expect(valuesReceived).toEqual({ @@ -802,13 +802,13 @@ describe('Onyx', () => { [key1]: {id: '1', name: 'Item 1'}, [key2]: {id: '2', name: 'Item 2'}, [key3]: {id: '3', name: 'Item 3'}, - } as GenericCollection); + }); // Should only be called once expect(mockCallback).toHaveBeenCalledTimes(1); // Should receive the entire merged collection - const receivedData = mockCallback.mock.calls[0][0]; + const receivedData = mockCallback.mock.calls.at(0)[0]; expect(receivedData).toEqual( expect.objectContaining({ [key1]: {id: '1', name: 'Item 1'}, @@ -829,7 +829,7 @@ describe('Onyx', () => { [key1]: {id: '1', name: 'Item 1'}, [key2]: {id: '2', name: 'Item 2'}, [key3]: {id: '3', name: 'Item 3'}, - } as GenericCollection); + }); connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, @@ -846,13 +846,13 @@ describe('Onyx', () => { [key1]: null, [key2]: {id: '2', name: 'Updated Item 2'}, [key3]: null, - } as GenericCollection); + }); // Should only be called once expect(mockCallback).toHaveBeenCalledTimes(1); // Should receive filtered collection - const receivedData = mockCallback.mock.calls[0][0]; + const receivedData = mockCallback.mock.calls.at(0)[0]; expect(receivedData).toEqual({ [key2]: {id: '2', name: 'Updated Item 2'}, }); @@ -957,7 +957,7 @@ describe('Onyx', () => { ID: 345, value: 'three', }, - } as GenericCollection, + }, }, ]); }) @@ -1221,7 +1221,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'}} as GenericCollection}, + {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}}}, ]).then(() => { expect(collectionCallback).toHaveBeenCalledTimes(2); expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_UPDATE, undefined); @@ -1430,7 +1430,7 @@ describe('Onyx', () => { 3: null, }, }, - } as GenericCollection).then(() => { + }).then(() => { expect(result).toEqual({ [routineRoute]: { waypoints: { @@ -1617,7 +1617,7 @@ describe('Onyx', () => { 0: 'Bed', }, }, - } as GenericCollection, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1716,7 +1716,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'}}, @@ -2210,7 +2210,7 @@ describe('Onyx', () => { [routeA]: {name: 'Route A'}, [routeB]: {name: 'Route B'}, [routeC]: {name: 'Route C'}, - } as GenericCollection) + }) .then(() => Onyx.update([ { @@ -2219,7 +2219,7 @@ describe('Onyx', () => { value: { [routeA]: {name: 'New Route A'}, [routeB]: {name: 'New Route B'}, - } as GenericCollection, + }, }, ]), ) @@ -2256,7 +2256,7 @@ describe('Onyx', () => { return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { [routeA]: {name: 'Route A'}, [routeB]: {name: 'Route B'}, - } as GenericCollection) + }) .then(() => Onyx.update([ { @@ -2269,7 +2269,7 @@ describe('Onyx', () => { key: ONYX_KEYS.COLLECTION.ROUTES, value: { [routeA]: {name: 'Final Route A'}, - } as GenericCollection, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -2323,7 +2323,7 @@ describe('Onyx', () => { value: { [key1]: {id: '1', name: 'Updated Item 1'}, [key2]: {id: '2', name: 'Updated Item 2'}, - } as GenericCollection, + }, }, ]); @@ -2744,14 +2744,14 @@ describe('Onyx', () => { [routeA]: {name: 'Route A'}, [routeB1]: {name: 'Route B1'}, [routeC]: {name: 'Route C'}, - } as GenericCollection); + }); // Replace with new collection data await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { [routeA]: {name: 'New Route A'}, [routeB]: {name: 'New Route B'}, [routeC]: {name: 'New Route C'}, - } as GenericCollection); + }); expect(result).toEqual({ [routeA]: {name: 'New Route A'}, @@ -2779,9 +2779,9 @@ describe('Onyx', () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { [routeA]: {name: 'Route A'}, - } as GenericCollection); + }); - await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, {} as GenericCollection); + await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, {}); expect(result).toEqual({}); }); @@ -2799,7 +2799,7 @@ describe('Onyx', () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { [routeA]: {name: 'Route A'}, - } as GenericCollection); + }); await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { [invalidRoute]: {name: 'Invalid Route'}, @@ -2831,13 +2831,13 @@ describe('Onyx', () => { [routeA]: null, [routeB]: {name: 'Route B'}, [routeC]: null, - } as GenericCollection); + }); // Should only be called once expect(mockCallback).toHaveBeenCalledTimes(1); // Should receive filtered collection (only non-null values) - const receivedData = mockCallback.mock.calls[0][0]; + const receivedData = mockCallback.mock.calls.at(0)[0]; expect(receivedData).toEqual({ [routeB]: {name: 'Route B'}, }); @@ -2910,7 +2910,7 @@ describe('Onyx', () => { [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, - } as GenericCollection); + }); expect(testKeyValue).toEqual({ [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, @@ -2932,7 +2932,7 @@ describe('Onyx', () => { [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, - } as GenericCollection); + }); expect(testKeyValue).toEqual({ [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, @@ -2954,7 +2954,7 @@ describe('Onyx', () => { [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, - } as GenericCollection); + }); expect(testKeyValue).toEqual({ [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, @@ -3304,7 +3304,7 @@ describe('RAM-only keys should not read from storage', () => { await act(async () => waitForPromisesToResolve()); // Get the callback that was passed to keepInstancesSync - const syncCallback = (StorageMock.keepInstancesSync as jest.Mock).mock.calls[0]?.[0]; + const syncCallback = (StorageMock.keepInstancesSync as jest.Mock).mock.calls.at(0)?.[0]; expect(syncCallback).toBeDefined(); let receivedValue: unknown; diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index a97d685bb..338409811 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -4,7 +4,6 @@ import OnyxUtils from '../../lib/OnyxUtils'; import type {GenericDeepRecord} from '../types'; import utils from '../../lib/utils'; import type {Collection, OnyxCollection} from '../../lib/types'; -import type GenericCollection from '../utils/GenericCollection'; import OnyxCache from '../../lib/OnyxCache'; import * as Logger from '../../lib/Logger'; import StorageMock from '../../lib/storage'; @@ -124,7 +123,7 @@ describe('OnyxUtils', () => { [routeA]: {name: 'Route A'}, [routeB1]: {name: 'Route B1'}, [routeC]: {name: 'Route C'}, - } as GenericCollection); + }); // Replace with new collection data await OnyxUtils.partialSetCollection({ @@ -133,7 +132,7 @@ describe('OnyxUtils', () => { [routeA]: {name: 'New Route A'}, [routeB]: {name: 'New Route B'}, [routeC]: {name: 'New Route C'}, - } as GenericCollection, + }, }); expect(result).toEqual({ @@ -157,11 +156,11 @@ describe('OnyxUtils', () => { await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { [routeA]: {name: 'Route A'}, - } as GenericCollection); + }); await OnyxUtils.partialSetCollection({ collectionKey: ONYXKEYS.COLLECTION.ROUTES, - collection: {} as GenericCollection, + collection: {}, }); expect(result).toEqual({ @@ -183,13 +182,13 @@ describe('OnyxUtils', () => { await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { [routeA]: {name: 'Route A'}, - } as GenericCollection); + }); await OnyxUtils.partialSetCollection({ collectionKey: ONYXKEYS.COLLECTION.ROUTES, collection: { [invalidRoute]: {name: 'Invalid Route'}, - } as GenericCollection, + }, }); expect(result).toEqual({ @@ -221,7 +220,7 @@ describe('OnyxUtils', () => { // Should be called only ONCE with the batched collection (not 3 times) expect(collectionCallback).toHaveBeenCalledTimes(1); - const [collection] = collectionCallback.mock.calls[0]; + const [collection] = collectionCallback.mock.calls.at(0); expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]).toEqual({id: 1}); expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]).toEqual({id: 2}); expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}3`]).toEqual({id: 3}); @@ -555,7 +554,7 @@ describe('OnyxUtils', () => { expect(collectionCallback).toHaveBeenCalledTimes(1); // Collection subscriber receives the full cached collection, subscriber.key, and partial - const [receivedCollection, receivedKey, receivedPartial] = collectionCallback.mock.calls[0]; + const [receivedCollection, receivedKey, receivedPartial] = collectionCallback.mock.calls.at(0); expect(receivedKey).toBe(ONYXKEYS.COLLECTION.TEST_KEY); expect(receivedCollection[entryKey]).toEqual(entryData); expect(receivedPartial).toEqual({[entryKey]: entryData}); @@ -785,7 +784,7 @@ describe('OnyxUtils', () => { // + name + message) is logged exactly once even though the operation retries 6 times. const unclassifiedCalls = logAlertSpy.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith('Unclassified storage error.')); expect(unclassifiedCalls).toHaveLength(1); - expect(unclassifiedCalls[0][0]).toBe( + expect(unclassifiedCalls.at(0)[0]).toBe( `Unclassified storage error. provider: MemoryOnlyProvider. name: ${genericError.name}. message: ${genericError.message}. onyxMethod: setWithRetry.`, ); }); @@ -980,7 +979,7 @@ describe('OnyxUtils', () => { await Onyx.mergeCollection(collectionKey, { [existingMemberKey]: {value: 'merged'}, [newMemberKey]: {value: 'new'}, - } as GenericCollection); + }); // Cache must reflect the merge regardless of the multiMerge rejection. This is the // cache-first / storage-second invariant that mergeCollectionWithPatches must honor. @@ -1022,7 +1021,7 @@ describe('OnyxUtils', () => { await Onyx.mergeCollection(collectionKey, { [newMemberKey1]: {value: 'first'}, [newMemberKey2]: {value: 'second'}, - } as GenericCollection); + }); // Cache must reflect the merge regardless of the multiSet rejection. This is the // cache-first / storage-second invariant that mergeCollectionWithPatches must honor. @@ -1075,7 +1074,7 @@ describe('OnyxUtils', () => { await Onyx.mergeCollection(collectionKey, { [existingMemberKey]: {value: 'merged'}, [newMemberKey]: {value: 'new'}, - } as GenericCollection); + }); // Before this fix, every retry attempt re-fired keysChanged() — and // waitForCollectionCallback subscribers fire on every keysChanged() call by contract. @@ -1127,7 +1126,7 @@ describe('OnyxUtils', () => { await Onyx.setCollection(collectionKey, { [memberKey1]: {value: 'first'}, [memberKey2]: {value: 'second'}, - } as GenericCollection); + }); expect(collectionCallback).toHaveBeenCalledTimes(1); }); @@ -1153,7 +1152,7 @@ describe('OnyxUtils', () => { collection: { [memberKey1]: {value: 'first'}, [memberKey2]: {value: 'second'}, - } as GenericCollection, + }, }); expect(collectionCallback).toHaveBeenCalledTimes(1); @@ -1203,7 +1202,7 @@ describe('OnyxUtils', () => { await Onyx.mergeCollection(collectionKey, { [existingKey1]: {value: 'merged-1'}, [existingKey2]: {value: 'merged-2'}, - } as GenericCollection); + }); // With every existingKey warm, the diff swaps Promise.all(get) for Promise.resolve(), // so no storage reads should happen during the pre-warm. @@ -1236,12 +1235,12 @@ describe('OnyxUtils', () => { [coldKey1]: {value: 'merged-1'}, [coldKey2]: {value: 'merged-2'}, [warmKey]: {value: 'merged-3'}, - } as GenericCollection); + }); // OnyxUtils.multiGet filters to cache-missing keys before issuing Storage.multiGet, so we // expect exactly one batched read containing only the cold keys (the warm key is skipped). expect(multiGetSpy).toHaveBeenCalledTimes(1); - const requestedKeys = multiGetSpy.mock.calls[0][0] as string[]; + const requestedKeys = multiGetSpy.mock.calls.at(0)[0]; expect(requestedKeys.sort()).toEqual([coldKey1, coldKey2].sort()); // No individual Storage.getItem calls during pre-warm. Old code path would have fired one @@ -1260,7 +1259,7 @@ describe('OnyxUtils', () => { await Onyx.mergeCollection(collectionKey, { [coldKey]: {c: 3}, - } as GenericCollection); + }); // If the pre-warm did NOT populate the cache from storage, fastMerge would treat the // previous value as undefined and the result would drop {a:1, b:2}. With the pre-warm @@ -1290,7 +1289,7 @@ describe('OnyxUtils', () => { key: collectionKey, value: { [existingKey]: {value: 'merged'}, - } as GenericCollection, + }, }, ]); @@ -1299,7 +1298,7 @@ describe('OnyxUtils', () => { // one merged value — not undefined first and the merged value on a later microtask. const broadcasts = collectionCallback.mock.calls.map((c) => c[0]); expect(broadcasts).toHaveLength(1); - expect(broadcasts[0]?.[existingKey]).toEqual({value: 'merged'}); + expect(broadcasts.at(0)?.[existingKey]).toEqual({value: 'merged'}); }); it('equivalence: warm-path and cold-path produce the same final cache state for the same merge', async () => { @@ -1311,7 +1310,7 @@ describe('OnyxUtils', () => { await Onyx.set(memberKey, {value: 'before', extra: 'kept'}); await Onyx.mergeCollection(collectionKey, { [memberKey]: delta, - } as GenericCollection); + }); const warmResult = OnyxCache.getCollectionData(collectionKey)?.[memberKey]; // Reset and replay with a cold cache before the merge. @@ -1320,7 +1319,7 @@ describe('OnyxUtils', () => { evictFromCache(memberKey); await Onyx.mergeCollection(collectionKey, { [memberKey]: delta, - } as GenericCollection); + }); const coldResult = OnyxCache.getCollectionData(collectionKey)?.[memberKey]; expect(warmResult).toEqual(coldResult); @@ -1364,7 +1363,7 @@ describe('OnyxUtils', () => { const result = await Onyx.mergeCollection(collectionKey, { [coldMemberKey]: {merged: true}, [newMemberKey]: {value: 'new'}, - } as GenericCollection).catch((e: unknown) => { + }).catch((e: unknown) => { outerRejected = e; }); expect(outerRejected).toBeNull(); @@ -1696,7 +1695,7 @@ describe('OnyxUtils', () => { // Storage.multiMerge rejects once with disk-full, then succeeds on retry. LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiMerge); - await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}} as GenericCollection); + await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}}); // The old code evicted the in-flight key and re-ran the merge against an empty cache, // collapsing {id: 1, value: 'orig'} + {value: 'merged'} to just {value: 'merged'}. Now @@ -1724,7 +1723,7 @@ describe('OnyxUtils', () => { // finds no acceptable key and reports the quota instead of dropping (and truncating) it. LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValue(diskFullError); - await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}} as GenericCollection); + await LocalOnyx.mergeCollection(collectionKey, {[memberKey]: {value: 'merged'}}); expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'}); expect(memberCalls.at(-1)).toEqual({id: 1, value: 'merged'}); diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index 26b0e9081..203055d34 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -54,9 +54,9 @@ describe('IDBKeyValProvider', () => { await IDB.setMany(testEntries, IDBKeyValProvider.store); expect(await IDBKeyValProvider.multiGet([`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_2])).toEqual([ - testEntries[3], - testEntries[0], - testEntries[1], + testEntries.at(3), + testEntries.at(0), + testEntries.at(1), ]); }); }); @@ -132,11 +132,11 @@ describe('IDBKeyValProvider', () => { ]; const expectedEntries = structuredClone(changedEntries); - const expectedTestKey3Value = structuredClone(testEntries[2])[1] as GenericDeepRecord; + const expectedTestKey3Value = structuredClone(testEntries.at(2))[1] as GenericDeepRecord; expectedTestKey3Value.key = 'value_changed'; expectedTestKey3Value.property.nestedProperty = {nestedKey2: 'nestedValue2_changed'}; expectedTestKey3Value.property.newKey = 'newValue'; - expectedEntries[2][1] = expectedTestKey3Value; + expectedEntries.at(2)[1] = expectedTestKey3Value; await IDBKeyValProvider.multiMerge(changedEntries); expect( @@ -158,9 +158,9 @@ describe('IDBKeyValProvider', () => { ]; const expectedEntries = structuredClone(changedEntries); - const expectedTestKey3Value = structuredClone(testEntries[2])[1] as GenericDeepRecord; + const expectedTestKey3Value = structuredClone(testEntries.at(2))[1] as GenericDeepRecord; expectedTestKey3Value.key = 'value_changed'; - expectedEntries[2][1] = expectedTestKey3Value; + expectedEntries.at(2)[1] = expectedTestKey3Value; await IDBKeyValProvider.multiMerge(changedEntries); // ONYXKEYS.TEST_KEY_3 and `${ONYXKEYS.COLLECTION.TEST_KEY}id2`. @@ -267,8 +267,8 @@ describe('IDBKeyValProvider', () => { newKey: 'newValue', }, }); - await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, false); - await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {newKey: 'newValue'}]); + await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, false); + await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {newKey: 'newValue'}]); expect(await IDB.get(ONYXKEYS.TEST_KEY, IDBKeyValProvider.store)).toEqual('value_changed'); expect(await IDB.get(ONYXKEYS.TEST_KEY_2, IDBKeyValProvider.store)).toEqual(1001); diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index bc0cac00a..13079d73d 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -69,9 +69,9 @@ describe('SQLiteProvider', () => { // (rows come back in primary-key order). IDB's getMany() does. So this // test mirrors the IDB one but asserts membership rather than order. it('should return the tuples for the keys supplied in a batch', async () => { - await SQLiteProvider.multiSet(testEntries as Array<[string, unknown]>); + await SQLiteProvider.multiSet(testEntries); const out = await SQLiteProvider.multiGet([`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_2]); - expect(out).toEqual(expect.arrayContaining([testEntries[3], testEntries[0], testEntries[1]])); + expect(out).toEqual(expect.arrayContaining([testEntries.at(3), testEntries.at(0), testEntries.at(1)])); expect(out).toHaveLength(3); }); }); @@ -127,7 +127,7 @@ describe('SQLiteProvider', () => { // before serializing, otherwise JSON.stringify(undefined) === undefined // and the row would store a literal "undefined" string. it('treats undefined as null', async () => { - await SQLiteProvider.multiSet([[ONYXKEYS.TEST_KEY, undefined as unknown as null]]); + await SQLiteProvider.multiSet([[ONYXKEYS.TEST_KEY, undefined]]); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); }); }); @@ -157,7 +157,7 @@ describe('SQLiteProvider', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {newKey: 'newValue'}]], ]; - const expectedTestKey3Value = structuredClone(testEntries[2])[1] as GenericDeepRecord; + const expectedTestKey3Value = structuredClone(testEntries.at(2))[1] as GenericDeepRecord; expectedTestKey3Value.key = 'value_changed'; expectedTestKey3Value.property.nestedProperty = {nestedKey2: 'nestedValue2_changed'}; expectedTestKey3Value.property.newKey = 'newValue'; @@ -256,8 +256,8 @@ describe('SQLiteProvider', () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_2, 1000); await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, {key: 'value', property: {propertyKey: 'propertyValue'}}); - await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, true); - await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {key: 'value'}, 1, true]); + await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, true); + await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {key: 'value'}, 1, true]); await SQLiteProvider.mergeItem(ONYXKEYS.TEST_KEY, 'value_changed'); await SQLiteProvider.mergeItem(ONYXKEYS.TEST_KEY_2, 1001); @@ -272,8 +272,8 @@ describe('SQLiteProvider', () => { }, [[['property'], {newKey: 'newValue'}]], ); - await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, false); - await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {newKey: 'newValue'}]); + await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, false); + await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {newKey: 'newValue'}]); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value_changed'); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual(1001); @@ -317,7 +317,7 @@ describe('SQLiteProvider', () => { describe('SQL-injection safety', () => { it('should treat a key containing SQL fragments as a literal record_key', async () => { const nastyKey = "'; DROP TABLE keyvaluepairs; --"; - await SQLiteProvider.setItem(nastyKey as string, 'survived'); + await SQLiteProvider.setItem(nastyKey, 'survived'); expect(await SQLiteProvider.getItem(nastyKey)).toEqual('survived'); expect(await SQLiteProvider.getAllKeys()).toEqual([nastyKey]); }); diff --git a/tests/unit/storage/providers/createStoreTest.ts b/tests/unit/storage/providers/createStoreTest.ts index 16f206747..c65f0f768 100644 --- a/tests/unit/storage/providers/createStoreTest.ts +++ b/tests/unit/storage/providers/createStoreTest.ts @@ -324,7 +324,7 @@ describe('createStore', () => { value: backingStoreError(), configurable: true, }); - req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest}); + req.onerror?.(new Event('error')); }); return req; } @@ -351,7 +351,7 @@ describe('createStore', () => { value: backingStoreError(), configurable: true, }); - req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest}); + req.onerror?.(new Event('error')); }); return req; }); @@ -490,7 +490,7 @@ describe('createStore', () => { value: connectionLostError(), configurable: true, }); - req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest}); + req.onerror?.(new Event('error')); }); return req; }); @@ -738,7 +738,7 @@ describe('createStore', () => { value: new DOMException('probe open failed', 'UnknownError'), configurable: true, }); - req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest}); + req.onerror?.(new Event('error')); }; return req; }); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index d5b9d0017..c3407751a 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -42,7 +42,7 @@ describe('useOnyx', () => { }); it('should not throw any errors when changing from a collection member key to another one', async () => { - const {rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); try { await act(async () => { @@ -54,7 +54,7 @@ describe('useOnyx', () => { }); it('should transition through loading when switching between collection member keys that both resolve to undefined', async () => { - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); // Wait for initial key to fully load await act(async () => waitForPromisesToResolve()); @@ -77,7 +77,7 @@ describe('useOnyx', () => { it('should return cached value immediately with loaded status when switching to a key that has data', async () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'test_value'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); await act(async () => waitForPromisesToResolve()); @@ -100,7 +100,7 @@ describe('useOnyx', () => { it('should clear previous data and transition through loading when switching from a key with data to one without', async () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, 'initial_value'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); await act(async () => waitForPromisesToResolve()); @@ -123,7 +123,7 @@ describe('useOnyx', () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, 'value_one'); Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'value_two'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); await act(async () => waitForPromisesToResolve()); @@ -145,7 +145,7 @@ describe('useOnyx', () => { const selector = ((entry: OnyxEntry<{id: string; name: string}>) => entry?.name) as UseOnyxSelector; - const {result, rerender} = renderHook((key: string) => useOnyx(key, {selector}), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key, {selector}), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); await act(async () => waitForPromisesToResolve()); @@ -166,7 +166,7 @@ describe('useOnyx', () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'value_two'); Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}3`, 'value_three'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); await act(async () => waitForPromisesToResolve()); @@ -186,7 +186,7 @@ describe('useOnyx', () => { Onyx.set(ONYXKEYS.TEST_KEY, 'value_one'); Onyx.set(ONYXKEYS.TEST_KEY_2, 'value_two'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: ONYXKEYS.TEST_KEY as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: ONYXKEYS.TEST_KEY}); await act(async () => waitForPromisesToResolve()); @@ -209,7 +209,7 @@ describe('useOnyx', () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, 'value_one'); Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'value_two'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1` as string}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); await act(async () => waitForPromisesToResolve()); @@ -427,7 +427,7 @@ describe('useOnyx', () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: entry1, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: entry2, - } as GenericCollection); + }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY)); await act(async () => waitForPromisesToResolve()); @@ -446,7 +446,7 @@ describe('useOnyx', () => { it('should keep the same collection reference when no members change', async () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - } as GenericCollection); + }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY)); await act(async () => waitForPromisesToResolve()); @@ -483,7 +483,7 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, - } as GenericCollection); + }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY, { @@ -556,7 +556,7 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, - } as GenericCollection); + }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY, { @@ -721,7 +721,7 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: {id: '3', value: 'item3'}, }; - await act(async () => Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, testCollection as GenericCollection)); + await act(async () => Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, testCollection)); let filterIds = ['1']; let selectorCallCount = 0; @@ -787,7 +787,7 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}item4`]: {id: 'item4', category: 'B', priority: 4}, }; - await act(async () => Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, testData as GenericCollection)); + await act(async () => Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, testData)); let categoryFilter = 'A'; let sortAscending = true; @@ -975,7 +975,7 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, - } as GenericCollection); + }); let externalValue = 'ex1'; @@ -1023,7 +1023,7 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, - } as GenericCollection); + }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY)); @@ -1040,7 +1040,7 @@ describe('useOnyx', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name_changed'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name_changed'}, [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name_changed'}, - } as GenericCollection), + }), ); expect(result.current[0]).toEqual({ @@ -1114,7 +1114,7 @@ describe('useOnyx', () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION, { [`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry1`]: {id: '1'}, [`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry2`]: {id: '2'}, - } as GenericCollection), + }), ); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION)); @@ -1261,7 +1261,7 @@ describe('useOnyx', () => { renders.push({value: r[0], status: r[1].status}); return r; }, - {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}A` as string}, + {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}A`}, ); await act(async () => waitForPromisesToResolve()); @@ -1285,7 +1285,7 @@ describe('useOnyx', () => { // Verify the reset took effect: a 'loading' frame must appear after the key change. const postSwitchStatuses = renders.slice(rendersAfterMount).map((r) => r.status); expect(postSwitchStatuses).toContain('loading'); - expect(postSwitchStatuses[postSwitchStatuses.length - 1]).toBe('loaded'); + expect(postSwitchStatuses.at(postSwitchStatuses.length - 1)).toBe('loaded'); }); it('should transition through loading and render exactly 3 times when switching between two cached keys', async () => { @@ -1299,7 +1299,7 @@ describe('useOnyx', () => { renders.push({value: r[0], status: r[1].status}); return r; }, - {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}A` as string}, + {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}A`}, ); await act(async () => waitForPromisesToResolve()); diff --git a/tests/unit/utilsTest.ts b/tests/unit/utilsTest.ts index 48e51d598..c7f11a67d 100644 --- a/tests/unit/utilsTest.ts +++ b/tests/unit/utilsTest.ts @@ -137,7 +137,7 @@ describe('utils', () => { }); it('should add the "ONYX_INTERNALS__REPLACE_OBJECT_MARK" flag to the merged object when the change is set to null and "objectRemovalMode" is set to "mark"', () => { - const result = utils.fastMerge(testMergeChanges[1], testMergeChanges[0], { + const result = utils.fastMerge(testMergeChanges.at(1), testMergeChanges.at(0), { shouldRemoveNestedNulls: true, objectRemovalMode: 'mark', }); diff --git a/tests/utils/alternateLists.ts b/tests/utils/alternateLists.ts index 061c418e8..64fdaa8a0 100644 --- a/tests/utils/alternateLists.ts +++ b/tests/utils/alternateLists.ts @@ -1,4 +1,6 @@ const alternateLists = (list1: unknown[], list2: unknown[]) => - list1.length > list2.length ? list1.flatMap((item, i) => [item, list2[i]]).filter((x) => x !== undefined) : list2.flatMap((item, i) => [list1[i], item]).filter((x) => x !== undefined); + list1.length > list2.length + ? list1.flatMap((item, i) => [item, list2.at(i)]).filter((x) => x !== undefined) + : list2.flatMap((item, i) => [list1.at(i), item]).filter((x) => x !== undefined); export default alternateLists; From 7dac669bb8ad7feccda3e36b1be857748a35b2eb Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 26 Jun 2026 12:15:04 -0700 Subject: [PATCH 07/20] Add ESLint overrides for seatbelt cleanup Configure Node globals for CJS config files, disable no-use-before-define in OnyxUtils, and relax test naming rules for collection key object properties. Co-authored-by: Cursor --- eslint.config.mjs | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 0ab076a26..fc8c0f8d5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -51,12 +51,89 @@ export default defineConfig([ 'react/require-default-props': 'off', }, }, + { + files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], + languageOptions: { + globals: globals.node, + }, + }, + { + files: ['lib/OnyxUtils.ts'], + rules: { + '@typescript-eslint/no-use-before-define': 'off', + }, + }, + { + files: ['tests/**/*'], + rules: { + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: ['variable', 'property'], + format: null, + filter: { + regex: '^__esModule$', + match: true, + }, + }, + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + filter: { + regex: '^private_[a-z][a-zA-Z0-9]*$', + match: false, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '_', + match: true, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '^[0-9]+$', + match: true, + }, + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + ], + }, + }, { files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], rules: { 'import/extensions': 'off', }, }, + { + files: ['lib/storage/providers/SQLiteProvider.ts'], + rules: { + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'typeProperty', + format: ['camelCase', 'snake_case'], + }, + ], + }, + }, { files: ['lib/storage/providers/MemoryOnlyProvider.ts'], languageOptions: { From 26ae96687e1ad7e1cd85e3498536a5a1e1c68c33 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 26 Jun 2026 12:15:08 -0700 Subject: [PATCH 08/20] Fix lodash import paths in lib storage code Use lodash.bindall and inline noop instead of umbrella lodash subpath imports that fail extraneous-deps. Co-authored-by: Cursor --- lib/OnyxCache.ts | 6 +++--- lib/OnyxConnectionManager.ts | 16 ++++++++++++---- lib/storage/InstanceSync/index.ts | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 23fae2130..d481e4df6 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -1,5 +1,5 @@ import {deepEqual} from 'fast-equals'; -import bindAll from 'lodash/bindAll'; +import bindAll from 'lodash.bindall'; import type {ValueOf} from 'type-fest'; import utils from './utils'; import type {CollectionKeyBase, KeyValueMapping, NonUndefined, OnyxCollection, OnyxKey, OnyxValue} from './types'; @@ -42,7 +42,7 @@ class OnyxCache { * Captured pending tasks for already running storage methods * Using a map yields better performance on operations such a delete */ - private pendingPromises: Map | OnyxKey[]>>; + private pendingPromises: Map>; /** List of keys that are safe to remove when we reach max storage */ private evictionAllowList: OnyxKey[] = []; @@ -272,7 +272,7 @@ class OnyxCache { * provided from this function * @param taskName - unique name given for the task */ - getTaskPromise(taskName: CacheTask): Promise | OnyxKey[]> | undefined { + getTaskPromise(taskName: CacheTask): Promise | undefined { return this.pendingPromises.get(taskName); } diff --git a/lib/OnyxConnectionManager.ts b/lib/OnyxConnectionManager.ts index 1714b97c0..4fb896985 100644 --- a/lib/OnyxConnectionManager.ts +++ b/lib/OnyxConnectionManager.ts @@ -1,4 +1,4 @@ -import bindAll from 'lodash/bindAll'; +import bindAll from 'lodash.bindall'; import * as Logger from './Logger'; import type {ConnectOptions} from './Onyx'; import OnyxUtils from './OnyxUtils'; @@ -142,10 +142,14 @@ class OnyxConnectionManager { } for (const callback of connection.callbacks.values()) { + const callbackKey = connection.cachedCallbackKey; + if (callbackKey === undefined) { + continue; + } if (connection.waitForCollectionCallback) { - (callback as CollectionConnectCallback)(connection.cachedCallbackValue as Record, connection.cachedCallbackKey!, connection.sourceValue); + (callback as CollectionConnectCallback)(connection.cachedCallbackValue as Record, callbackKey, connection.sourceValue); } else { - (callback as DefaultConnectCallback)(connection.cachedCallbackValue, connection.cachedCallbackKey!); + (callback as DefaultConnectCallback)(connection.cachedCallbackValue, callbackKey); } } } @@ -204,7 +208,11 @@ class OnyxConnectionManager { // Defer the callback execution to the next tick of the event loop. // This ensures that the current execution flow completes and the result connection object is available when the callback fires. Promise.resolve().then(() => { - (connectOptions as DefaultConnectOptions).callback?.(connectionMetadata.cachedCallbackValue, connectionMetadata.cachedCallbackKey!); + const callbackKey = connectionMetadata.cachedCallbackKey; + if (callbackKey === undefined) { + return; + } + (connectOptions as DefaultConnectOptions).callback?.(connectionMetadata.cachedCallbackValue, callbackKey); }); } diff --git a/lib/storage/InstanceSync/index.ts b/lib/storage/InstanceSync/index.ts index af7424f6c..892d21d50 100644 --- a/lib/storage/InstanceSync/index.ts +++ b/lib/storage/InstanceSync/index.ts @@ -1,4 +1,4 @@ -import NOOP from 'lodash/noop'; +const NOOP = () => {}; /** * This is used to keep multiple browser tabs in sync, therefore only needed on web From 75490482f136f56bb533e054afdde2b789d2e764 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 26 Jun 2026 12:15:11 -0700 Subject: [PATCH 09/20] Fix NoopProvider unused param and Onyx loop-func Type getItem with void key usage and hoist handlers outside the connect loop to satisfy no-loop-func. Co-authored-by: Cursor --- lib/Onyx.ts | 68 +++++++++++++-------------- lib/storage/providers/NoopProvider.ts | 12 +++-- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index f8dd445c0..100cabbb8 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -260,7 +260,7 @@ function merge(key: TKey, changes: OnyxMergeInput): OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MERGE, key, changes, mergedValue); }); } catch (error) { - Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`); + Logger.logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error instanceof Error ? error.message : String(error)}`); return Promise.resolve(); } }); @@ -329,8 +329,8 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { const keyValuesToResetAsCollection: Record< OnyxKey, { - oldValues: Record; - newValues: Record; + oldValues: Record; + newValues: Record; } > = {}; @@ -447,6 +447,37 @@ function update(data: Array>): Promise = Promise.resolve(); const onyxMethods = Object.values(OnyxUtils.METHOD); + const handlers: Record) => void> = { + [OnyxUtils.METHOD.SET]: enqueueSetOperation, + [OnyxUtils.METHOD.MERGE]: enqueueMergeOperation, + [OnyxUtils.METHOD.MERGE_COLLECTION]: (k, v) => { + const collection = v as OnyxMergeCollectionInput; + if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) { + Logger.logInfo('Invalid or empty value provided in Onyx mergeCollection. Skipping this operation.'); + return; + } + + // Confirm all the collection keys belong to the same parent + const collectionKeys = Object.keys(collection); + if (OnyxUtils.doAllCollectionItemsBelongToSameParent(k, collectionKeys)) { + const mergedCollection: OnyxInputKeyValueMapping = collection; + for (const collectionKey of collectionKeys) enqueueMergeOperation(collectionKey, mergedCollection[collectionKey]); + } + }, + [OnyxUtils.METHOD.SET_COLLECTION]: (k, v) => promises.push(() => setCollection(k as TKey, v as OnyxSetCollectionInput)), + [OnyxUtils.METHOD.MULTI_SET]: (k, v) => { + if (typeof v !== 'object' || Array.isArray(v) || typeof v === 'function') { + Logger.logInfo(`Invalid value provided in Onyx multiSet. Value must be of type object. Skipping this operation.`); + return; + } + + for (const [entryKey, entryValue] of Object.entries(v as Partial)) enqueueSetOperation(entryKey, entryValue); + }, + [OnyxUtils.METHOD.CLEAR]: () => { + clearPromise = clear(); + }, + }; + for (const {onyxMethod, key, value} of data) { if (!onyxMethods.includes(onyxMethod)) { Logger.logInfo(`Invalid onyxMethod ${onyxMethod} in Onyx update. Skipping this operation.`); @@ -457,37 +488,6 @@ function update(data: Array>): Promise void> = { - [OnyxUtils.METHOD.SET]: enqueueSetOperation, - [OnyxUtils.METHOD.MERGE]: enqueueMergeOperation, - [OnyxUtils.METHOD.MERGE_COLLECTION]: () => { - const collection = value as OnyxMergeCollectionInput; - if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) { - Logger.logInfo('Invalid or empty value provided in Onyx mergeCollection. Skipping this operation.'); - return; - } - - // Confirm all the collection keys belong to the same parent - const collectionKeys = Object.keys(collection); - if (OnyxUtils.doAllCollectionItemsBelongToSameParent(key, collectionKeys)) { - const mergedCollection: OnyxInputKeyValueMapping = collection; - for (const collectionKey of collectionKeys) enqueueMergeOperation(collectionKey, mergedCollection[collectionKey]); - } - }, - [OnyxUtils.METHOD.SET_COLLECTION]: (k, v) => promises.push(() => setCollection(k as TKey, v as OnyxSetCollectionInput)), - [OnyxUtils.METHOD.MULTI_SET]: (k, v) => { - if (typeof value !== 'object' || Array.isArray(value) || typeof value === 'function') { - Logger.logInfo(`Invalid value provided in Onyx multiSet. Value must be of type object. Skipping this operation.`); - return; - } - - for (const [entryKey, entryValue] of Object.entries(v as Partial)) enqueueSetOperation(entryKey, entryValue); - }, - [OnyxUtils.METHOD.CLEAR]: () => { - clearPromise = clear(); - }, - }; - handlers[onyxMethod](key, value); } diff --git a/lib/storage/providers/NoopProvider.ts b/lib/storage/providers/NoopProvider.ts index 4f3aaea11..6ef440e55 100644 --- a/lib/storage/providers/NoopProvider.ts +++ b/lib/storage/providers/NoopProvider.ts @@ -1,4 +1,4 @@ -import type {OnyxValue} from '../../types'; +import type {OnyxKey, OnyxValue} from '../../types'; import {StorageErrorClass} from '../errors'; import type StorageProvider from './types'; @@ -25,8 +25,9 @@ const provider: StorageProvider = { /** * Get the value of a given key or return `null` if it's not available in memory */ - getItem(_key) { - return Promise.resolve(null as OnyxValue); + getItem(key: TKey) { + void key; + return Promise.resolve(null as OnyxValue); }, /** @@ -105,7 +106,10 @@ const provider: StorageProvider = { * `bytesRemaining` will always be `Number.POSITIVE_INFINITY` since we don't have a hard limit on memory. */ getDatabaseSize() { - return Promise.resolve({bytesRemaining: Number.POSITIVE_INFINITY, bytesUsed: 0}); + return Promise.resolve({ + bytesRemaining: Number.POSITIVE_INFINITY, + bytesUsed: 0, + }); }, }; From bd34cf73e44d235be0b9d494486c89b0be304d3e Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 26 Jun 2026 12:15:18 -0700 Subject: [PATCH 10/20] Fix remaining seatbelt violations outside no-unsafe-* Resolve template expressions, nullish coalescing, non-null assertions in lib code, unbound-method in tests, and type cleanup across OnyxUtils, merge, and utils. Update seatbelt to drop fixed suppressions while keeping test-only allowances. Co-authored-by: Cursor --- eslint.seatbelt.tsv | 48 --- lib/CircuitBreaker/AbstractCircuitBreaker.ts | 26 +- lib/CircuitBreaker/types.ts | 6 +- lib/OnyxKeys.ts | 2 +- lib/OnyxMerge/index.native.ts | 6 +- lib/OnyxMerge/index.ts | 6 +- lib/OnyxMerge/types.ts | 2 +- lib/OnyxSnapshotCache.ts | 22 +- lib/OnyxUtils.ts | 46 +-- lib/storage/errors.ts | 13 +- lib/storage/index.ts | 3 +- lib/storage/providers/SQLiteProvider.ts | 2 +- lib/types.ts | 44 ++- lib/useLiveRef.ts | 6 +- lib/utils.ts | 26 +- tests/perf-test/Onyx.perf-test.ts | 22 +- .../OnyxConnectionManager.perf-test.ts | 4 +- tests/unit/OnyxConnectionManagerTest.ts | 2 +- tests/unit/onyxTest.ts | 363 ++++++++++++++---- tests/unit/onyxUtilsTest.ts | 8 +- .../providers/IDBKeyvalProviderTest.ts | 34 +- .../storage/providers/SQLiteProviderTest.ts | 6 +- .../unit/storage/providers/createStoreTest.ts | 74 ++-- tests/unit/useOnyxTest.ts | 229 ++++++++--- tests/unit/utilsTest.ts | 4 +- tests/utils/collections/reportActions.ts | 2 +- 26 files changed, 686 insertions(+), 320 deletions(-) diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv index 41c5563cc..e2e925e6e 100644 --- a/eslint.seatbelt.tsv +++ b/eslint.seatbelt.tsv @@ -1,71 +1,37 @@ # eslint-seatbelt temporarily allowed errors # docs: https://github.com/justjake/eslint-seatbelt#readme -".prettierrc.cjs" "no-undef" 1 "buildDocs.ts" "import/no-extraneous-dependencies" 1 -"jest-sequencer.js" "no-undef" 2 "jest-test-environment.ts" "import/no-extraneous-dependencies" 1 -"jestSetup.js" "no-undef" 2 "lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-assignment" 1 "lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-member-access" 1 "lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"lib/Onyx.ts" "@typescript-eslint/no-loop-func" 1 -"lib/Onyx.ts" "@typescript-eslint/no-redundant-type-constituents" 2 "lib/Onyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 -"lib/Onyx.ts" "@typescript-eslint/restrict-template-expressions" 1 -"lib/OnyxCache.ts" "@typescript-eslint/no-redundant-type-constituents" 2 -"lib/OnyxCache.ts" "import/no-extraneous-dependencies" 1 -"lib/OnyxConnectionManager.ts" "@typescript-eslint/no-non-null-assertion" 3 "lib/OnyxConnectionManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 -"lib/OnyxConnectionManager.ts" "import/no-extraneous-dependencies" 1 "lib/OnyxKeys.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"lib/OnyxKeys.ts" "@typescript-eslint/restrict-template-expressions" 1 -"lib/OnyxMerge/index.native.ts" "@typescript-eslint/no-redundant-type-constituents" 2 "lib/OnyxMerge/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"lib/OnyxMerge/index.ts" "@typescript-eslint/no-redundant-type-constituents" 2 "lib/OnyxMerge/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"lib/OnyxMerge/types.ts" "@typescript-eslint/no-redundant-type-constituents" 2 -"lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-non-null-assertion" 2 "lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"lib/OnyxSnapshotCache.ts" "@typescript-eslint/prefer-nullish-coalescing" 2 -"lib/OnyxUtils.ts" "@typescript-eslint/no-non-null-assertion" 2 -"lib/OnyxUtils.ts" "@typescript-eslint/no-redundant-type-constituents" 7 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-argument" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-assignment" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-call" 2 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-member-access" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 32 -"lib/OnyxUtils.ts" "@typescript-eslint/no-use-before-define" 28 -"lib/OnyxUtils.ts" "@typescript-eslint/prefer-nullish-coalescing" 4 -"lib/OnyxUtils.ts" "@typescript-eslint/restrict-template-expressions" 3 "lib/Str.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"lib/storage/InstanceSync/index.ts" "import/no-extraneous-dependencies" 1 "lib/storage/__mocks__/index.ts" "@typescript-eslint/no-unsafe-argument" 1 -"lib/storage/index.ts" "@typescript-eslint/restrict-template-expressions" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-return" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "lib/storage/providers/MemoryOnlyProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unused-vars" 1 -"lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/naming-convention" 3 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-return" 3 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"lib/types.ts" "@typescript-eslint/no-empty-object-type" 1 -"lib/types.ts" "@typescript-eslint/no-restricted-types" 2 -"lib/useLiveRef.ts" "@typescript-eslint/no-deprecated" 1 -"lib/useLiveRef.ts" "react-hooks/refs" 1 -"lib/useOnyx.ts" "@typescript-eslint/no-deprecated" 1 "lib/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"lib/utils.ts" "@typescript-eslint/naming-convention" 2 -"lib/utils.ts" "@typescript-eslint/no-redundant-type-constituents" 2 "lib/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/restrict-template-expressions" 1 "tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-non-null-assertion" 1 "tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-assignment" 2 "tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 2 "tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/no-non-null-assertion" 1 -"tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/unbound-method" 2 "tests/perf-test/OnyxSnapshotCache.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-misused-promises" 1 "tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -75,14 +41,11 @@ "tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 "tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-return" 1 -"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/unbound-method" 1 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"tests/unit/OnyxKeysTest.ts" "@typescript-eslint/naming-convention" 16 "tests/unit/OnyxSnapshotCacheTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-argument" 2 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-assignment" 6 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-member-access" 9 -"tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/naming-convention" 1 "tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-non-null-assertion" 3 "tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-unsafe-assignment" 7 @@ -91,15 +54,11 @@ "tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 "tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/unit/onyxClearNativeStorageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/naming-convention" 12 "tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 "tests/unit/onyxClearWebStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 -"tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/naming-convention" 16 "tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-assignment" 1 "tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/unit/onyxMultiMergeWebStorageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"tests/unit/onyxTest.ts" "@typescript-eslint/naming-convention" 122 -"tests/unit/onyxTest.ts" "@typescript-eslint/no-deprecated" 2 "tests/unit/onyxTest.ts" "@typescript-eslint/no-non-null-assertion" 1 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-assignment" 9 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-call" 2 @@ -115,17 +74,10 @@ "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unsafe-member-access" 2 "tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/unbound-method" 2 "tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-member-access" 2 "tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-return" 1 "tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-non-null-assertion" 1 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-assignment" 18 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/unbound-method" 10 "tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 "tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/unit/useOnyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 -"tests/unit/useOnyxTest.ts" "@typescript-eslint/prefer-nullish-coalescing" 1 "tests/unit/utilsTest.ts" "@typescript-eslint/no-unsafe-member-access" 3 -"tests/utils/collections/reportActions.ts" "@typescript-eslint/restrict-template-expressions" 1 diff --git a/lib/CircuitBreaker/AbstractCircuitBreaker.ts b/lib/CircuitBreaker/AbstractCircuitBreaker.ts index 3ba6feeca..f1d4465ef 100644 --- a/lib/CircuitBreaker/AbstractCircuitBreaker.ts +++ b/lib/CircuitBreaker/AbstractCircuitBreaker.ts @@ -7,7 +7,7 @@ import type {CircuitBreakerOptions, CircuitBreakerState} from './types'; * * - **closed**: requests are allowed; failures are counted. * - **open**: requests are rejected until {@link resetTimeoutMs} elapses. - * - **half-open**: the recovery-probe state. After the open timeout, the breaker admits exactly ONE + * - **halfOpen**: the recovery-probe state. After the open timeout, the breaker admits exactly ONE * probe request: success means the dependency recovered, so the circuit closes. Failure means it's * still down, so the circuit reopens. This single-request probe prevents a "thundering herd" where * every caller fails loudly when the service hasn't recovered yet. @@ -69,10 +69,10 @@ abstract class AbstractCircuitBreaker { /** * Whether a request may proceed. * - * Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and + * Returns `false` while open. In halfOpen, the FIRST caller is admitted as the recovery probe and * `isProbeInFlight` is latched so every subsequent caller is rejected until that probe resolves * (via {@link recordSuccess} → close, or {@link recordFailure} → reopen). That single-probe gate is - * the whole point of half-open: it tests recovery with one request instead of letting a herd of + * the whole point of halfOpen: it tests recovery with one request instead of letting a herd of * waiting callers stampede a dependency that may still be down. */ isAllowed(): boolean { @@ -82,7 +82,7 @@ abstract class AbstractCircuitBreaker { return false; } - if (currentState === 'half-open') { + if (currentState === 'halfOpen') { if (this.isProbeInFlight) { return false; } @@ -93,7 +93,7 @@ abstract class AbstractCircuitBreaker { } /** - * Record a failed request. May open the circuit from closed or half-open. + * Record a failed request. May open the circuit from closed or halfOpen. * @returns `true` when the circuit is open after recording (the request must not proceed). */ recordFailure(): boolean { @@ -101,7 +101,7 @@ abstract class AbstractCircuitBreaker { return true; } - if (this.machine.state === 'half-open') { + if (this.machine.state === 'halfOpen') { this.trip(); return true; } @@ -115,9 +115,9 @@ abstract class AbstractCircuitBreaker { return false; } - /** Record a successful request. Closes the circuit from half-open and clears failure counts. */ + /** Record a successful request. Closes the circuit from halfOpen and clears failure counts. */ recordSuccess(): void { - if (this.machine.state === 'half-open') { + if (this.machine.state === 'halfOpen') { this.close(); return; } @@ -129,7 +129,7 @@ abstract class AbstractCircuitBreaker { /** * The current state WITHOUT advancing recovery — a pure query, safe to call without side effects. - * The open→half-open transition is applied only at the admission point ({@link isAllowed}); by the + * The open→halfOpen transition is applied only at the admission point ({@link isAllowed}); by the * time a caller queries state after being admitted, that transition has already happened. */ peekState(): CircuitBreakerState { @@ -166,7 +166,7 @@ abstract class AbstractCircuitBreaker { } private close(): void { - // close() only ever runs from half-open (see recordSuccess), and half-open → closed is the one + // close() only ever runs from halfOpen (see recordSuccess), and halfOpen → closed is the one // legal closing transition — so go through transition() to keep the illegal open → closed jump // an error rather than silently constructing a fresh closed machine. this.machine = this.machine.transition('closed'); @@ -177,9 +177,9 @@ abstract class AbstractCircuitBreaker { } /** - * Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read + * Lazily advance open → halfOpen once the reset timeout has elapsed. This is checked on read * (via {@link getCurrentState}) rather than on a timer, so there's nothing to schedule or clean up: - * the transition simply becomes visible to the next caller after the window. Entering half-open + * the transition simply becomes visible to the next caller after the window. Entering halfOpen * clears `isProbeInFlight` so the next admitted request becomes the recovery probe. */ private maybeRecover(): void { @@ -191,7 +191,7 @@ abstract class AbstractCircuitBreaker { return; } - this.machine = this.machine.transition('half-open'); + this.machine = this.machine.transition('halfOpen'); this.isProbeInFlight = false; } } diff --git a/lib/CircuitBreaker/types.ts b/lib/CircuitBreaker/types.ts index d4076c684..6eb4e854c 100644 --- a/lib/CircuitBreaker/types.ts +++ b/lib/CircuitBreaker/types.ts @@ -5,7 +5,7 @@ * - **open**: tripped; requests are rejected outright so a known-bad dependency isn't hammered. * - **half-open**: a trial state entered after the open timeout — see {@link CIRCUIT_BREAKER_TRANSITIONS}. */ -type CircuitBreakerState = 'closed' | 'open' | 'half-open'; +type CircuitBreakerState = 'closed' | 'open' | 'halfOpen'; /** * Legal state transitions. The flow is closed → open → half-open → (closed | open). @@ -24,8 +24,8 @@ type CircuitBreakerState = 'closed' | 'open' | 'half-open'; */ const CIRCUIT_BREAKER_TRANSITIONS = { closed: ['open'], - open: ['half-open'], - 'half-open': ['closed', 'open'], + open: ['halfOpen'], + halfOpen: ['closed', 'open'], } as const satisfies Record; type CircuitBreakerOptions = { diff --git a/lib/OnyxKeys.ts b/lib/OnyxKeys.ts index 748b717d9..275ff7b3d 100644 --- a/lib/OnyxKeys.ts +++ b/lib/OnyxKeys.ts @@ -178,7 +178,7 @@ function splitCollectionMemberKey | undefined, TChange extends OnyxInput | undefined>( - key: TKey, - existingValue: TValue, - validChanges: TChange[], -) => { +const applyMerge: ApplyMerge = , TChange extends OnyxInput>(key: TKey, existingValue: TValue, validChanges: TChange[]) => { // If any of the changes is null, we need to discard the existing value. const baseValue = validChanges.includes(null as TChange) ? undefined : existingValue; diff --git a/lib/OnyxMerge/index.ts b/lib/OnyxMerge/index.ts index dd6c86b5e..68be645fc 100644 --- a/lib/OnyxMerge/index.ts +++ b/lib/OnyxMerge/index.ts @@ -5,11 +5,7 @@ import Storage from '../storage'; import type {OnyxInput, OnyxKey, OnyxValue} from '../types'; import type {ApplyMerge} from './types'; -const applyMerge: ApplyMerge = | undefined, TChange extends OnyxInput | undefined>( - key: TKey, - existingValue: TValue, - validChanges: TChange[], -) => { +const applyMerge: ApplyMerge = , TChange extends OnyxInput>(key: TKey, existingValue: TValue, validChanges: TChange[]) => { const {result: mergedValue} = OnyxUtils.mergeChanges(validChanges, existingValue); // In cache, we don't want to remove the key if it's null to improve performance and speed up the next merge. diff --git a/lib/OnyxMerge/types.ts b/lib/OnyxMerge/types.ts index e53d8ff32..a755e3ce0 100644 --- a/lib/OnyxMerge/types.ts +++ b/lib/OnyxMerge/types.ts @@ -4,7 +4,7 @@ type ApplyMergeResult = { mergedValue: TValue; }; -type ApplyMerge = | undefined, TChange extends OnyxInput | null>( +type ApplyMerge = , TChange extends OnyxInput>( key: TKey, existingValue: TValue, validChanges: TChange[], diff --git a/lib/OnyxSnapshotCache.ts b/lib/OnyxSnapshotCache.ts index 92c7bba1e..70af8021e 100644 --- a/lib/OnyxSnapshotCache.ts +++ b/lib/OnyxSnapshotCache.ts @@ -43,11 +43,13 @@ class OnyxSnapshotCache { */ getSelectorID(selector: UseOnyxSelector): number { const typedSelector = selector as unknown as UseOnyxSelector; - if (!this.selectorIDMap.has(typedSelector)) { - const id = this.selectorIDCounter++; - this.selectorIDMap.set(typedSelector, id); + const existingID = this.selectorIDMap.get(typedSelector); + if (existingID !== undefined) { + return existingID; } - return this.selectorIDMap.get(typedSelector)!; + const id = this.selectorIDCounter++; + this.selectorIDMap.set(typedSelector, id); + return id; } /** @@ -67,7 +69,7 @@ class OnyxSnapshotCache { const cacheKey = `${key}_${selectorID}`; // Increment reference count for this cache key - const currentCount = this.cacheKeyRefCounts.get(cacheKey) || 0; + const currentCount = this.cacheKeyRefCounts.get(cacheKey) ?? 0; this.cacheKeyRefCounts.set(cacheKey, currentCount + 1); return cacheKey; @@ -78,7 +80,7 @@ class OnyxSnapshotCache { * Decrements reference counter and removes cache entry if no consumers remain. */ deregisterConsumer(key: OnyxKey, cacheKey: string): void { - const currentCount = this.cacheKeyRefCounts.get(cacheKey) || 0; + const currentCount = this.cacheKeyRefCounts.get(cacheKey) ?? 0; if (currentCount <= 1) { // Last consumer - remove from reference counter and cache @@ -111,10 +113,12 @@ class OnyxSnapshotCache { * Set cached snapshot result for a key and cache key combination */ setCachedResult>>(key: OnyxKey, cacheKey: string, result: TResult): void { - if (!this.snapshotCache.has(key)) { - this.snapshotCache.set(key, new Map()); + let keyCache = this.snapshotCache.get(key); + if (!keyCache) { + keyCache = new Map(); + this.snapshotCache.set(key, keyCache); } - this.snapshotCache.get(key)!.set(cacheKey, result); + keyCache.set(cacheKey, result); } /** diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 9bdfbbb42..9b6c11d1a 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -55,6 +55,10 @@ const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5; type OnyxMethod = ValueOf; +function formatCaughtError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + // Key/value store of Onyx key and arrays of values to merge let mergeQueue: Record>> = {}; let mergeQueuePromise: Record> = {}; @@ -213,13 +217,8 @@ function sendActionToDevTools( value: OnyxEntry, mergedValue?: OnyxEntry, ): void; -function sendActionToDevTools( - method: OnyxMethod, - key: OnyxKey | undefined, - value: OnyxCollection | OnyxEntry, - mergedValue: OnyxEntry = undefined, -): void { - DevTools.registerAction(utils.formatActionName(method, key), value, key ? {[key]: mergedValue || value} : (value as OnyxCollection)); +function sendActionToDevTools(method: OnyxMethod, key: OnyxKey | undefined, value: unknown, mergedValue: OnyxEntry = undefined): void { + DevTools.registerAction(utils.formatActionName(method, key), value, key ? {[key]: mergedValue !== undefined ? mergedValue : value} : (value as OnyxCollection)); } /** @@ -494,7 +493,7 @@ function tryGetCachedValue(key: TKey): OnyxValue function getCachedCollection(collectionKey: TKey, collectionMemberKeys?: string[]): NonNullable> { // Use optimized collection data retrieval when cache is populated const collectionData = cache.getCollectionData(collectionKey); - const allKeys = collectionMemberKeys || cache.getAllKeys(); + const allKeys = collectionMemberKeys ?? cache.getAllKeys(); if (collectionData !== undefined && (Array.isArray(allKeys) ? allKeys.length > 0 : allKeys.size > 0)) { // If we have specific member keys, filter the collection if (collectionMemberKeys) { @@ -606,7 +605,7 @@ function keysChanged( currentSubscriberCallback(cachedCollection[dataKey], dataKey as TKey); } } catch (error) { - Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`); + Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${formatCaughtError(error)}`); } } @@ -629,7 +628,7 @@ function keysChanged( matchedKey: subscriber.key, }); } catch (error) { - Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`); + Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${formatCaughtError(error)}`); } } } @@ -717,7 +716,7 @@ function keyChanged( }); continue; } catch (error) { - Logger.logAlert(`[OnyxUtils.keyChanged] Subscriber callback threw an error for key '${key}': ${error}`); + Logger.logAlert(`[OnyxUtils.keyChanged] Subscriber callback threw an error for key '${key}': ${formatCaughtError(error)}`); } continue; @@ -759,7 +758,8 @@ function sendDataToConnection(mapping: CallbackToStateMapp return; } - (mapping as DefaultConnectOptions).callback?.(value, matchedKey!); + const callbackKey = matchedKey ?? mapping.key; + (mapping as DefaultConnectOptions).callback?.(value, callbackKey); } /** @@ -956,7 +956,7 @@ function prepareKeyValuePairsForStorage( * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ -function mergeChanges | undefined, TChange extends OnyxInput | undefined>(changes: TChange[], existingValue?: TValue): FastMergeResult { +function mergeChanges, TChange extends OnyxInput>(changes: TChange[], existingValue?: TValue): FastMergeResult { return mergeInternal('merge', changes, existingValue); } @@ -967,10 +967,7 @@ function mergeChanges | undefined, TChange ext * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ -function mergeAndMarkChanges | undefined, TChange extends OnyxInput | undefined>( - changes: TChange[], - existingValue?: TValue, -): FastMergeResult { +function mergeAndMarkChanges, TChange extends OnyxInput>(changes: TChange[], existingValue?: TValue): FastMergeResult { return mergeInternal('mark', changes, existingValue); } @@ -980,11 +977,7 @@ function mergeAndMarkChanges | undefined, TCha * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ -function mergeInternal | undefined, TChange extends OnyxInput | undefined>( - mode: 'merge' | 'mark', - changes: TChange[], - existingValue?: TValue, -): FastMergeResult { +function mergeInternal, TChange extends OnyxInput>(mode: 'merge' | 'mark', changes: TChange[], existingValue?: TValue): FastMergeResult { const lastChange = changes?.at(-1); if (Array.isArray(lastChange)) { @@ -1014,7 +1007,10 @@ function mergeInternal | undefined, TChange ex // If we have anything else we can't merge it so we'll // simply return the last value that was queued - return {result: lastChange!, replaceNullPatches: []}; + if (lastChange === undefined) { + return {result: (existingValue ?? {}) as TChange, replaceNullPatches: []}; + } + return {result: lastChange, replaceNullPatches: []}; } /** @@ -1266,7 +1262,7 @@ function updateSnapshots(data: Array>, me } if (Array.isArray(value) || Array.isArray(snapshotData[key])) { - updatedData[key] = value || []; + updatedData[key] = value ?? []; continue; } @@ -1275,7 +1271,7 @@ function updateSnapshots(data: Array>, me continue; } - const oldValue = updatedData[key] || {}; + const oldValue = updatedData[key] ?? {}; // Snapshot entries are stored as a "shape" of the last known data per key, so by default we only // merge fields that already exist in the snapshot to avoid unintentionally bloating snapshot data. diff --git a/lib/storage/errors.ts b/lib/storage/errors.ts index 635f55428..23063ec74 100644 --- a/lib/storage/errors.ts +++ b/lib/storage/errors.ts @@ -30,9 +30,18 @@ const StorageErrorClass = { */ function getErrorParts(error: unknown): {name: string; message: string} { if (error instanceof Error || (typeof DOMException !== 'undefined' && error instanceof DOMException)) { - return {name: (error.name ?? '').toLowerCase(), message: (error.message ?? '').toLowerCase()}; + return { + name: (error.name ?? '').toLowerCase(), + message: (error.message ?? '').toLowerCase(), + }; } - return {name: '', message: String(error ?? '').toLowerCase()}; + if (typeof error === 'string') { + return {name: '', message: error.toLowerCase()}; + } + if (error === null || error === undefined) { + return {name: '', message: ''}; + } + return {name: '', message: JSON.stringify(error).toLowerCase()}; } export {StorageErrorClass, getErrorParts}; diff --git a/lib/storage/index.ts b/lib/storage/index.ts index 414a0a0ca..c35968ce3 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -20,7 +20,8 @@ type Storage = { * Degrade performance by removing the storage provider and only using cache */ function degradePerformance(error: Error) { - Logger.logHmmm(`Error while using ${provider.name}. Falling back to only using cache and dropping storage.\n Error: ${error.message}\n Stack: ${error.stack}\n Cause: ${error.cause}`); + const causeMessage = error.cause instanceof Error ? error.cause.message : typeof error.cause === 'string' ? error.cause : ''; + Logger.logHmmm(`Error while using ${provider.name}. Falling back to only using cache and dropping storage.\n Error: ${error.message}\n Stack: ${error.stack}\n Cause: ${causeMessage}`); console.error(error); provider = MemoryOnlyProvider; } diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 56fadc588..7e19f54b0 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -41,7 +41,7 @@ const DB_NAME = 'OnyxDB'; * Prevents the stringifying of the object markers. */ function objectMarkRemover(key: string, value: unknown) { - if (key === utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK) return undefined; + if (key === utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK) return undefined; return value; } diff --git a/lib/types.ts b/lib/types.ts index f0cb86d22..ed9c3f276 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -88,7 +88,7 @@ type TypeOptions = Merge< * } * ``` */ -type CustomTypeOptions = {}; +type CustomTypeOptions = Record; /** * Represents a string union of all Onyx normal keys. @@ -189,12 +189,12 @@ type NonTransformableTypes = * * settings = applySavedSettings({textEditor: {fontWeight: 500, fontColor: null}}); */ -type NullishDeep = T extends NonTransformableTypes ? T : T extends object ? NullishObjectDeep : unknown; +type NullishDeep = T extends NonTransformableTypes ? T : T extends Record ? NullishObjectDeep : T; /** - * Same as `NullishDeep`, but accepts only `object`s as inputs. Internal helper for `NullishDeep`. + * Same as `NullishDeep`, but accepts only records as inputs. Internal helper for `NullishDeep`. */ -type NullishObjectDeep = { +type NullishObjectDeep> = { [KeyType in keyof ObjectType]?: NullishDeep | null; }; @@ -334,12 +334,36 @@ type ExpandOnyxKeys = TKey extends CollectionKeyBase ? NoI type OnyxUpdate = { // ⚠️ DO NOT CHANGE THIS TYPE, UNLESS YOU KNOW WHAT YOU ARE DOING. ⚠️ [K in TKey]: - | {onyxMethod: typeof OnyxUtils.METHOD.SET; key: ExpandOnyxKeys; value: OnyxSetInput} - | {onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; key: ExpandOnyxKeys; value: OnyxMultiSetInput} - | {onyxMethod: typeof OnyxUtils.METHOD.MERGE; key: ExpandOnyxKeys; value: OnyxMergeInput} - | {onyxMethod: typeof OnyxUtils.METHOD.CLEAR; key: ExpandOnyxKeys; value?: never} - | {onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; key: K; value: OnyxMergeCollectionInput} - | {onyxMethod: typeof OnyxUtils.METHOD.SET_COLLECTION; key: K; value: OnyxSetCollectionInput}; + | { + onyxMethod: typeof OnyxUtils.METHOD.SET; + key: ExpandOnyxKeys; + value: OnyxSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; + key: ExpandOnyxKeys; + value: OnyxMultiSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE; + key: ExpandOnyxKeys; + value: OnyxMergeInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.CLEAR; + key: ExpandOnyxKeys; + value?: never; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; + key: K; + value: OnyxMergeCollectionInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.SET_COLLECTION; + key: K; + value: OnyxSetCollectionInput; + }; }[TKey]; /** diff --git a/lib/useLiveRef.ts b/lib/useLiveRef.ts index 869f439db..c28b77451 100644 --- a/lib/useLiveRef.ts +++ b/lib/useLiveRef.ts @@ -4,14 +4,16 @@ import {useRef} from 'react'; * Creates a mutable reference to a value, useful when you need to * maintain a reference to a value that may change over time without triggering re-renders. * - * @deprecated This hook breaks the Rules of React, and should not be used. - * The migration effort to remove it safely is not currently planned. + * This hook intentionally assigns to ref.current during render. The migration effort to + * remove it safely is not currently planned. */ +/* eslint-disable react-hooks/refs -- Intentional live-ref pattern for dependency tracking without re-renders */ function useLiveRef(value: T) { const ref = useRef(value); ref.current = value; return ref; } +/* eslint-enable react-hooks/refs */ export default useLiveRef; diff --git a/lib/utils.ts b/lib/utils.ts index b469c1acd..33177e102 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -37,7 +37,7 @@ type FastMergeResult = { replaceNullPatches: FastMergeReplaceNullPatch[]; }; -const ONYX_INTERNALS__REPLACE_OBJECT_MARK = 'ONYX_INTERNALS__REPLACE_OBJECT_MARK'; +const ONYX_INTERNALS_REPLACE_OBJECT_MARK = 'ONYX_INTERNALS__REPLACE_OBJECT_MARK'; /** * Merges two objects and removes null values if "shouldRemoveNestedNulls" is set to true @@ -66,7 +66,10 @@ function fastMerge(target: TValue, source: TValue, options?: FastMergeOp const mergedValue = mergeObject(target, source as Record, optionsWithDefaults, metadata, basePath) as TValue; - return {result: mergedValue, replaceNullPatches: metadata.replaceNullPatches}; + return { + result: mergedValue, + replaceNullPatches: metadata.replaceNullPatches, + }; } /** @@ -79,7 +82,7 @@ function fastMerge(target: TValue, source: TValue, options?: FastMergeOp * @returns - The merged object. */ function mergeObject>( - target: TObject | unknown | null | undefined, + target: TObject | null | undefined, source: TObject, options: FastMergeOptions, metadata: FastMergeMetadata, @@ -143,19 +146,19 @@ function mergeObject>( // When calling fastMerge again with "mark" removal mode, the marked objects will be removed. if (options.objectRemovalMode === 'mark' && targetProperty === null) { hasChanged = true; - targetProperty = {[ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true}; + targetProperty = {[ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true}; metadata.replaceNullPatches.push([[...basePath, key], {...sourceProperty}]); } // Later, when merging the batched changes with the Onyx value, if a nested object of the batched changes // has the internal flag set, we replace the entire destination object with the source one and remove // the flag. - if (options.objectRemovalMode === 'replace' && sourceProperty[ONYX_INTERNALS__REPLACE_OBJECT_MARK]) { + if (options.objectRemovalMode === 'replace' && sourceProperty[ONYX_INTERNALS_REPLACE_OBJECT_MARK]) { hasChanged = true; // We do a spread here in order to have a new object reference and allow us to delete the internal flag // of the merged object only. const sourcePropertyWithoutMark = {...sourceProperty}; - delete sourcePropertyWithoutMark.ONYX_INTERNALS__REPLACE_OBJECT_MARK; + delete sourcePropertyWithoutMark[ONYX_INTERNALS_REPLACE_OBJECT_MARK]; destination[key] = sourcePropertyWithoutMark; continue; } @@ -197,7 +200,7 @@ function isMergeableObject>(value: unkno } /** Deep removes the nested null values from the given value. Returns the original reference if no nulls were found. */ -function removeNestedNullValues | null>(value: TValue): TValue { +function removeNestedNullValues>(value: TValue): TValue { if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) { return value; } @@ -237,7 +240,12 @@ function formatActionName(method: string, key?: OnyxKey): string { function checkCompatibilityWithExistingValue( value: unknown, existingValue: unknown, -): {isCompatible: boolean; existingValueType?: string; newValueType?: string; isEmptyArrayCoercion?: boolean} { +): { + isCompatible: boolean; + existingValueType?: string; + newValueType?: string; + isEmptyArrayCoercion?: boolean; +} { if (!existingValue || !value) { return { isCompatible: true, @@ -330,6 +338,6 @@ export default { checkCompatibilityWithExistingValue, pick, omit, - ONYX_INTERNALS__REPLACE_OBJECT_MARK, + ONYX_INTERNALS_REPLACE_OBJECT_MARK, }; export type {FastMergeResult, FastMergeReplaceNullPatch, FastMergeOptions}; diff --git a/tests/perf-test/Onyx.perf-test.ts b/tests/perf-test/Onyx.perf-test.ts index 83f4f2489..8bb5b18c4 100644 --- a/tests/perf-test/Onyx.perf-test.ts +++ b/tests/perf-test/Onyx.perf-test.ts @@ -42,7 +42,7 @@ describe('Onyx', () => { describe('set', () => { test('10k calls with heavy objects', async () => { await measureAsyncFunction( - () => Promise.all(Object.values(mockedReportActionsMap).map((reportAction) => Onyx.set(`${collectionKey}${reportAction.reportActionID}`, reportAction))), + () => Promise.all(Object.values(mockedReportActionsMap).map((reportAction) => Onyx.set(`${collectionKey}${String(reportAction.reportActionID)}`, reportAction))), {afterEach: clearOnyxAfterEachMeasure}, ); }); @@ -50,7 +50,9 @@ describe('Onyx', () => { describe('multiSet', () => { test('one call with 10k heavy objects', async () => { - await measureAsyncFunction(() => Onyx.multiSet(mockedReportActionsMap), {afterEach: clearOnyxAfterEachMeasure}); + await measureAsyncFunction(() => Onyx.multiSet(mockedReportActionsMap), { + afterEach: clearOnyxAfterEachMeasure, + }); }); }); @@ -99,11 +101,23 @@ describe('Onyx', () => { const sets = Object.entries(changedReportActions) .filter(([, v]) => Number(v.reportActionID) % 2 === 0) - .map(([k, v]): OnyxUpdate => ({key: k, onyxMethod: Onyx.METHOD.SET, value: v})); + .map( + ([k, v]): OnyxUpdate => ({ + key: k, + onyxMethod: Onyx.METHOD.SET, + value: v, + }), + ); const merges = Object.entries(changedReportActions) .filter(([, v]) => Number(v.reportActionID) % 2 !== 0) - .map(([k, v]): OnyxUpdate => ({key: k, onyxMethod: Onyx.METHOD.MERGE, value: v})); + .map( + ([k, v]): OnyxUpdate => ({ + key: k, + onyxMethod: Onyx.METHOD.MERGE, + value: v, + }), + ); const updates = alternateLists(sets, merges) as Array>; diff --git a/tests/perf-test/OnyxConnectionManager.perf-test.ts b/tests/perf-test/OnyxConnectionManager.perf-test.ts index 4e93dc30a..e95145677 100644 --- a/tests/perf-test/OnyxConnectionManager.perf-test.ts +++ b/tests/perf-test/OnyxConnectionManager.perf-test.ts @@ -29,8 +29,8 @@ const mockedReportActionsKeys = Object.keys(mockedReportActionsMap); // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -const generateConnectionID = connectionManager.generateConnectionID; -const fireCallbacks = connectionManager.fireCallbacks; +const generateConnectionID = connectionManager.generateConnectionID.bind(connectionManager); +const fireCallbacks = connectionManager.fireCallbacks.bind(connectionManager); const resetConectionManagerAfterEachMeasure = () => { connectionManager.disconnectAll(); diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index e7ebc8673..3a720740b 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -8,7 +8,7 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. const connectionsMap = connectionManager.connectionsMap; -const generateConnectionID = connectionManager.generateConnectionID; +const generateConnectionID = connectionManager.generateConnectionID.bind(connectionManager); const getSessionID = () => connectionManager.sessionID; const ONYXKEYS = { diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 95c13529d..0140937ff 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -223,7 +223,9 @@ describe('Onyx', () => { await Onyx.set(collectionKey, []); expect((testKeyValue as Record)?.[collectionKey]).toStrictEqual([]); - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {[collectionKey]: {test: 'value'}}); + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [collectionKey]: {test: 'value'}, + }); expect((testKeyValue as Record)?.[collectionKey]).toStrictEqual({test: 'value'}); }); @@ -577,15 +579,21 @@ describe('Onyx', () => { }); }) .then(() => { - expect(testKeyValue).toEqual({test1: {test2: 'test2', test3: 'test3'}}); + expect(testKeyValue).toEqual({ + test1: {test2: 'test2', test3: 'test3'}, + }); return Onyx.merge(ONYX_KEYS.TEST_KEY, {test1: undefined}); }) .then(() => { - expect(testKeyValue).toEqual({test1: {test2: 'test2', test3: 'test3'}}); + expect(testKeyValue).toEqual({ + test1: {test2: 'test2', test3: 'test3'}, + }); return Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); }) .then(() => { - expect(testKeyValue).toEqual({test1: {test2: 'test2', test3: 'test3'}}); + expect(testKeyValue).toEqual({ + test1: {test2: 'test2', test3: 'test3'}, + }); }); }); @@ -652,8 +660,12 @@ describe('Onyx', () => { }, }); - Onyx.merge(ONYX_KEYS.COLLECTION.TEST_KEY, {test_1: {something: [1, 2, 3]}}); - return Onyx.merge(ONYX_KEYS.COLLECTION.TEST_KEY, {test_1: {something: [4]}}).then(() => { + Onyx.merge(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: {something: [1, 2, 3]}, + }); + return Onyx.merge(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: {something: [4]}, + }).then(() => { expect(testKeyValue).toEqual({test_1: {something: [4]}}); }); }); @@ -733,7 +745,10 @@ describe('Onyx', () => { callback: (data, key) => (valuesReceived[key] = data), }); - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {test_1: {ID: 123}, notMyTest: {beep: 'boop'}}).then(() => { + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: {ID: 123}, + notMyTest: {beep: 'boop'}, + }).then(() => { expect(valuesReceived).toEqual({}); }); }); @@ -1216,12 +1231,26 @@ describe('Onyx', () => { connections.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); connections.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback})); - connections.push(Onyx.connect({key: ONYX_KEYS.COLLECTION.TEST_UPDATE, callback: collectionCallback, waitForCollectionCallback: true})); + connections.push( + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_UPDATE, + callback: collectionCallback, + waitForCollectionCallback: true, + }), + ); return waitForPromisesToResolve().then(() => 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, + key: ONYX_KEYS.OTHER_TEST, + value: 'pizza', + }, + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYX_KEYS.COLLECTION.TEST_UPDATE, + value: {[itemKey]: {a: 'a'}}, + }, ]).then(() => { expect(collectionCallback).toHaveBeenCalledTimes(2); expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_UPDATE, undefined); @@ -1517,7 +1546,7 @@ describe('Onyx', () => { await Onyx.update([{key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE}]); - expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenNthCalledWith(1, {data: {[cat]: initialValue}}, snapshot1); expect(callback).toHaveBeenNthCalledWith(2, {data: {[cat]: finalValue}}, snapshot1); }); @@ -1548,9 +1577,21 @@ describe('Onyx', () => { await Onyx.update([{key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE}]); - expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenNthCalledWith(1, {data: {[cat]: initialValue}}, snapshot1); - expect(callback).toHaveBeenNthCalledWith(2, {data: {[cat]: {name: 'Kitty', pendingAction: 'delete', pendingFields: {preview: 'delete'}}}}, snapshot1); + expect(callback).toHaveBeenNthCalledWith( + 2, + { + data: { + [cat]: { + name: 'Kitty', + pendingAction: 'delete', + pendingFields: {preview: 'delete'}, + }, + }, + }, + snapshot1, + ); }); describe('update', () => { @@ -1661,8 +1702,18 @@ describe('Onyx', () => { }, ONYX_KEYS.COLLECTION.ROUTES, { - [holidayRoute]: {waypoints: {0: 'Bed', 1: 'Home', 2: 'Beach', 3: 'Restaurant', 4: 'Home'}}, - [routineRoute]: {waypoints: {0: 'Bed', 1: 'Home', 2: 'Work', 3: 'Gym'}}, + [holidayRoute]: { + waypoints: { + 0: 'Bed', + 1: 'Home', + 2: 'Beach', + 3: 'Restaurant', + 4: 'Home', + }, + }, + [routineRoute]: { + waypoints: {0: 'Bed', 1: 'Home', 2: 'Work', 3: 'Gym'}, + }, }, ); @@ -1686,7 +1737,12 @@ describe('Onyx', () => { const catCallback = jest.fn(); connections.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); - connections.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback})); + connections.push( + Onyx.connect({ + key: ONYX_KEYS.OTHER_TEST, + callback: otherTestCallback, + }), + ); connections.push( Onyx.connect({ key: ONYX_KEYS.COLLECTION.ANIMALS, @@ -1704,11 +1760,31 @@ describe('Onyx', () => { connections.push(Onyx.connect({key: cat, callback: catCallback})); return Onyx.update([ - {onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.TEST_KEY, value: 'none'}, - {onyxMethod: Onyx.METHOD.SET, key: ONYX_KEYS.TEST_KEY, value: {food: 'taco'}}, - {onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.TEST_KEY, value: {drink: 'wine'}}, - {onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.OTHER_TEST, value: {food: 'pizza'}}, - {onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.OTHER_TEST, value: {drink: 'water'}}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.TEST_KEY, + value: 'none', + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYX_KEYS.TEST_KEY, + value: {food: 'taco'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.TEST_KEY, + value: {drink: 'wine'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.OTHER_TEST, + value: {food: 'pizza'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.OTHER_TEST, + value: {drink: 'water'}, + }, {onyxMethod: Onyx.METHOD.MERGE, key: dog, value: {sound: 'woof'}}, { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, @@ -1721,7 +1797,11 @@ describe('Onyx', () => { {onyxMethod: Onyx.METHOD.SET, key: cat, value: {age: 3}}, {onyxMethod: Onyx.METHOD.MERGE, key: cat, value: {sound: 'meow'}}, {onyxMethod: Onyx.METHOD.MERGE, key: bob, value: {car: 'sedan'}}, - {onyxMethod: Onyx.METHOD.MERGE, key: lisa, value: {car: 'SUV', age: 21}}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: lisa, + value: {car: 'SUV', age: 21}, + }, {onyxMethod: Onyx.METHOD.MERGE, key: bob, value: {age: 25}}, ]).then(() => { expect(testCallback).toHaveBeenNthCalledWith(1, {food: 'taco', drink: 'wine'}, ONYX_KEYS.TEST_KEY); @@ -1831,7 +1911,11 @@ describe('Onyx', () => { await Onyx.update(queuedUpdates); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: {someKey: 'someValueChanged'}}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { + someKey: 'someValueChanged', + }, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual({someKey: 'someValueChanged'}); }); @@ -1855,7 +1939,9 @@ describe('Onyx', () => { someKey: 'someValue', }, }; - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1}); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); const entry1ExpectedResult = lodashCloneDeep(entry1); const queuedUpdates: Array> = []; @@ -1885,7 +1971,9 @@ describe('Onyx', () => { await Onyx.update(queuedUpdates); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); }); @@ -1902,7 +1990,9 @@ describe('Onyx', () => { }, }, }; - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1}); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); const entry1ExpectedResult = lodashCloneDeep(entry1); const queuedUpdates: Array> = []; @@ -1957,7 +2047,9 @@ describe('Onyx', () => { await Onyx.update(queuedUpdates); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); }); @@ -1974,7 +2066,9 @@ describe('Onyx', () => { }, }, }; - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1}); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); const entry1ExpectedResult = lodashCloneDeep(entry1); const queuedUpdates: Array> = []; @@ -2017,7 +2111,9 @@ describe('Onyx', () => { await Onyx.update(queuedUpdates); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); }); @@ -2056,7 +2152,10 @@ describe('Onyx', () => { }, }, }); - entry1ExpectedResult = {someKey: 'someValueChanged', someNestedObject: {someNestedKey: 'someNestedValue'}}; + entry1ExpectedResult = { + someKey: 'someValueChanged', + someNestedObject: {someNestedKey: 'someNestedValue'}, + }; queuedUpdates.push({ key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, @@ -2079,11 +2178,15 @@ describe('Onyx', () => { }, }, }); - entry1ExpectedResult.someNestedObject = {someNestedKeyChanged: 'someNestedValueChange'}; + entry1ExpectedResult.someNestedObject = { + someNestedKeyChanged: 'someNestedValueChange', + }; await Onyx.update(queuedUpdates); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); }); @@ -2102,8 +2205,12 @@ describe('Onyx', () => { someKey: 'someValue', }, }; - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1}); - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`]: entry2}); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`]: entry2, + }); const entry1ExpectedResult = lodashCloneDeep(entry1); const entry2ExpectedResult = lodashCloneDeep(entry2); @@ -2381,7 +2488,11 @@ describe('Onyx', () => { Onyx.update([ {onyxMethod: 'set', key: ONYX_KEYS.TEST_KEY, value: 'test1'}, // @ts-expect-error invalid method - {onyxMethod: 'invalidMethod', key: ONYX_KEYS.OTHER_TEST, value: 'test2'}, + { + onyxMethod: 'invalidMethod', + key: ONYX_KEYS.OTHER_TEST, + value: 'test2', + }, ]), ); @@ -2414,7 +2525,11 @@ describe('Onyx', () => { await act(async () => Onyx.update([ // @ts-expect-error invalid value - {onyxMethod: 'mergecollection', key: ONYX_KEYS.COLLECTION.TEST_KEY, value: 'test1'}, + { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: 'test1', + }, ]), ); @@ -2452,7 +2567,11 @@ describe('Onyx', () => { await waitForPromisesToResolve(); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: {someKey: 'someValueChanged'}}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { + someKey: 'someValueChanged', + }, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual({someKey: 'someValueChanged'}); }); @@ -2476,7 +2595,9 @@ describe('Onyx', () => { someKey: 'someValue', }, }; - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1}); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); const entry1ExpectedResult = lodashCloneDeep(entry1); @@ -2497,7 +2618,9 @@ describe('Onyx', () => { await waitForPromisesToResolve(); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); }); @@ -2514,7 +2637,9 @@ describe('Onyx', () => { }, }, }; - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1}); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); const entry1ExpectedResult = lodashCloneDeep(entry1); @@ -2556,7 +2681,9 @@ describe('Onyx', () => { await waitForPromisesToResolve(); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); }); @@ -2573,7 +2700,9 @@ describe('Onyx', () => { }, }, }; - await Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1}); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); const entry1ExpectedResult = lodashCloneDeep(entry1); @@ -2607,7 +2736,9 @@ describe('Onyx', () => { await waitForPromisesToResolve(); - expect(result).toEqual({[`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult}); + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); }); }); @@ -2653,7 +2784,9 @@ describe('Onyx', () => { it('should not save a RAM-only key to storage when using merge', async () => { await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, {someProperty: 'value'}); - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({someProperty: 'value'}); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({ + someProperty: 'value', + }); expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); }); @@ -2683,7 +2816,9 @@ describe('Onyx', () => { .then(() => { expect(testKeyValue).toEqual(testData); - return Onyx.set(ONYX_KEYS.TEST_KEY, testData, {skipCacheCheck: true}); + return Onyx.set(ONYX_KEYS.TEST_KEY, testData, { + skipCacheCheck: true, + }); }) .then(() => { expect(testKeyValue).toEqual(testData); @@ -2870,11 +3005,20 @@ describe('Onyx', () => { }, }); - await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, {id: 'entry1_id', name: 'entry2_name'}); - await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, {id: 'skippable-id_id', name: 'skippable-id_name'}); + await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, { + id: 'entry1_id', + name: 'entry2_name', + }); + await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, { + id: 'skippable-id_id', + name: 'skippable-id_name', + }); expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry2_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry2_name', + }, }); }); @@ -2888,11 +3032,20 @@ describe('Onyx', () => { }, }); - await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, {id: 'entry1_id', name: 'entry2_name'}); - await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, {id: 'skippable-id_id', name: 'skippable-id_name'}); + await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, { + id: 'entry1_id', + name: 'entry2_name', + }); + await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, { + id: 'skippable-id_id', + name: 'skippable-id_name', + }); expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry2_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry2_name', + }, }); }); @@ -2907,14 +3060,29 @@ describe('Onyx', () => { }); await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name', + }, }); expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, }); }); @@ -2929,14 +3097,29 @@ describe('Onyx', () => { }); await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name', + }, }); expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, }); }); @@ -2951,14 +3134,29 @@ describe('Onyx', () => { }); await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name', + }, }); expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, }); }); it('should clear pending merge for a key during multiSet()', async () => { @@ -3054,7 +3252,9 @@ describe('Onyx.init', () => { }); it('mergeCollection', async () => { - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {[`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1'}); + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1', + }); await act(async () => waitForPromisesToResolve()); expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); @@ -3093,7 +3293,9 @@ describe('Onyx.init', () => { }); it('setCollection', async () => { - Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {[`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1'}); + Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1', + }); await act(async () => waitForPromisesToResolve()); expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); @@ -3180,7 +3382,9 @@ describe('RAM-only keys should not read from storage', () => { it('should not include stale RAM-only keys in getAllKeys results', async () => { // Simulate stale data in storage await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value'); - await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, {stale: 'member'}); + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, { + stale: 'member', + }); await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, 'normal_value'); Onyx.init({ @@ -3216,7 +3420,10 @@ describe('RAM-only keys should not read from storage', () => { it('should not use stale storage data as merge base for RAM-only keys', async () => { // Simulate stale data in storage - await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, {name: 'stale', token: 'old_token'}); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, { + name: 'stale', + token: 'old_token', + }); Onyx.init({ keys: ONYX_KEYS, @@ -3349,7 +3556,9 @@ describe('RAM-only keys should not read from storage', () => { // Pre-seed storage with stale data for both normal and RAM-only keys await StorageMock.setItem(normalMember, 'normal_from_storage'); - await StorageMock.setItem(ramOnlyMember, {data: 'stale_collection_member'}); + await StorageMock.setItem(ramOnlyMember, { + data: 'stale_collection_member', + }); Onyx.init({ keys: ONYX_KEYS, @@ -3434,8 +3643,16 @@ describe('get() should prefer cache over stale storage', () => { // 2+ collection keys get batched into mergeCollectionWithPatches (deferred cache write) const updatePromise = Onyx.update([ - {onyxMethod: Onyx.METHOD.MERGE, key: member1, value: {isOptimistic: true, name: 'first'}}, - {onyxMethod: Onyx.METHOD.MERGE, key: member2, value: {isOptimistic: true, name: 'second'}}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: member1, + value: {isOptimistic: true, name: 'first'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: member2, + value: {isOptimistic: true, name: 'second'}, + }, ]); // Concurrent merge fires before cache write — its get() hits the delayed storage mock diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 338409811..d4842e8bc 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -689,11 +689,11 @@ describe('OnyxUtils', () => { d: { i: 'i', j: 'j', - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, }, h: 'h', g: { - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, k: 'k', }, }, @@ -727,11 +727,11 @@ describe('OnyxUtils', () => { d: { i: 'i', j: 'j', - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, }, h: 'h', g: { - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, k: 'k', }, }, diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index 203055d34..85d886d05 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -121,7 +121,7 @@ describe('IDBKeyValProvider', () => { property: { nestedProperty: { nestedKey2: 'nestedValue2_changed', - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, }, newKey: 'newValue', }, @@ -134,7 +134,9 @@ describe('IDBKeyValProvider', () => { const expectedEntries = structuredClone(changedEntries); const expectedTestKey3Value = structuredClone(testEntries.at(2))[1] as GenericDeepRecord; expectedTestKey3Value.key = 'value_changed'; - expectedTestKey3Value.property.nestedProperty = {nestedKey2: 'nestedValue2_changed'}; + expectedTestKey3Value.property.nestedProperty = { + nestedKey2: 'nestedValue2_changed', + }; expectedTestKey3Value.property.newKey = 'newValue'; expectedEntries.at(2)[1] = expectedTestKey3Value; @@ -175,7 +177,9 @@ describe('IDBKeyValProvider', () => { it('should insert a new record when key does not exist', async () => { await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]); - expect(await IDBKeyValProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true}); + expect(await IDBKeyValProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({ + fresh: true, + }); }); }); @@ -185,18 +189,18 @@ describe('IDBKeyValProvider', () => { // rejects with as-is. Every write path must instead reject with a real Error so the failure // can be classified and retried. function abortTransactionOnPut() { - const originalPut = IDBObjectStore.prototype.put; - jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters) { - const request = originalPut.apply(this, args); + const spy = jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters) { + spy.mockRestore(); + const request = this.put(...args); this.transaction.abort(); return request; }); } function abortTransactionOnDelete() { - const originalDelete = IDBObjectStore.prototype.delete; - jest.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function del(this: IDBObjectStore, ...args: Parameters) { - const request = originalDelete.apply(this, args); + const spy = jest.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function del(this: IDBObjectStore, ...args: Parameters) { + spy.mockRestore(); + const request = this.delete(...args); this.transaction.abort(); return request; }); @@ -263,7 +267,7 @@ describe('IDBKeyValProvider', () => { await IDBKeyValProvider.mergeItem(ONYXKEYS.TEST_KEY_3, { key: 'value_changed', property: { - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, newKey: 'newValue', }, }); @@ -328,7 +332,15 @@ describe('IDBKeyValProvider', () => { beforeEach(() => { Object.defineProperty(window.navigator, 'storage', { value: { - estimate: jest.fn().mockResolvedValue({quota: 750000, usage: 250000, usageDetails: {caches: 100000, fileSystem: 50000, indexedDB: 100000}}), + estimate: jest.fn().mockResolvedValue({ + quota: 750000, + usage: 250000, + usageDetails: { + caches: 100000, + fileSystem: 50000, + indexedDB: 100000, + }, + }), }, configurable: true, }); diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 13079d73d..4e81ccb70 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -145,7 +145,7 @@ describe('SQLiteProvider', () => { property: { nestedProperty: { nestedKey2: 'nestedValue2_changed', - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, }, newKey: 'newValue', }, @@ -232,7 +232,7 @@ describe('SQLiteProvider', () => { nested: { // The mark is filtered out by SQLiteProvider's `objectMarkRemover` // before the value is stringified. - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, newKey: 'newValue', }, }, @@ -266,7 +266,7 @@ describe('SQLiteProvider', () => { { key: 'value_changed', property: { - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, newKey: 'newValue', }, }, diff --git a/tests/unit/storage/providers/createStoreTest.ts b/tests/unit/storage/providers/createStoreTest.ts index c65f0f768..c6193bec0 100644 --- a/tests/unit/storage/providers/createStoreTest.ts +++ b/tests/unit/storage/providers/createStoreTest.ts @@ -16,11 +16,10 @@ function uniqueDBName() { */ async function captureDB(store: ReturnType): Promise { const captured: {db?: IDBDatabase} = {}; - const original = IDBDatabase.prototype.transaction; const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { captured.db = this; spy.mockRestore(); - return original.apply(this, args); + return this.transaction(...args); }); await store('readonly', (s) => IDB.promisifyRequest(s.getAllKeys())); return captured.db; @@ -48,14 +47,14 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount += 1; if (callCount === 1) { throw new DOMException('The database connection is closing.', 'InvalidStateError'); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); @@ -140,14 +139,14 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount += 1; if (callCount === 1) { throw new DOMException('The database connection is closing.', 'InvalidStateError'); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); await store('readwrite', (s) => { @@ -173,14 +172,14 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount += 1; if (callCount === 1) { throw new DOMException('The database connection is closing.', 'InvalidStateError'); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); await store('readwrite', (s) => { @@ -232,8 +231,10 @@ describe('createStore', () => { const store = createStore(dbName, STORE_NAME); const db = await captureDB(store); - expect(db).toBeDefined(); - const closeSpy = jest.spyOn(db!, 'close'); + if (!db) { + throw new Error('Expected db to be captured'); + } + const closeSpy = jest.spyOn(db, 'close'); // @ts-expect-error -- our handler ignores the event argument db?.onversionchange?.call(db, new Event('versionchange')); @@ -276,14 +277,14 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount++; if (callCount === 1) { throw backingStoreError(); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); @@ -377,8 +378,6 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; - // Drain budget to 1 remaining: fail twice, each heals successfully for (let i = 0; i < 2; i++) { let callCount = 0; @@ -388,7 +387,7 @@ describe('createStore', () => { throw backingStoreError(); } spy.mockRestore(); - return original.apply(this, args); + return this.transaction(...args); }); await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); } @@ -407,7 +406,7 @@ describe('createStore', () => { throw backingStoreError(); } spy.mockRestore(); - return original.apply(this, args); + return this.transaction(...args); }); const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); expect(result).toBe('value'); @@ -458,14 +457,14 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount++; if (callCount === 1) { throw connectionLostError(); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); @@ -511,14 +510,14 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount++; if (callCount === 1) { throw new DOMException('Connection is closing.', 'UnknownError'); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); @@ -534,7 +533,6 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; const backingStoreError = () => new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError'); // Connection-lost errors are transient and must NOT decrement the backing-store budget. @@ -549,7 +547,7 @@ describe('createStore', () => { throw connectionLostError(); } spy.mockRestore(); - return original.apply(this, args); + return this.transaction(...args); }); await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); } @@ -562,7 +560,7 @@ describe('createStore', () => { throw backingStoreError(); } backingSpy.mockRestore(); - return original.apply(this, args); + return this.transaction(...args); }); const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); expect(result).toBe('value'); @@ -582,14 +580,14 @@ describe('createStore', () => { return IDB.promisifyRequest(s.transaction); }); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount++; if (callCount === 1) { throw new DOMException('IDB write transaction aborted without an error', 'AbortError'); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); @@ -628,14 +626,14 @@ describe('createStore', () => { simulateVisibilityChange('hidden'); - const original = IDBDatabase.prototype.transaction; let probeIntercepted = false; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { if (!probeIntercepted) { probeIntercepted = true; throw new DOMException('Connection to Indexed Database server lost. Refresh the page to try again', 'UnknownError'); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); simulateVisibilityChange('visible'); @@ -698,14 +696,14 @@ describe('createStore', () => { simulateVisibilityChange('hidden'); - const original = IDBDatabase.prototype.transaction; let callCount = 0; - jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { callCount++; if (callCount === 1) { throw new DOMException('The database connection is closing.', 'InvalidStateError'); } - return original.apply(this, args); + spy.mockRestore(); + return this.transaction(...args); }); simulateVisibilityChange('visible'); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index c3407751a..b655bad4d 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -42,7 +42,9 @@ describe('useOnyx', () => { }); it('should not throw any errors when changing from a collection member key to another one', async () => { - const {rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); + const {rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + }); try { await act(async () => { @@ -54,7 +56,9 @@ describe('useOnyx', () => { }); it('should transition through loading when switching between collection member keys that both resolve to undefined', async () => { - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + }); // Wait for initial key to fully load await act(async () => waitForPromisesToResolve()); @@ -77,7 +81,9 @@ describe('useOnyx', () => { it('should return cached value immediately with loaded status when switching to a key that has data', async () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'test_value'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + }); await act(async () => waitForPromisesToResolve()); @@ -100,7 +106,9 @@ describe('useOnyx', () => { it('should clear previous data and transition through loading when switching from a key with data to one without', async () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, 'initial_value'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + }); await act(async () => waitForPromisesToResolve()); @@ -123,7 +131,9 @@ describe('useOnyx', () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, 'value_one'); Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'value_two'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + }); await act(async () => waitForPromisesToResolve()); @@ -140,8 +150,14 @@ describe('useOnyx', () => { }); it('should apply the selector against the new key data when switching keys', async () => { - Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, {id: 'entry1_id', name: 'entry1_name'}); - Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, {id: 'entry2_id', name: 'entry2_name'}); + Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, { + id: 'entry1_id', + name: 'entry1_name', + }); + Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, { + id: 'entry2_id', + name: 'entry2_name', + }); const selector = ((entry: OnyxEntry<{id: string; name: string}>) => entry?.name) as UseOnyxSelector; @@ -166,7 +182,9 @@ describe('useOnyx', () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'value_two'); Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}3`, 'value_three'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + }); await act(async () => waitForPromisesToResolve()); @@ -186,7 +204,9 @@ describe('useOnyx', () => { Onyx.set(ONYXKEYS.TEST_KEY, 'value_one'); Onyx.set(ONYXKEYS.TEST_KEY_2, 'value_two'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: ONYXKEYS.TEST_KEY}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: ONYXKEYS.TEST_KEY, + }); await act(async () => waitForPromisesToResolve()); @@ -209,7 +229,9 @@ describe('useOnyx', () => { Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, 'value_one'); Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}2`, 'value_two'); - const {result, rerender} = renderHook((key: string) => useOnyx(key), {initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`}); + const {result, rerender} = renderHook((key: string) => useOnyx(key), { + initialProps: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + }); await act(async () => waitForPromisesToResolve()); @@ -432,12 +454,22 @@ describe('useOnyx', () => { const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY)); await act(async () => waitForPromisesToResolve()); - const firstCollection = result.current[0] as OnyxCollection<{id: string; name: string}>; + const firstCollection = result.current[0] as OnyxCollection<{ + id: string; + name: string; + }>; const firstEntry1Ref = firstCollection?.[`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]; - await act(async () => Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, {name: 'entry2_updated'})); + await act(async () => + Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, { + name: 'entry2_updated', + }), + ); - const secondCollection = result.current[0] as OnyxCollection<{id: string; name: string}>; + const secondCollection = result.current[0] as OnyxCollection<{ + id: string; + name: string; + }>; expect(secondCollection).not.toBe(firstCollection); expect(secondCollection?.[`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]).toBe(firstEntry1Ref); @@ -445,7 +477,10 @@ describe('useOnyx', () => { it('should keep the same collection reference when no members change', async () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY)); @@ -453,7 +488,12 @@ describe('useOnyx', () => { const firstResult = result.current; - await act(async () => Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, {id: 'entry1_id', name: 'entry1_name'})); + await act(async () => + Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, { + id: 'entry1_id', + name: 'entry1_name', + }), + ); expect(result.current).toBe(firstResult); }); @@ -472,7 +512,12 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('id - test_id, name - test_name'); expect(result.current[1].status).toEqual('loaded'); - await act(async () => Onyx.merge(ONYXKEYS.TEST_KEY, {id: 'changed_id', name: 'changed_name'})); + await act(async () => + Onyx.merge(ONYXKEYS.TEST_KEY, { + id: 'changed_id', + name: 'changed_name', + }), + ); expect(result.current[0]).toEqual('id - changed_id, name - changed_name'); expect(result.current[1].status).toEqual('loaded'); @@ -480,9 +525,18 @@ describe('useOnyx', () => { it('should return selected data from a collection key', async () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: { + id: 'entry3_id', + name: 'entry3_name', + }, }); const {result} = renderHook(() => @@ -526,7 +580,9 @@ describe('useOnyx', () => { // object const {result: objectResult} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { - selector: ((entry: OnyxEntry<{id: string; name: string}>) => ({id: entry?.id})) as UseOnyxSelector, + selector: ((entry: OnyxEntry<{id: string; name: string}>) => ({ + id: entry?.id, + })) as UseOnyxSelector, }), ); @@ -553,14 +609,25 @@ describe('useOnyx', () => { it('should not change selected collection data if a property outside that data was changed', async () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: { + id: 'entry3_id', + name: 'entry3_name', + }, }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY, { - selector: ((entry: OnyxEntry<{id: string; name: string}>) => ({id: entry?.id})) as UseOnyxSelector, + selector: ((entry: OnyxEntry<{id: string; name: string}>) => ({ + id: entry?.id, + })) as UseOnyxSelector, }), ); @@ -568,7 +635,11 @@ describe('useOnyx', () => { const oldResult = result.current; - await act(async () => Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, {name: 'entry2_changed'})); + await act(async () => + Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, { + name: 'entry2_changed', + }), + ); // must be the same reference expect(oldResult).toBe(result.current); @@ -611,7 +682,11 @@ describe('useOnyx', () => { }); it('should memoize selector output and return same reference when input unchanged', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name', count: 1}); + Onyx.set(ONYXKEYS.TEST_KEY, { + id: 'test_id', + name: 'test_name', + count: 1, + }); const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { @@ -654,7 +729,10 @@ describe('useOnyx', () => { // Should return a new reference since data changed expect(result.current[0]).not.toBe(firstResult); - expect(result.current[0]).toEqual({id: 'changed_id', name: 'test_name'}); + expect(result.current[0]).toEqual({ + id: 'changed_id', + name: 'test_name', + }); }); it('should memoize selector output using deep equality check', async () => { @@ -690,7 +768,7 @@ describe('useOnyx', () => { const {result} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { - selector: ((entry: OnyxEntry<{count: number; name: string}>) => entry?.count || 0) as UseOnyxSelector, + selector: ((entry: OnyxEntry<{count: number; name: string}>) => entry?.count ?? 0) as UseOnyxSelector, }), ); @@ -781,10 +859,26 @@ describe('useOnyx', () => { it('should handle complex dependency scenarios with multiple values', async () => { type TestItem = {id: string; category: string; priority: number}; const testData = { - [`${ONYXKEYS.COLLECTION.TEST_KEY}item1`]: {id: 'item1', category: 'A', priority: 1}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}item2`]: {id: 'item2', category: 'B', priority: 2}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}item3`]: {id: 'item3', category: 'A', priority: 3}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}item4`]: {id: 'item4', category: 'B', priority: 4}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}item1`]: { + id: 'item1', + category: 'A', + priority: 1, + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}item2`]: { + id: 'item2', + category: 'B', + priority: 2, + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}item3`]: { + id: 'item3', + category: 'A', + priority: 3, + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}item4`]: { + id: 'item4', + category: 'B', + priority: 4, + }, }; await act(async () => Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, testData)); @@ -972,9 +1066,18 @@ describe('useOnyx', () => { describe('dependencies', () => { it('should return the updated selected value when a external value passed to the dependencies list changes', async () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: {id: 'entry3_id', name: 'entry3_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: { + id: 'entry3_id', + name: 'entry3_name', + }, }); let externalValue = 'ex1'; @@ -1020,9 +1123,18 @@ describe('useOnyx', () => { describe('skippable collection member ids', () => { it('should always return undefined entry when subscribing to a collection with skippable member ids', async () => { Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name', + }, }); const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY)); @@ -1030,22 +1142,43 @@ describe('useOnyx', () => { await act(async () => waitForPromisesToResolve()); expect(result.current[0]).toEqual({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, }); expect(result.current[1].status).toEqual('loaded'); await act(async () => Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name_changed'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name_changed'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: {id: 'skippable-id_id', name: 'skippable-id_name_changed'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name_changed', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name_changed', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name_changed', + }, }), ); expect(result.current[0]).toEqual({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: {id: 'entry1_id', name: 'entry1_name_changed'}, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: {id: 'entry2_id', name: 'entry2_name_changed'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name_changed', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name_changed', + }, }); expect(result.current[1].status).toEqual('loaded'); }); @@ -1136,7 +1269,11 @@ describe('useOnyx', () => { expect(result.current[0]).toBeUndefined(); expect(result.current[1].status).toEqual('loaded'); - await act(async () => Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry1`, {id: 'fresh'})); + await act(async () => + Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}entry1`, { + id: 'fresh', + }), + ); expect(result.current[0]).toEqual({id: 'fresh'}); expect(result.current[1].status).toEqual('loaded'); diff --git a/tests/unit/utilsTest.ts b/tests/unit/utilsTest.ts index c7f11a67d..4a1a7a598 100644 --- a/tests/unit/utilsTest.ts +++ b/tests/unit/utilsTest.ts @@ -146,7 +146,7 @@ describe('utils', () => { b: { d: { h: 'h', - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, }, h: 'h', }, @@ -161,7 +161,7 @@ describe('utils', () => { b: { d: { h: 'h', - [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, }, h: 'h', }, diff --git a/tests/utils/collections/reportActions.ts b/tests/utils/collections/reportActions.ts index 240fa654a..db1719cd2 100644 --- a/tests/utils/collections/reportActions.ts +++ b/tests/utils/collections/reportActions.ts @@ -14,7 +14,7 @@ const getRandomDate = (): string => { const getRandomReportActions = (collection: string, length = 10000) => createCollection>( - (item) => `${collection}${item.reportActionID}`, + (item) => `${collection}${String(item.reportActionID)}`, (index) => createRandomReportAction(index), length, ); From b01ee0896bd4750b0692a33f39600bc42c89a1af Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 1 Jul 2026 14:15:41 -0700 Subject: [PATCH 11/20] Fix TypeScript build errors blocking npm ci Restore variadic noop stubs for InstanceSync, correct the circuit breaker halfOpen state check, guard indexed key lookups, and add lodash.bindall type declarations so prepare/build succeeds in CI. Co-authored-by: Cursor --- lib/OnyxUtils.ts | 5 ++++- lib/StorageCircuitBreaker.ts | 2 +- lib/lodash.bindall.d.ts | 7 +++++++ lib/storage/InstanceSync/index.ts | 2 +- lib/storage/providers/IDBKeyValProvider/index.ts | 2 +- lib/utils.ts | 2 +- 6 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 lib/lodash.bindall.d.ts diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 70ca62a79..ce56e01e0 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -392,7 +392,10 @@ function multiGet( // Wait for all the pending tasks to resolve and then add the data to the data map. .then((values) => { for (const [index, value] of values.entries()) { - dataMap.set(pendingKeys.at(index), value); + const pendingKey = pendingKeys[index]; + if (pendingKey !== undefined) { + dataMap.set(pendingKey, value); + } } return Promise.resolve(); diff --git a/lib/StorageCircuitBreaker.ts b/lib/StorageCircuitBreaker.ts index b8cfef0c3..bb4a19b51 100644 --- a/lib/StorageCircuitBreaker.ts +++ b/lib/StorageCircuitBreaker.ts @@ -68,7 +68,7 @@ class StorageCircuitBreaker extends AbstractCircuitBreaker { // failure that triggered it must not re-trip the circuit. Let it proceed; the probe's outcome // is the verdict — recordWriteSuccess (retry landed) closes, recordProbeFailure (retry failed, // re-entering retryOperation) reopens. - if (this.peekState() === 'half-open') { + if (this.peekState() === 'halfOpen') { return false; } return this.recordFailure(); diff --git a/lib/lodash.bindall.d.ts b/lib/lodash.bindall.d.ts new file mode 100644 index 000000000..14126e85d --- /dev/null +++ b/lib/lodash.bindall.d.ts @@ -0,0 +1,7 @@ +declare module "lodash.bindall" { + function bindAll( + object: T, + ...methodNames: Array + ): T; + export default bindAll; +} diff --git a/lib/storage/InstanceSync/index.ts b/lib/storage/InstanceSync/index.ts index 892d21d50..f8a5be162 100644 --- a/lib/storage/InstanceSync/index.ts +++ b/lib/storage/InstanceSync/index.ts @@ -1,4 +1,4 @@ -const NOOP = () => {}; +const NOOP = (..._args: unknown[]) => {}; /** * This is used to keep multiple browser tabs in sync, therefore only needed on web diff --git a/lib/storage/providers/IDBKeyValProvider/index.ts b/lib/storage/providers/IDBKeyValProvider/index.ts index fcbcee52b..baaaecb66 100644 --- a/lib/storage/providers/IDBKeyValProvider/index.ts +++ b/lib/storage/providers/IDBKeyValProvider/index.ts @@ -68,7 +68,7 @@ const provider: StorageProvider = { throw new Error('Store not initialized!'); } - return IDB.getMany(keysParam, provider.store).then((values) => values.map((value, index) => [keysParam.at(index), value])); + return IDB.getMany(keysParam, provider.store).then((values) => values.map((value, index) => [keysParam[index], value])); }, multiMerge(pairs) { if (!provider.store) { diff --git a/lib/utils.ts b/lib/utils.ts index 33177e102..2052cb495 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -82,7 +82,7 @@ function fastMerge(target: TValue, source: TValue, options?: FastMergeOp * @returns - The merged object. */ function mergeObject>( - target: TObject | null | undefined, + target: TObject | unknown | null | undefined, source: TObject, options: FastMergeOptions, metadata: FastMergeMetadata, From 2cb18e1047ca39b669586fe4c0d0682a86c4944a Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 1 Jul 2026 14:22:29 -0700 Subject: [PATCH 12/20] Fix remaining CI failures after eslint-config-expensify 4 upgrade Remove stale seatbelt entries for dropped no-unsafe-argument rule, restore CustomTypeOptions as an augmentable interface for test:types, fix private property access and collection typing in tests, and restore IDB abort mocks so write-error normalization tests propagate DOMException. Co-authored-by: Cursor --- eslint.seatbelt.tsv | 3 - lib/OnyxConnectionManager.ts | 472 +- lib/OnyxUtils.ts | 3162 ++++---- lib/lodash.bindall.d.ts | 9 +- lib/types.ts | 423 +- tests/unit/DevToolsTest.ts | 16 +- tests/unit/OnyxConnectionManagerTest.ts | 1028 ++- tests/unit/collectionHydrationTest.ts | 200 +- tests/unit/onyxTest.ts | 7118 ++++++++--------- tests/unit/onyxUtilsTest.ts | 3617 ++++----- .../providers/IDBKeyvalProviderTest.ts | 12 +- 11 files changed, 7589 insertions(+), 8471 deletions(-) diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv index e2e925e6e..781ef06a2 100644 --- a/eslint.seatbelt.tsv +++ b/eslint.seatbelt.tsv @@ -12,13 +12,11 @@ "lib/OnyxMerge/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "lib/OnyxMerge/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-argument" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-assignment" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-call" 2 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-member-access" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 32 "lib/Str.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"lib/storage/__mocks__/index.ts" "@typescript-eslint/no-unsafe-argument" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-return" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "lib/storage/providers/MemoryOnlyProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 @@ -43,7 +41,6 @@ "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-return" 1 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "tests/unit/OnyxSnapshotCacheTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 -"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-argument" 2 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-assignment" 6 "tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-member-access" 9 "tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 diff --git a/lib/OnyxConnectionManager.ts b/lib/OnyxConnectionManager.ts index 600e902fe..1eae1cdc5 100644 --- a/lib/OnyxConnectionManager.ts +++ b/lib/OnyxConnectionManager.ts @@ -1,304 +1,262 @@ -import bindAll from "lodash.bindall"; -import * as Logger from "./Logger"; -import type { ConnectOptions } from "./Onyx"; -import OnyxUtils from "./OnyxUtils"; -import OnyxKeys from "./OnyxKeys"; -import * as Str from "./Str"; -import type { - CollectionConnectCallback, - DefaultConnectCallback, - OnyxKey, - OnyxValue, -} from "./types"; -import onyxSnapshotCache from "./OnyxSnapshotCache"; - -type ConnectCallback = - | DefaultConnectCallback - | CollectionConnectCallback; +import bindAll from 'lodash.bindall'; +import * as Logger from './Logger'; +import type {ConnectOptions} from './Onyx'; +import OnyxUtils from './OnyxUtils'; +import OnyxKeys from './OnyxKeys'; +import * as Str from './Str'; +import type {CollectionConnectCallback, DefaultConnectCallback, OnyxKey, OnyxValue} from './types'; +import onyxSnapshotCache from './OnyxSnapshotCache'; + +type ConnectCallback = DefaultConnectCallback | CollectionConnectCallback; /** * Represents the connection's metadata that contains the necessary properties * to handle that connection. */ type ConnectionMetadata = { - /** - * The subscription ID returned by `OnyxUtils.subscribeToKey()` that is associated to this connection. - */ - subscriptionID: number; - - /** - * The Onyx key associated to this connection. - */ - onyxKey: OnyxKey; - - /** - * Whether the first connection's callback was fired or not. - */ - isConnectionMade: boolean; - - /** - * A map of the subscriber's callbacks associated to this connection. - */ - callbacks: Map; - - /** - * The last callback value returned by `OnyxUtils.subscribeToKey()`'s callback. - */ - cachedCallbackValue?: OnyxValue; - - /** - * The last callback key returned by `OnyxUtils.subscribeToKey()`'s callback. - */ - cachedCallbackKey?: OnyxKey; + /** + * The subscription ID returned by `OnyxUtils.subscribeToKey()` that is associated to this connection. + */ + subscriptionID: number; + + /** + * The Onyx key associated to this connection. + */ + onyxKey: OnyxKey; + + /** + * Whether the first connection's callback was fired or not. + */ + isConnectionMade: boolean; + + /** + * A map of the subscriber's callbacks associated to this connection. + */ + callbacks: Map; + + /** + * The last callback value returned by `OnyxUtils.subscribeToKey()`'s callback. + */ + cachedCallbackValue?: OnyxValue; + + /** + * The last callback key returned by `OnyxUtils.subscribeToKey()`'s callback. + */ + cachedCallbackKey?: OnyxKey; }; /** * Represents the connection object returned by `Onyx.connect()`. */ type Connection = { - /** - * The ID used to identify this particular connection. - */ - id: string; - - /** - * The ID of the subscriber's callback that is associated to this connection. - */ - callbackID: string; + /** + * The ID used to identify this particular connection. + */ + id: string; + + /** + * The ID of the subscriber's callback that is associated to this connection. + */ + callbackID: string; }; /** * Manages Onyx connections of `Onyx.connect()` and `useOnyx()` subscribers. */ class OnyxConnectionManager { - /** - * A map where the key is the connection ID generated inside `connect()` and the value is the metadata of that connection. - */ - private connectionsMap: Map; - - /** - * Stores the last generated callback ID which will be incremented when making a new connection. - */ - private lastCallbackID: number; - - /** - * Stores the last generated session ID for the connection manager. The current session ID - * is appended to the connection IDs and it's used to create new different connections for the same key - * when `refreshSessionID()` is called. - * - * When calling `Onyx.clear()` after a logout operation some connections might remain active as they - * aren't tied to the React's lifecycle e.g. `Onyx.connect()` usage, causing infinite loading state issues to new `useOnyx()` subscribers - * that are connecting to the same key as we didn't populate the cache again because we are still reusing such connections. - * - * To elimitate this problem, the session ID must be refreshed during the `Onyx.clear()` call (by using `refreshSessionID()`) - * in order to create fresh connections when new subscribers connect to the same keys again, allowing them - * to use the cache system correctly and avoid the mentioned issues in `useOnyx()`. - */ - private sessionID: string; - - constructor() { - this.connectionsMap = new Map(); - this.lastCallbackID = 0; - this.sessionID = Str.guid(); - - // Binds all public methods to prevent problems with `this`. - bindAll( - this, - "generateConnectionID", - "fireCallbacks", - "connect", - "disconnect", - "disconnectAll", - "refreshSessionID", - ); - } - - /** - * Generates a connection ID based on the `connectOptions` object passed to the function. - * - * The properties used to generate the ID are handpicked for performance reasons and - * according to their purpose and effect they produce in the Onyx connection. - */ - private generateConnectionID( - connectOptions: ConnectOptions, - ): string { - const { key, reuseConnection } = connectOptions; - - // The current session ID is appended to the connection ID so we can have different connections - // after an `Onyx.clear()` operation. - let suffix = `,sessionID=${this.sessionID}`; - - // We will generate a unique ID when `reuseConnection` is `false`, which means the subscriber - // explicitly wants the connection to not be reused. Collection-root subscriptions are now always - // snapshot mode, so they can be reused like any other connection. - if (reuseConnection === false) { - suffix += `,uniqueID=${Str.guid()}`; + /** + * A map where the key is the connection ID generated inside `connect()` and the value is the metadata of that connection. + */ + private connectionsMap: Map; + + /** + * Stores the last generated callback ID which will be incremented when making a new connection. + */ + private lastCallbackID: number; + + /** + * Stores the last generated session ID for the connection manager. The current session ID + * is appended to the connection IDs and it's used to create new different connections for the same key + * when `refreshSessionID()` is called. + * + * When calling `Onyx.clear()` after a logout operation some connections might remain active as they + * aren't tied to the React's lifecycle e.g. `Onyx.connect()` usage, causing infinite loading state issues to new `useOnyx()` subscribers + * that are connecting to the same key as we didn't populate the cache again because we are still reusing such connections. + * + * To elimitate this problem, the session ID must be refreshed during the `Onyx.clear()` call (by using `refreshSessionID()`) + * in order to create fresh connections when new subscribers connect to the same keys again, allowing them + * to use the cache system correctly and avoid the mentioned issues in `useOnyx()`. + */ + private sessionID: string; + + constructor() { + this.connectionsMap = new Map(); + this.lastCallbackID = 0; + this.sessionID = Str.guid(); + + // Binds all public methods to prevent problems with `this`. + bindAll(this, 'generateConnectionID', 'fireCallbacks', 'connect', 'disconnect', 'disconnectAll', 'refreshSessionID'); } - return `onyxKey=${key}${suffix}`; - } + /** + * Generates a connection ID based on the `connectOptions` object passed to the function. + * + * The properties used to generate the ID are handpicked for performance reasons and + * according to their purpose and effect they produce in the Onyx connection. + */ + private generateConnectionID(connectOptions: ConnectOptions): string { + const {key, reuseConnection} = connectOptions; + + // The current session ID is appended to the connection ID so we can have different connections + // after an `Onyx.clear()` operation. + let suffix = `,sessionID=${this.sessionID}`; + + // We will generate a unique ID when `reuseConnection` is `false`, which means the subscriber + // explicitly wants the connection to not be reused. Collection-root subscriptions are now always + // snapshot mode, so they can be reused like any other connection. + if (reuseConnection === false) { + suffix += `,uniqueID=${Str.guid()}`; + } - /** - * Fires all the subscribers callbacks associated with that connection ID. - */ - private fireCallbacks(connectionID: string): void { - const connection = this.connectionsMap.get(connectionID); - if (!connection) { - return; + return `onyxKey=${key}${suffix}`; } - for (const callback of connection.callbacks.values()) { - try { - if (OnyxKeys.isCollectionKey(connection.onyxKey)) { - (callback as CollectionConnectCallback)( - connection.cachedCallbackValue as Record, - connection.cachedCallbackKey as OnyxKey, - ); - } else { - (callback as DefaultConnectCallback)( - connection.cachedCallbackValue, - connection.cachedCallbackKey as OnyxKey, - ); + /** + * Fires all the subscribers callbacks associated with that connection ID. + */ + private fireCallbacks(connectionID: string): void { + const connection = this.connectionsMap.get(connectionID); + if (!connection) { + return; + } + + for (const callback of connection.callbacks.values()) { + try { + if (OnyxKeys.isCollectionKey(connection.onyxKey)) { + (callback as CollectionConnectCallback)(connection.cachedCallbackValue as Record, connection.cachedCallbackKey as OnyxKey); + } else { + (callback as DefaultConnectCallback)(connection.cachedCallbackValue, connection.cachedCallbackKey as OnyxKey); + } + } catch (error) { + Logger.logAlert(`[ConnectionManager] Subscriber callback threw an error for key '${connection.onyxKey}': ${error}`); + } } - } catch (error) { - Logger.logAlert( - `[ConnectionManager] Subscriber callback threw an error for key '${connection.onyxKey}': ${error}`, - ); - } } - } - - /** - * Connects to an Onyx key given the options passed and listens to its changes. - * - * @param connectOptions The options object that will define the behavior of the connection. - * @returns The connection object to use when calling `disconnect()`. - */ - connect( - connectOptions: ConnectOptions, - ): Connection { - const connectionID = this.generateConnectionID(connectOptions); - let connectionMetadata = this.connectionsMap.get(connectionID); - let subscriptionID: number | undefined; - - const callbackID = String(this.lastCallbackID++); - - // If there is no connection yet for that connection ID, we create a new one. - if (!connectionMetadata) { - const callback: ConnectCallback = ( - value: OnyxValue, - key: OnyxKey, - ) => { - const createdConnection = this.connectionsMap.get(connectionID); - if (createdConnection) { - // We signal that the first connection was made and now any new subscribers - // can fire their callbacks immediately with the cached value when connecting. - createdConnection.isConnectionMade = true; - createdConnection.cachedCallbackValue = value; - createdConnection.cachedCallbackKey = key; - this.fireCallbacks(connectionID); + + /** + * Connects to an Onyx key given the options passed and listens to its changes. + * + * @param connectOptions The options object that will define the behavior of the connection. + * @returns The connection object to use when calling `disconnect()`. + */ + connect(connectOptions: ConnectOptions): Connection { + const connectionID = this.generateConnectionID(connectOptions); + let connectionMetadata = this.connectionsMap.get(connectionID); + let subscriptionID: number | undefined; + + const callbackID = String(this.lastCallbackID++); + + // If there is no connection yet for that connection ID, we create a new one. + if (!connectionMetadata) { + const callback: ConnectCallback = (value: OnyxValue, key: OnyxKey) => { + const createdConnection = this.connectionsMap.get(connectionID); + if (createdConnection) { + // We signal that the first connection was made and now any new subscribers + // can fire their callbacks immediately with the cached value when connecting. + createdConnection.isConnectionMade = true; + createdConnection.cachedCallbackValue = value; + createdConnection.cachedCallbackKey = key; + this.fireCallbacks(connectionID); + } + }; + + subscriptionID = OnyxUtils.subscribeToKey({ + ...connectOptions, + callback, + } as ConnectOptions); + + connectionMetadata = { + subscriptionID, + onyxKey: connectOptions.key, + isConnectionMade: false, + callbacks: new Map(), + }; + + this.connectionsMap.set(connectionID, connectionMetadata); } - }; - subscriptionID = OnyxUtils.subscribeToKey({ - ...connectOptions, - callback, - } as ConnectOptions); + // We add the subscriber's callback to the list of callbacks associated with this connection. + if (connectOptions.callback) { + connectionMetadata.callbacks.set(callbackID, connectOptions.callback as ConnectCallback); + } - connectionMetadata = { - subscriptionID, - onyxKey: connectOptions.key, - isConnectionMade: false, - callbacks: new Map(), - }; + // If the first connection is already made we want any new subscribers to receive the cached callback value immediately. + if (connectionMetadata.isConnectionMade) { + // Defer the callback execution to the next tick of the event loop. + // This ensures that the current execution flow completes and the result connection object is available when the callback fires. + Promise.resolve().then(() => { + (connectOptions.callback as DefaultConnectCallback | undefined)?.(connectionMetadata.cachedCallbackValue, connectionMetadata.cachedCallbackKey as OnyxKey); + }); + } - this.connectionsMap.set(connectionID, connectionMetadata); + return {id: connectionID, callbackID}; } - // We add the subscriber's callback to the list of callbacks associated with this connection. - if (connectOptions.callback) { - connectionMetadata.callbacks.set( - callbackID, - connectOptions.callback as ConnectCallback, - ); - } + /** + * Disconnects and removes the listener from the Onyx key. + * + * @param connection Connection object returned by calling `connect()`. + */ + disconnect(connection: Connection): void { + if (!connection) { + Logger.logInfo(`[ConnectionManager] Attempted to disconnect passing an undefined connection object.`); + return; + } - // If the first connection is already made we want any new subscribers to receive the cached callback value immediately. - if (connectionMetadata.isConnectionMade) { - // Defer the callback execution to the next tick of the event loop. - // This ensures that the current execution flow completes and the result connection object is available when the callback fires. - Promise.resolve().then(() => { - ( - connectOptions.callback as DefaultConnectCallback | undefined - )?.( - connectionMetadata.cachedCallbackValue, - connectionMetadata.cachedCallbackKey as OnyxKey, - ); - }); - } + const connectionMetadata = this.connectionsMap.get(connection.id); + if (!connectionMetadata) { + Logger.logInfo(`[ConnectionManager] Attempted to disconnect but no connection was found.`); + return; + } - return { id: connectionID, callbackID }; - } - - /** - * Disconnects and removes the listener from the Onyx key. - * - * @param connection Connection object returned by calling `connect()`. - */ - disconnect(connection: Connection): void { - if (!connection) { - Logger.logInfo( - `[ConnectionManager] Attempted to disconnect passing an undefined connection object.`, - ); - return; - } + // Removes the callback from the connection's callbacks map. + connectionMetadata.callbacks.delete(connection.callbackID); + + // If the connection's callbacks map is empty we can safely unsubscribe from the Onyx key. + if (connectionMetadata.callbacks.size === 0) { + OnyxUtils.unsubscribeFromKey(connectionMetadata.subscriptionID); - const connectionMetadata = this.connectionsMap.get(connection.id); - if (!connectionMetadata) { - Logger.logInfo( - `[ConnectionManager] Attempted to disconnect but no connection was found.`, - ); - return; + this.connectionsMap.delete(connection.id); + } } - // Removes the callback from the connection's callbacks map. - connectionMetadata.callbacks.delete(connection.callbackID); + /** + * Disconnect all subscribers from Onyx. + */ + disconnectAll(): void { + for (const connectionMetadata of this.connectionsMap.values()) { + OnyxUtils.unsubscribeFromKey(connectionMetadata.subscriptionID); + } - // If the connection's callbacks map is empty we can safely unsubscribe from the Onyx key. - if (connectionMetadata.callbacks.size === 0) { - OnyxUtils.unsubscribeFromKey(connectionMetadata.subscriptionID); + this.connectionsMap.clear(); - this.connectionsMap.delete(connection.id); + // Clear snapshot cache when all connections are disconnected + onyxSnapshotCache.clear(); } - } - - /** - * Disconnect all subscribers from Onyx. - */ - disconnectAll(): void { - for (const connectionMetadata of this.connectionsMap.values()) { - OnyxUtils.unsubscribeFromKey(connectionMetadata.subscriptionID); - } - - this.connectionsMap.clear(); - // Clear snapshot cache when all connections are disconnected - onyxSnapshotCache.clear(); - } + /** + * Refreshes the connection manager's session ID. + */ + refreshSessionID(): void { + this.sessionID = Str.guid(); - /** - * Refreshes the connection manager's session ID. - */ - refreshSessionID(): void { - this.sessionID = Str.guid(); - - // Clear snapshot cache when session refreshes to avoid stale cache issues - onyxSnapshotCache.clear(); - } + // Clear snapshot cache when session refreshes to avoid stale cache issues + onyxSnapshotCache.clear(); + } } const connectionManager = new OnyxConnectionManager(); export default connectionManager; -export type { Connection }; +export type {Connection}; diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index ce56e01e0..e855e0d46 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1,52 +1,52 @@ -import { shallowEqual } from "fast-equals"; -import type { ValueOf } from "type-fest"; -import _ from "underscore"; -import DevTools from "./DevTools"; -import * as Logger from "./Logger"; -import type Onyx from "./Onyx"; -import cache, { TASK } from "./OnyxCache"; -import OnyxKeys from "./OnyxKeys"; -import StorageCircuitBreaker from "./StorageCircuitBreaker"; -import Storage from "./storage"; -import { StorageErrorClass } from "./storage/errors"; +import {shallowEqual} from 'fast-equals'; +import type {ValueOf} from 'type-fest'; +import _ from 'underscore'; +import DevTools from './DevTools'; +import * as Logger from './Logger'; +import type Onyx from './Onyx'; +import cache, {TASK} from './OnyxCache'; +import OnyxKeys from './OnyxKeys'; +import StorageCircuitBreaker from './StorageCircuitBreaker'; +import Storage from './storage'; +import {StorageErrorClass} from './storage/errors'; import type { - CollectionKeyBase, - ConnectOptions, - DeepRecord, - DefaultConnectCallback, - KeyValueMapping, - CallbackToStateMapping, - MultiMergeReplaceNullPatches, - OnyxCollection, - OnyxEntry, - OnyxInput, - OnyxInputKeyValueMapping, - OnyxKey, - OnyxMergeCollectionInput, - OnyxUpdate, - OnyxValue, - Selector, - MergeCollectionWithPatchesParams, - SetCollectionParams, - SetParams, - OnyxMultiSetInput, - RetriableOnyxOperation, -} from "./types"; -import type { FastMergeOptions, FastMergeResult } from "./utils"; -import utils from "./utils"; -import type { DeferredTask } from "./createDeferredTask"; -import createDeferredTask from "./createDeferredTask"; -import type { StorageKeyValuePair } from "./storage/providers/types"; -import logMessages from "./logMessages"; + CollectionKeyBase, + ConnectOptions, + DeepRecord, + DefaultConnectCallback, + KeyValueMapping, + CallbackToStateMapping, + MultiMergeReplaceNullPatches, + OnyxCollection, + OnyxEntry, + OnyxInput, + OnyxInputKeyValueMapping, + OnyxKey, + OnyxMergeCollectionInput, + OnyxUpdate, + OnyxValue, + Selector, + MergeCollectionWithPatchesParams, + SetCollectionParams, + SetParams, + OnyxMultiSetInput, + RetriableOnyxOperation, +} from './types'; +import type {FastMergeOptions, FastMergeResult} from './utils'; +import utils from './utils'; +import type {DeferredTask} from './createDeferredTask'; +import createDeferredTask from './createDeferredTask'; +import type {StorageKeyValuePair} from './storage/providers/types'; +import logMessages from './logMessages'; // Method constants const METHOD = { - SET: "set", - MERGE: "merge", - MERGE_COLLECTION: "mergecollection", - SET_COLLECTION: "setcollection", - MULTI_SET: "multiset", - CLEAR: "clear", + SET: 'set', + MERGE: 'merge', + MERGE_COLLECTION: 'mergecollection', + SET_COLLECTION: 'setcollection', + MULTI_SET: 'multiset', + CLEAR: 'clear', } as const; // Max number of retries for failed storage operations @@ -55,7 +55,7 @@ const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5; type OnyxMethod = ValueOf; function formatCaughtError(error: unknown): string { - return error instanceof Error ? error.message : String(error); + return error instanceof Error ? error.message : String(error); } // Key/value store of Onyx key and arrays of values to merge @@ -63,10 +63,7 @@ let mergeQueue: Record>> = {}; let mergeQueuePromise: Record> = {}; // Holds a mapping of all the React components that want their state subscribed to a store key -let callbackToStateMapping: Record< - string, - CallbackToStateMapping -> = {}; +let callbackToStateMapping: Record> = {}; // Holds a mapping of the connected key to the subscriptionID for faster lookups let onyxKeyToSubscriptionIDs = new Map(); @@ -75,10 +72,7 @@ let onyxKeyToSubscriptionIDs = new Map(); let defaultKeyStates: Record> = {}; // Used for comparison with a new update to avoid invoking the Onyx.connect callback with the same data. -let lastConnectionCallbackData = new Map< - number, - { value: OnyxValue; matchedKey: OnyxKey | undefined } ->(); +let lastConnectionCallbackData = new Map; matchedKey: OnyxKey | undefined}>(); let snapshotKey: OnyxKey | null = null; @@ -96,35 +90,35 @@ let skippableCollectionMemberIDs = new Set(); let snapshotMergeKeys = new Set(); function getSnapshotKey(): OnyxKey | null { - return snapshotKey; + return snapshotKey; } /** * Getter - returns the merge queue. */ function getMergeQueue(): Record>> { - return mergeQueue; + return mergeQueue; } /** * Getter - returns the merge queue promise. */ function getMergeQueuePromise(): Record> { - return mergeQueuePromise; + return mergeQueuePromise; } /** * Getter - returns the default key states. */ function getDefaultKeyStates(): Record> { - return defaultKeyStates; + return defaultKeyStates; } /** * Getter - returns the deffered init task. */ function getDeferredInitTask(): DeferredTask { - return deferredInitTask; + return deferredInitTask; } /** @@ -136,38 +130,38 @@ function getDeferredInitTask(): DeferredTask { * @returns The result of the action */ function afterInit(action: () => Promise): Promise { - if (deferredInitTask.isResolved) { - return action(); - } - return deferredInitTask.promise.then(action); + if (deferredInitTask.isResolved) { + return action(); + } + return deferredInitTask.promise.then(action); } /** * Getter - returns the skippable collection member IDs. */ function getSkippableCollectionMemberIDs(): Set { - return skippableCollectionMemberIDs; + return skippableCollectionMemberIDs; } /** * Getter - returns the snapshot merge keys allowlist. */ function getSnapshotMergeKeys(): Set { - return snapshotMergeKeys; + return snapshotMergeKeys; } /** * Setter - sets the skippable collection member IDs. */ function setSkippableCollectionMemberIDs(ids: Set): void { - skippableCollectionMemberIDs = ids; + skippableCollectionMemberIDs = ids; } /** * Setter - sets the snapshot merge keys allowlist. */ function setSnapshotMergeKeys(keys: Set): void { - snapshotMergeKeys = keys; + snapshotMergeKeys = keys; } /** @@ -177,36 +171,29 @@ function setSnapshotMergeKeys(keys: Set): void { * @param initialKeyStates - initial data to set when `init()` and `clear()` are called * @param evictableKeys - This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged as "safe" for removal. */ -function initStoreValues( - keys: DeepRecord, - initialKeyStates: Partial, - evictableKeys: OnyxKey[], -): void { - // We need the value of the collection keys later for checking if a - // key is a collection. We store it in a map for faster lookup. - const collectionValues = Object.values(keys.COLLECTION ?? {}) as string[]; - const collectionKeySet = collectionValues.reduce((acc, val) => { - acc.add(val); - return acc; - }, new Set()); - - // Set our default key states to use when initializing and clearing Onyx data - defaultKeyStates = initialKeyStates; - - DevTools.initState(initialKeyStates); - - // Let Onyx know about which keys are safe to evict - cache.setEvictionAllowList(evictableKeys); - - // Set collection keys in cache for optimized storage - cache.setCollectionKeys(collectionKeySet); - - if ( - typeof keys.COLLECTION === "object" && - typeof keys.COLLECTION.SNAPSHOT === "string" - ) { - snapshotKey = keys.COLLECTION.SNAPSHOT; - } +function initStoreValues(keys: DeepRecord, initialKeyStates: Partial, evictableKeys: OnyxKey[]): void { + // We need the value of the collection keys later for checking if a + // key is a collection. We store it in a map for faster lookup. + const collectionValues = Object.values(keys.COLLECTION ?? {}) as string[]; + const collectionKeySet = collectionValues.reduce((acc, val) => { + acc.add(val); + return acc; + }, new Set()); + + // Set our default key states to use when initializing and clearing Onyx data + defaultKeyStates = initialKeyStates; + + DevTools.initState(initialKeyStates); + + // Let Onyx know about which keys are safe to evict + cache.setEvictionAllowList(evictableKeys); + + // Set collection keys in cache for optimized storage + cache.setCollectionKeys(collectionKeySet); + + if (typeof keys.COLLECTION === 'object' && typeof keys.COLLECTION.SNAPSHOT === 'string') { + snapshotKey = keys.COLLECTION.SNAPSHOT; + } } /** @@ -218,38 +205,19 @@ function initStoreValues( * @param mergedValue - (optional) value that was written in the storage after a merge method was executed. */ function sendActionToDevTools( - method: - | typeof METHOD.MERGE_COLLECTION - | typeof METHOD.MULTI_SET - | typeof METHOD.SET_COLLECTION, - key: undefined, - value: OnyxCollection, - mergedValue?: undefined, + method: typeof METHOD.MERGE_COLLECTION | typeof METHOD.MULTI_SET | typeof METHOD.SET_COLLECTION, + key: undefined, + value: OnyxCollection, + mergedValue?: undefined, ): void; function sendActionToDevTools( - method: Exclude< - OnyxMethod, - | typeof METHOD.MERGE_COLLECTION - | typeof METHOD.MULTI_SET - | typeof METHOD.SET_COLLECTION - >, - key: OnyxKey, - value: OnyxEntry, - mergedValue?: OnyxEntry, + method: Exclude, + key: OnyxKey, + value: OnyxEntry, + mergedValue?: OnyxEntry, ): void; -function sendActionToDevTools( - method: OnyxMethod, - key: OnyxKey | undefined, - value: unknown, - mergedValue: OnyxEntry = undefined, -): void { - DevTools.registerAction( - utils.formatActionName(method, key), - value, - key - ? { [key]: mergedValue !== undefined ? mergedValue : value } - : (value as OnyxCollection), - ); +function sendActionToDevTools(method: OnyxMethod, key: OnyxKey | undefined, value: unknown, mergedValue: OnyxEntry = undefined): void { + DevTools.registerAction(utils.formatActionName(method, key), value, key ? {[key]: mergedValue !== undefined ? mergedValue : value} : (value as OnyxCollection)); } /** @@ -258,192 +226,178 @@ function sendActionToDevTools( * The resulting collection will only contain items that are returned by the selector. */ function reduceCollectionWithSelector( - collection: OnyxCollection, - selector: Selector, + collection: OnyxCollection, + selector: Selector, ): Record { - return Object.entries(collection ?? {}).reduce( - (finalCollection: Record, [key, item]) => { - // eslint-disable-next-line no-param-reassign - finalCollection[key] = selector(item); - - return finalCollection; - }, - {}, - ); + return Object.entries(collection ?? {}).reduce((finalCollection: Record, [key, item]) => { + // eslint-disable-next-line no-param-reassign + finalCollection[key] = selector(item); + + return finalCollection; + }, {}); } /** Get some data from the store */ -function get>( - key: TKey, -): Promise { - // When we already have the value in cache - resolve right away - if (cache.hasCacheForKey(key)) { - return Promise.resolve(cache.get(key) as TValue); - } - - // RAM-only keys should never read from storage (they may have stale persisted data - // from before the key was migrated to RAM-only). Mark as nullish so future get() calls - // short-circuit via hasCacheForKey and avoid re-running this branch. - if (OnyxKeys.isRamOnlyKey(key)) { - cache.addNullishStorageKey(key); - return Promise.resolve(undefined as TValue); - } - - const taskName = `${TASK.GET}:${key}` as const; - - // When a value retrieving task for this key is still running hook to it - if (cache.hasPendingTask(taskName)) { - return cache.getTaskPromise(taskName) as Promise; - } - - // Otherwise retrieve the value from storage and capture a promise to aid concurrent usages - const promise = Storage.getItem(key) - .then((val) => { - if (skippableCollectionMemberIDs.size) { - try { - const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); - if (skippableCollectionMemberIDs.has(collectionMemberID)) { - // The key is a skippable one, so we set the value to undefined. - // eslint-disable-next-line no-param-reassign - val = undefined as OnyxValue; - } - } catch (e) { - // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. - } - } - - // Prefer cache over stale storage if a concurrent write populated it during the read. - const cachedValue = cache.get(key) as TValue; - if (cachedValue !== undefined) { - return cachedValue; - } +function get>(key: TKey): Promise { + // When we already have the value in cache - resolve right away + if (cache.hasCacheForKey(key)) { + return Promise.resolve(cache.get(key) as TValue); + } - if (val === undefined) { + // RAM-only keys should never read from storage (they may have stale persisted data + // from before the key was migrated to RAM-only). Mark as nullish so future get() calls + // short-circuit via hasCacheForKey and avoid re-running this branch. + if (OnyxKeys.isRamOnlyKey(key)) { cache.addNullishStorageKey(key); - return undefined; - } - - cache.set(key, val); - return val; - }) - .catch((err) => - Logger.logInfo( - `Unable to get item from persistent storage. Key: ${key} Error: ${err}`, - ), - ); + return Promise.resolve(undefined as TValue); + } - return cache.captureTask(taskName, promise) as Promise; -} + const taskName = `${TASK.GET}:${key}` as const; -// multiGet the data first from the cache and then from the storage for the missing keys. -function multiGet( - keys: CollectionKeyBase[], -): Promise>> { - // Keys that are not in the cache - const missingKeys: OnyxKey[] = []; - - // Tasks that are pending - const pendingTasks: Array>> = []; - - // Keys for the tasks that are pending - const pendingKeys: OnyxKey[] = []; - - // Data to be sent back to the invoker - const dataMap = new Map>(); - - /** - * We are going to iterate over all the matching keys and check if we have the data in the cache. - * If we do then we add it to the data object. If we do not have them, then we check if there is a pending task - * for the key. If there is such task, then we add the promise to the pendingTasks array and the key to the pendingKeys - * array. If there is no pending task then we add the key to the missingKeys array. - * - * These missingKeys will be later used to multiGet the data from the storage. - */ - for (const key of keys) { - // RAM-only keys should never read from storage as they may have stale persisted data - // from before the key was migrated to RAM-only. - if (OnyxKeys.isRamOnlyKey(key)) { - if (cache.hasCacheForKey(key)) { - dataMap.set(key, cache.get(key) as OnyxValue); - } - continue; + // When a value retrieving task for this key is still running hook to it + if (cache.hasPendingTask(taskName)) { + return cache.getTaskPromise(taskName) as Promise; } - // hasCacheForKey catches cached falsy values (0, '', false, null) as cache hits, which - // a truthy check on the value would miss. - if (cache.hasCacheForKey(key)) { - dataMap.set(key, cache.get(key) as OnyxValue); - continue; - } + // Otherwise retrieve the value from storage and capture a promise to aid concurrent usages + const promise = Storage.getItem(key) + .then((val) => { + if (skippableCollectionMemberIDs.size) { + try { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); + if (skippableCollectionMemberIDs.has(collectionMemberID)) { + // The key is a skippable one, so we set the value to undefined. + // eslint-disable-next-line no-param-reassign + val = undefined as OnyxValue; + } + } catch (e) { + // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. + } + } - const pendingKey = `${TASK.GET}:${key}` as const; - if (cache.hasPendingTask(pendingKey)) { - pendingTasks.push( - cache.getTaskPromise(pendingKey) as Promise>, - ); - pendingKeys.push(key); - } else { - missingKeys.push(key); - } - } - - return ( - Promise.all(pendingTasks) - // Wait for all the pending tasks to resolve and then add the data to the data map. - .then((values) => { - for (const [index, value] of values.entries()) { - const pendingKey = pendingKeys[index]; - if (pendingKey !== undefined) { - dataMap.set(pendingKey, value); - } - } + // Prefer cache over stale storage if a concurrent write populated it during the read. + const cachedValue = cache.get(key) as TValue; + if (cachedValue !== undefined) { + return cachedValue; + } - return Promise.resolve(); - }) - // Get the missing keys using multiGet from the storage. - .then(() => { - if (missingKeys.length === 0) { - return Promise.resolve(undefined); - } + if (val === undefined) { + cache.addNullishStorageKey(key); + return undefined; + } - return Storage.multiGet(missingKeys); - }) - // Add the data from the missing keys to the data map and also merge it to the cache. - .then((values) => { - if (!values || values.length === 0) { - return dataMap; - } + cache.set(key, val); + return val; + }) + .catch((err) => Logger.logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); - // temp object is used to merge the missing data into the cache - const temp: OnyxCollection = {}; - for (const [key, value] of values) { - if (skippableCollectionMemberIDs.size) { - try { - const [, collectionMemberID] = - OnyxKeys.splitCollectionMemberKey(key); - if (skippableCollectionMemberIDs.has(collectionMemberID)) { - // The key is a skippable one, so we skip this iteration. - continue; - } - } catch (e) { - // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. + return cache.captureTask(taskName, promise) as Promise; +} + +// multiGet the data first from the cache and then from the storage for the missing keys. +function multiGet(keys: CollectionKeyBase[]): Promise>> { + // Keys that are not in the cache + const missingKeys: OnyxKey[] = []; + + // Tasks that are pending + const pendingTasks: Array>> = []; + + // Keys for the tasks that are pending + const pendingKeys: OnyxKey[] = []; + + // Data to be sent back to the invoker + const dataMap = new Map>(); + + /** + * We are going to iterate over all the matching keys and check if we have the data in the cache. + * If we do then we add it to the data object. If we do not have them, then we check if there is a pending task + * for the key. If there is such task, then we add the promise to the pendingTasks array and the key to the pendingKeys + * array. If there is no pending task then we add the key to the missingKeys array. + * + * These missingKeys will be later used to multiGet the data from the storage. + */ + for (const key of keys) { + // RAM-only keys should never read from storage as they may have stale persisted data + // from before the key was migrated to RAM-only. + if (OnyxKeys.isRamOnlyKey(key)) { + if (cache.hasCacheForKey(key)) { + dataMap.set(key, cache.get(key) as OnyxValue); } - } + continue; + } - // Prefer cache over stale storage if a concurrent write populated it during - // the read — otherwise cache.merge(temp) below would resurrect dropped fields. - if (cache.hasCacheForKey(key)) { + // hasCacheForKey catches cached falsy values (0, '', false, null) as cache hits, which + // a truthy check on the value would miss. + if (cache.hasCacheForKey(key)) { dataMap.set(key, cache.get(key) as OnyxValue); continue; - } + } - dataMap.set(key, value as OnyxValue); - temp[key] = value; + const pendingKey = `${TASK.GET}:${key}` as const; + if (cache.hasPendingTask(pendingKey)) { + pendingTasks.push(cache.getTaskPromise(pendingKey) as Promise>); + pendingKeys.push(key); + } else { + missingKeys.push(key); } - cache.merge(temp); - return dataMap; - }) - ); + } + + return ( + Promise.all(pendingTasks) + // Wait for all the pending tasks to resolve and then add the data to the data map. + .then((values) => { + for (const [index, value] of values.entries()) { + const pendingKey = pendingKeys[index]; + if (pendingKey !== undefined) { + dataMap.set(pendingKey, value); + } + } + + return Promise.resolve(); + }) + // Get the missing keys using multiGet from the storage. + .then(() => { + if (missingKeys.length === 0) { + return Promise.resolve(undefined); + } + + return Storage.multiGet(missingKeys); + }) + // Add the data from the missing keys to the data map and also merge it to the cache. + .then((values) => { + if (!values || values.length === 0) { + return dataMap; + } + + // temp object is used to merge the missing data into the cache + const temp: OnyxCollection = {}; + for (const [key, value] of values) { + if (skippableCollectionMemberIDs.size) { + try { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); + if (skippableCollectionMemberIDs.has(collectionMemberID)) { + // The key is a skippable one, so we skip this iteration. + continue; + } + } catch (e) { + // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. + } + } + + // Prefer cache over stale storage if a concurrent write populated it during + // the read — otherwise cache.merge(temp) below would resurrect dropped fields. + if (cache.hasCacheForKey(key)) { + dataMap.set(key, cache.get(key) as OnyxValue); + continue; + } + + dataMap.set(key, value as OnyxValue); + temp[key] = value; + } + cache.merge(temp); + return dataMap; + }) + ); } /** @@ -452,12 +406,10 @@ function multiGet( * * Note: just using `.map`, you'd end up with `Array|OnyxEntry>`, which is not what we want. This preserves the order of the keys provided. */ -function tupleGet( - keys: Keys, -): Promise<{ [Index in keyof Keys]: OnyxValue }> { - return Promise.all(keys.map((key) => get(key))) as Promise<{ - [Index in keyof Keys]: OnyxValue; - }>; +function tupleGet(keys: Keys): Promise<{[Index in keyof Keys]: OnyxValue}> { + return Promise.all(keys.map((key) => get(key))) as Promise<{ + [Index in keyof Keys]: OnyxValue; + }>; } /** @@ -467,10 +419,10 @@ function tupleGet( * @param key - A key that the subscriber is subscribed to. */ function storeKeyBySubscriptions(key: OnyxKey, subscriptionID: number) { - if (!onyxKeyToSubscriptionIDs.has(key)) { - onyxKeyToSubscriptionIDs.set(key, []); - } - onyxKeyToSubscriptionIDs.get(key).push(subscriptionID); + if (!onyxKeyToSubscriptionIDs.has(key)) { + onyxKeyToSubscriptionIDs.set(key, []); + } + onyxKeyToSubscriptionIDs.get(key).push(subscriptionID); } /** @@ -479,427 +431,354 @@ function storeKeyBySubscriptions(key: OnyxKey, subscriptionID: number) { * @param subscriptionID - The subscription ID to be deleted. */ function deleteKeyBySubscriptions(subscriptionID: number) { - const subscriber = callbackToStateMapping[subscriptionID]; + const subscriber = callbackToStateMapping[subscriptionID]; - if (subscriber && onyxKeyToSubscriptionIDs.has(subscriber.key)) { - const updatedSubscriptionsIDs = onyxKeyToSubscriptionIDs - .get(subscriber.key) - .filter((id: number) => id !== subscriptionID); - onyxKeyToSubscriptionIDs.set(subscriber.key, updatedSubscriptionsIDs); - } + if (subscriber && onyxKeyToSubscriptionIDs.has(subscriber.key)) { + const updatedSubscriptionsIDs = onyxKeyToSubscriptionIDs.get(subscriber.key).filter((id: number) => id !== subscriptionID); + onyxKeyToSubscriptionIDs.set(subscriber.key, updatedSubscriptionsIDs); + } - lastConnectionCallbackData.delete(subscriptionID); + lastConnectionCallbackData.delete(subscriptionID); } /** Returns current key names stored in persisted storage */ function getAllKeys(): Promise> { - // When we've already read stored keys, resolve right away - const cachedKeys = cache.getAllKeys(); - if (cachedKeys.size > 0) { - return Promise.resolve(cachedKeys); - } - - // When a value retrieving task for all keys is still running hook to it - if (cache.hasPendingTask(TASK.GET_ALL_KEYS)) { - return cache.getTaskPromise(TASK.GET_ALL_KEYS) as Promise>; - } - - // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages - const promise = Storage.getAllKeys().then((keys) => { - // Filter out RAM-only keys from storage results as they may be stale entries - // from before the key was migrated to RAM-only. - const filteredKeys = keys.filter((key) => !OnyxKeys.isRamOnlyKey(key)); - cache.setAllKeys(filteredKeys); - - // return the updated set of keys - return cache.getAllKeys(); - }); - - return cache.captureTask(TASK.GET_ALL_KEYS, promise) as Promise>; + // When we've already read stored keys, resolve right away + const cachedKeys = cache.getAllKeys(); + if (cachedKeys.size > 0) { + return Promise.resolve(cachedKeys); + } + + // When a value retrieving task for all keys is still running hook to it + if (cache.hasPendingTask(TASK.GET_ALL_KEYS)) { + return cache.getTaskPromise(TASK.GET_ALL_KEYS) as Promise>; + } + + // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages + const promise = Storage.getAllKeys().then((keys) => { + // Filter out RAM-only keys from storage results as they may be stale entries + // from before the key was migrated to RAM-only. + const filteredKeys = keys.filter((key) => !OnyxKeys.isRamOnlyKey(key)); + cache.setAllKeys(filteredKeys); + + // return the updated set of keys + return cache.getAllKeys(); + }); + + return cache.captureTask(TASK.GET_ALL_KEYS, promise) as Promise>; } /** * Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined. * If the requested key is a collection, it will return an object with all the collection members. */ -function tryGetCachedValue( - key: TKey, -): OnyxValue { - let val = cache.get(key); - - if (OnyxKeys.isCollectionKey(key)) { - const collectionData = cache.getCollectionData(key); - if (collectionData !== undefined) { - val = collectionData; - } else { - // If we haven't loaded all keys yet, we can't determine if the collection exists - if (cache.getAllKeys().size === 0) { - return; - } - // Set an empty collection object for collections that exist but have no data - val = {}; +function tryGetCachedValue(key: TKey): OnyxValue { + let val = cache.get(key); + + if (OnyxKeys.isCollectionKey(key)) { + const collectionData = cache.getCollectionData(key); + if (collectionData !== undefined) { + val = collectionData; + } else { + // If we haven't loaded all keys yet, we can't determine if the collection exists + if (cache.getAllKeys().size === 0) { + return; + } + // Set an empty collection object for collections that exist but have no data + val = {}; + } } - } - return val; + return val; } -function getCachedCollection( - collectionKey: TKey, - collectionMemberKeys?: string[], -): NonNullable> { - // Use optimized collection data retrieval when cache is populated - const collectionData = cache.getCollectionData(collectionKey); - const allKeys = collectionMemberKeys ?? cache.getAllKeys(); - if ( - collectionData !== undefined && - (Array.isArray(allKeys) ? allKeys.length > 0 : allKeys.size > 0) - ) { - // If we have specific member keys, filter the collection - if (collectionMemberKeys) { - const filteredCollection: OnyxCollection = {}; - for (const key of collectionMemberKeys) { - if (collectionData[key] !== undefined) { - filteredCollection[key] = collectionData[key]; - } else if (cache.hasNullishStorageKey(key)) { - filteredCollection[key] = cache.get(key); +function getCachedCollection(collectionKey: TKey, collectionMemberKeys?: string[]): NonNullable> { + // Use optimized collection data retrieval when cache is populated + const collectionData = cache.getCollectionData(collectionKey); + const allKeys = collectionMemberKeys ?? cache.getAllKeys(); + if (collectionData !== undefined && (Array.isArray(allKeys) ? allKeys.length > 0 : allKeys.size > 0)) { + // If we have specific member keys, filter the collection + if (collectionMemberKeys) { + const filteredCollection: OnyxCollection = {}; + for (const key of collectionMemberKeys) { + if (collectionData[key] !== undefined) { + filteredCollection[key] = collectionData[key]; + } else if (cache.hasNullishStorageKey(key)) { + filteredCollection[key] = cache.get(key); + } + } + return filteredCollection; } - } - return filteredCollection; - } - // Snapshot is frozen — safe to return by reference - return collectionData; - } - - // Fallback to original implementation if collection data not available - const collection: OnyxCollection = {}; - - // forEach exists on both Set and Array - for (const key of allKeys) { - // If we don't have collectionMemberKeys array then we have to check whether a key is a collection member key. - // Because in that case the keys will be coming from `cache.getAllKeys()` and we need to filter out the keys that - // are not part of the collection. - if ( - !collectionMemberKeys && - !OnyxKeys.isCollectionMemberKey(collectionKey, key) - ) { - continue; + // Snapshot is frozen — safe to return by reference + return collectionData; } - const cachedValue = cache.get(key); + // Fallback to original implementation if collection data not available + const collection: OnyxCollection = {}; - if (cachedValue === undefined && !cache.hasNullishStorageKey(key)) { - continue; - } + // forEach exists on both Set and Array + for (const key of allKeys) { + // If we don't have collectionMemberKeys array then we have to check whether a key is a collection member key. + // Because in that case the keys will be coming from `cache.getAllKeys()` and we need to filter out the keys that + // are not part of the collection. + if (!collectionMemberKeys && !OnyxKeys.isCollectionMemberKey(collectionKey, key)) { + continue; + } + + const cachedValue = cache.get(key); - collection[key] = cache.get(key); - } + if (cachedValue === undefined && !cache.hasNullishStorageKey(key)) { + continue; + } - return collection; + collection[key] = cache.get(key); + } + + return collection; } /** * When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks */ function keysChanged( - collectionKey: TKey, - partialCollection: OnyxCollection, - partialPreviousCollection: OnyxCollection | undefined, + collectionKey: TKey, + partialCollection: OnyxCollection, + partialPreviousCollection: OnyxCollection | undefined, ): void { - const cachedCollection = getCachedCollection(collectionKey); - const previousCollection = partialPreviousCollection ?? {}; - const changedMemberKeys = Object.keys(partialCollection ?? {}); - - // Add or remove the keys from the recentlyAccessedKeys list - for (const memberKey of changedMemberKeys) { - const value = partialCollection?.[memberKey]; - if (value !== null && value !== undefined) { - cache.addLastAccessedKey(memberKey, false); - } else { - cache.removeLastAccessedKey(memberKey); - } - } - - // Use indexed lookup instead of scanning all subscribers. - // We need subscribers for: (1) the collection key itself, and (2) individual changed member keys. - const collectionSubscriberIDs = - onyxKeyToSubscriptionIDs.get(collectionKey) ?? []; - const memberSubscriberIDs: number[] = []; - for (const memberKey of changedMemberKeys) { - const ids = onyxKeyToSubscriptionIDs.get(memberKey); - if (ids) { - for (const id of ids) { - memberSubscriberIDs.push(id); - } + const cachedCollection = getCachedCollection(collectionKey); + const previousCollection = partialPreviousCollection ?? {}; + const changedMemberKeys = Object.keys(partialCollection ?? {}); + + // Add or remove the keys from the recentlyAccessedKeys list + for (const memberKey of changedMemberKeys) { + const value = partialCollection?.[memberKey]; + if (value !== null && value !== undefined) { + cache.addLastAccessedKey(memberKey, false); + } else { + cache.removeLastAccessedKey(memberKey); + } } - } - // Notify collection-level subscribers - for (const subID of collectionSubscriberIDs) { - const subscriber = callbackToStateMapping[subID]; - if (!subscriber || typeof subscriber.callback !== "function") { - continue; + // Use indexed lookup instead of scanning all subscribers. + // We need subscribers for: (1) the collection key itself, and (2) individual changed member keys. + const collectionSubscriberIDs = onyxKeyToSubscriptionIDs.get(collectionKey) ?? []; + const memberSubscriberIDs: number[] = []; + for (const memberKey of changedMemberKeys) { + const ids = onyxKeyToSubscriptionIDs.get(memberKey); + if (ids) { + for (const id of ids) { + memberSubscriberIDs.push(id); + } + } } - try { - lastConnectionCallbackData.set(subscriber.subscriptionID, { - value: cachedCollection, - matchedKey: subscriber.key, - }); - subscriber.callback(cachedCollection, subscriber.key); - } catch (error) { - Logger.logAlert( - `[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${formatCaughtError(error)}`, - ); - } - } + // Notify collection-level subscribers + for (const subID of collectionSubscriberIDs) { + const subscriber = callbackToStateMapping[subID]; + if (!subscriber || typeof subscriber.callback !== 'function') { + continue; + } - // Notify member-level subscribers (e.g. subscribed to `report_123`) - for (const subID of memberSubscriberIDs) { - const subscriber = callbackToStateMapping[subID]; - if (!subscriber || typeof subscriber.callback !== "function") { - continue; + try { + lastConnectionCallbackData.set(subscriber.subscriptionID, { + value: cachedCollection, + matchedKey: subscriber.key, + }); + subscriber.callback(cachedCollection, subscriber.key); + } catch (error) { + Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${formatCaughtError(error)}`); + } } - if ( - cachedCollection[subscriber.key] === previousCollection[subscriber.key] - ) { - continue; - } + // Notify member-level subscribers (e.g. subscribed to `report_123`) + for (const subID of memberSubscriberIDs) { + const subscriber = callbackToStateMapping[subID]; + if (!subscriber || typeof subscriber.callback !== 'function') { + continue; + } - try { - const subscriberCallback = - subscriber.callback as DefaultConnectCallback; - subscriberCallback( - cachedCollection[subscriber.key], - subscriber.key as TKey, - ); - lastConnectionCallbackData.set(subscriber.subscriptionID, { - value: cachedCollection[subscriber.key], - matchedKey: subscriber.key, - }); - } catch (error) { - Logger.logAlert( - `[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${formatCaughtError(error)}`, - ); + if (cachedCollection[subscriber.key] === previousCollection[subscriber.key]) { + continue; + } + + try { + const subscriberCallback = subscriber.callback as DefaultConnectCallback; + subscriberCallback(cachedCollection[subscriber.key], subscriber.key as TKey); + lastConnectionCallbackData.set(subscriber.subscriptionID, { + value: cachedCollection[subscriber.key], + matchedKey: subscriber.key, + }); + } catch (error) { + Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${formatCaughtError(error)}`); + } } - } } /** * When a key change happens, search for any callbacks matching the key or collection key and trigger those callbacks */ function keyChanged( - key: TKey, - value: OnyxValue, - canUpdateSubscriber: ( - subscriber?: CallbackToStateMapping, - ) => boolean = () => true, - isProcessingCollectionUpdate = false, + key: TKey, + value: OnyxValue, + canUpdateSubscriber: (subscriber?: CallbackToStateMapping) => boolean = () => true, + isProcessingCollectionUpdate = false, ): void { - // Add or remove this key from the recentlyAccessedKeys list - if (value !== null && value !== undefined) { - cache.addLastAccessedKey(key, OnyxKeys.isCollectionKey(key)); - } else { - cache.removeLastAccessedKey(key); - } - - // We get the subscribers interested in the key that has just changed. If the subscriber's key is a collection key then we will - // notify them if the key that changed is a collection member. Or if it is a regular key notify them when there is an exact match. - // Given the amount of times this function is called we need to make sure we are not iterating over all subscribers every time. On the other hand, we don't need to - // do the same in keysChanged, because we only call that function when a collection key changes, and it doesn't happen that often. - // For performance reason, we look for the given key and later if don't find it we look for the collection key, instead of checking if it is a collection key first. - let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; - const collectionKey = OnyxKeys.getCollectionKey(key); - - if (collectionKey) { - // Getting the collection key from the specific key because only collection keys were stored in the mapping. - stateMappingKeys = [ - ...stateMappingKeys, - ...(onyxKeyToSubscriptionIDs.get(collectionKey) ?? []), - ]; - if (stateMappingKeys.length === 0) { - return; - } - } - - // Cache the collection snapshot per dispatch so all subscribers to the same collection - // see a consistent view, even if an earlier subscriber's callback synchronously writes - // to the same collection. - const cachedCollections: Record< - string, - ReturnType - > = {}; - - for (const stateMappingKey of stateMappingKeys) { - const subscriber = callbackToStateMapping[stateMappingKey]; - if ( - !subscriber || - !OnyxKeys.isKeyMatch(subscriber.key, key) || - !canUpdateSubscriber(subscriber) - ) { - continue; + // Add or remove this key from the recentlyAccessedKeys list + if (value !== null && value !== undefined) { + cache.addLastAccessedKey(key, OnyxKeys.isCollectionKey(key)); + } else { + cache.removeLastAccessedKey(key); } - // Subscriber is a regular call to connect() and provided a callback - if (typeof subscriber.callback === "function") { - try { - const lastData = lastConnectionCallbackData.get( - subscriber.subscriptionID, - ); - if ( - lastData && - lastData.matchedKey === key && - lastData.value === value - ) { - continue; + // We get the subscribers interested in the key that has just changed. If the subscriber's key is a collection key then we will + // notify them if the key that changed is a collection member. Or if it is a regular key notify them when there is an exact match. + // Given the amount of times this function is called we need to make sure we are not iterating over all subscribers every time. On the other hand, we don't need to + // do the same in keysChanged, because we only call that function when a collection key changes, and it doesn't happen that often. + // For performance reason, we look for the given key and later if don't find it we look for the collection key, instead of checking if it is a collection key first. + let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; + const collectionKey = OnyxKeys.getCollectionKey(key); + + if (collectionKey) { + // Getting the collection key from the specific key because only collection keys were stored in the mapping. + stateMappingKeys = [...stateMappingKeys, ...(onyxKeyToSubscriptionIDs.get(collectionKey) ?? [])]; + if (stateMappingKeys.length === 0) { + return; } + } - if (OnyxKeys.isCollectionKey(subscriber.key)) { - // Skip individual key changes during collection updates to prevent duplicate - // callbacks - the collection update will handle this properly. - if (isProcessingCollectionUpdate) { + // Cache the collection snapshot per dispatch so all subscribers to the same collection + // see a consistent view, even if an earlier subscriber's callback synchronously writes + // to the same collection. + const cachedCollections: Record> = {}; + + for (const stateMappingKey of stateMappingKeys) { + const subscriber = callbackToStateMapping[stateMappingKey]; + if (!subscriber || !OnyxKeys.isKeyMatch(subscriber.key, key) || !canUpdateSubscriber(subscriber)) { continue; - } - // Cache once per dispatch to ensure all subscribers see a consistent snapshot - // even if a previous callback synchronously wrote to the same collection. - let cachedCollection = cachedCollections[subscriber.key]; - if (!cachedCollection) { - cachedCollection = getCachedCollection(subscriber.key); - cachedCollections[subscriber.key] = cachedCollection; - } - lastConnectionCallbackData.set(subscriber.subscriptionID, { - value: cachedCollection, - matchedKey: subscriber.key, - }); - subscriber.callback(cachedCollection, subscriber.key); - continue; } - const subscriberCallback = - subscriber.callback as DefaultConnectCallback; - subscriberCallback(value, key); + // Subscriber is a regular call to connect() and provided a callback + if (typeof subscriber.callback === 'function') { + try { + const lastData = lastConnectionCallbackData.get(subscriber.subscriptionID); + if (lastData && lastData.matchedKey === key && lastData.value === value) { + continue; + } + + if (OnyxKeys.isCollectionKey(subscriber.key)) { + // Skip individual key changes during collection updates to prevent duplicate + // callbacks - the collection update will handle this properly. + if (isProcessingCollectionUpdate) { + continue; + } + // Cache once per dispatch to ensure all subscribers see a consistent snapshot + // even if a previous callback synchronously wrote to the same collection. + let cachedCollection = cachedCollections[subscriber.key]; + if (!cachedCollection) { + cachedCollection = getCachedCollection(subscriber.key); + cachedCollections[subscriber.key] = cachedCollection; + } + lastConnectionCallbackData.set(subscriber.subscriptionID, { + value: cachedCollection, + matchedKey: subscriber.key, + }); + subscriber.callback(cachedCollection, subscriber.key); + continue; + } + + const subscriberCallback = subscriber.callback as DefaultConnectCallback; + subscriberCallback(value, key); + + lastConnectionCallbackData.set(subscriber.subscriptionID, { + value, + matchedKey: key, + }); + continue; + } catch (error) { + Logger.logAlert(`[OnyxUtils.keyChanged] Subscriber callback threw an error for key '${key}': ${formatCaughtError(error)}`); + } - lastConnectionCallbackData.set(subscriber.subscriptionID, { - value, - matchedKey: key, - }); - continue; - } catch (error) { - Logger.logAlert( - `[OnyxUtils.keyChanged] Subscriber callback threw an error for key '${key}': ${formatCaughtError(error)}`, - ); - } + continue; + } - continue; + console.error('Warning: Found a matching subscriber to a key that changed, but no callback could be found.'); } - - console.error( - "Warning: Found a matching subscriber to a key that changed, but no callback could be found.", - ); - } } /** * Sends the data obtained from the keys to the connection. */ -function sendDataToConnection( - mapping: CallbackToStateMapping, - matchedKey: TKey | undefined, -): void { - // If the mapping no longer exists then we should not send any data. - // This means our subscriber was disconnected. - if (!callbackToStateMapping[mapping.subscriptionID]) { - return; - } - - // Always read the latest value from cache to avoid stale or duplicate data. - // For collection-root subscribers, read the full collection. - // For individual key subscribers, read just that key's value. - let value: OnyxValue | undefined; - if (OnyxKeys.isCollectionKey(mapping.key)) { - const collection = getCachedCollection(mapping.key); - value = - Object.keys(collection).length > 0 - ? (collection as OnyxValue) - : undefined; - } else { - value = cache.get(matchedKey ?? mapping.key) as OnyxValue; - } - - // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage. - value = value === null ? undefined : value; - const lastData = lastConnectionCallbackData.get(mapping.subscriptionID); - - // If the value has not changed for the same key we do not need to trigger the callback. - // We compare matchedKey to avoid suppressing callbacks for different collection members - // that happen to have shallow-equal values (e.g. during hydration racing with set()). - if ( - lastData && - lastData.matchedKey === matchedKey && - shallowEqual(lastData.value, value) - ) { - return; - } - - (mapping.callback as DefaultConnectCallback | undefined)?.( - value, - matchedKey as TKey, - ); +function sendDataToConnection(mapping: CallbackToStateMapping, matchedKey: TKey | undefined): void { + // If the mapping no longer exists then we should not send any data. + // This means our subscriber was disconnected. + if (!callbackToStateMapping[mapping.subscriptionID]) { + return; + } + + // Always read the latest value from cache to avoid stale or duplicate data. + // For collection-root subscribers, read the full collection. + // For individual key subscribers, read just that key's value. + let value: OnyxValue | undefined; + if (OnyxKeys.isCollectionKey(mapping.key)) { + const collection = getCachedCollection(mapping.key); + value = Object.keys(collection).length > 0 ? (collection as OnyxValue) : undefined; + } else { + value = cache.get(matchedKey ?? mapping.key) as OnyxValue; + } + + // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage. + value = value === null ? undefined : value; + const lastData = lastConnectionCallbackData.get(mapping.subscriptionID); + + // If the value has not changed for the same key we do not need to trigger the callback. + // We compare matchedKey to avoid suppressing callbacks for different collection members + // that happen to have shallow-equal values (e.g. during hydration racing with set()). + if (lastData && lastData.matchedKey === matchedKey && shallowEqual(lastData.value, value)) { + return; + } + + (mapping.callback as DefaultConnectCallback | undefined)?.(value, matchedKey as TKey); } /** * Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber. */ -function getCollectionDataAndSendAsObject( - matchingKeys: CollectionKeyBase[], - mapping: CallbackToStateMapping, -): void { - multiGet(matchingKeys).then(() => { - sendDataToConnection(mapping, mapping.key); - }); +function getCollectionDataAndSendAsObject(matchingKeys: CollectionKeyBase[], mapping: CallbackToStateMapping): void { + multiGet(matchingKeys).then(() => { + sendDataToConnection(mapping, mapping.key); + }); } /** * Remove a key from Onyx and update the subscribers */ -function remove( - key: TKey, - isProcessingCollectionUpdate?: boolean, -): Promise { - cache.drop(key); - keyChanged( - key, - undefined as OnyxValue, - undefined, - isProcessingCollectionUpdate, - ); - - if (OnyxKeys.isRamOnlyKey(key)) { - return Promise.resolve(); - } - - return Storage.removeItem(key).then(() => undefined); +function remove(key: TKey, isProcessingCollectionUpdate?: boolean): Promise { + cache.drop(key); + keyChanged(key, undefined as OnyxValue, undefined, isProcessingCollectionUpdate); + + if (OnyxKeys.isRamOnlyKey(key)) { + return Promise.resolve(); + } + + return Storage.removeItem(key).then(() => undefined); } function reportStorageQuota(error?: Error): Promise { - return Storage.getDatabaseSize() - .then(({ bytesUsed, bytesRemaining, usageDetails }) => { - // `bytesRemaining` comes from navigator.storage.estimate() and is an ORIGIN-WIDE estimate, - // not headroom for this database. The browser allocates IndexedDB storage dynamically, so a - // QuotaExceededError can legitimately occur even when this number still looks large. - Logger.logInfo( - `Storage Quota Check -- bytesUsed: ${bytesUsed} originWideBytesRemaining (estimate, not per-DB headroom): ${bytesRemaining}${ - usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : "" - }. Original error: ${error}`, - ); - }) - .catch((dbSizeError) => { - Logger.logAlert( - `Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`, - ); - }); + return Storage.getDatabaseSize() + .then(({bytesUsed, bytesRemaining, usageDetails}) => { + // `bytesRemaining` comes from navigator.storage.estimate() and is an ORIGIN-WIDE estimate, + // not headroom for this database. The browser allocates IndexedDB storage dynamically, so a + // QuotaExceededError can legitimately occur even when this number still looks large. + Logger.logInfo( + `Storage Quota Check -- bytesUsed: ${bytesUsed} originWideBytesRemaining (estimate, not per-DB headroom): ${bytesRemaining}${ + usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : '' + }. Original error: ${error}`, + ); + }) + .catch((dbSizeError) => { + Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`); + }); } /** @@ -916,129 +795,107 @@ function reportStorageQuota(error?: Error): Promise { * provider) once so it's visible, then bounded retry without eviction. */ function retryOperation( - error: Error, - onyxMethod: TMethod, - defaultParams: Parameters[0], - retryAttempt: number | undefined, - inFlightKeys?: Set, + error: Error, + onyxMethod: TMethod, + defaultParams: Parameters[0], + retryAttempt: number | undefined, + inFlightKeys?: Set, ): Promise { - const currentRetryAttempt = retryAttempt ?? 0; - const nextRetryAttempt = currentRetryAttempt + 1; - const errorClass = Storage.classifyError(error); - - // While open (or while a half-open probe is already in flight), drop capacity retries silently — - // the breaker already emitted its single alert, and logging per failed write is exactly the storm - // we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so - // the circuit reopens for another window. (We return before the log line below on purpose.) - if ( - errorClass === StorageErrorClass.CAPACITY && - !StorageCircuitBreaker.isAllowed() - ) { - StorageCircuitBreaker.recordProbeFailure(); - return Promise.resolve(); - } - - Logger.logInfo( - `Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`, - ); - - if (errorClass === StorageErrorClass.INVALID_DATA) { - Logger.logAlert( - `Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`, - ); - throw error; - } + const currentRetryAttempt = retryAttempt ?? 0; + const nextRetryAttempt = currentRetryAttempt + 1; + const errorClass = Storage.classifyError(error); + + // While open (or while a half-open probe is already in flight), drop capacity retries silently — + // the breaker already emitted its single alert, and logging per failed write is exactly the storm + // we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so + // the circuit reopens for another window. (We return before the log line below on purpose.) + if (errorClass === StorageErrorClass.CAPACITY && !StorageCircuitBreaker.isAllowed()) { + StorageCircuitBreaker.recordProbeFailure(); + return Promise.resolve(); + } - if ( - errorClass === StorageErrorClass.TRANSIENT || - errorClass === StorageErrorClass.FATAL - ) { Logger.logInfo( - `Storage operation skipped retry; ${errorClass} errors are handled by the connection layer. Error: ${error}. onyxMethod: ${onyxMethod.name}.`, + `Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`, ); - return Promise.resolve(); - } - if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) { - Logger.logAlert( - `Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`, - ); - return Promise.resolve(); - } - - if (errorClass === StorageErrorClass.UNKNOWN) { - // UNKNOWN is the blind spot: the active provider's classifier did not recognize this error, so - // we cannot route it to a real recovery strategy. Log the full error shape (name + message + - // provider) once per operation so telemetry can reveal what lives in UNKNOWN, letting us promote - // recurring cases into TRANSIENT/CAPACITY/FATAL. Logged on the first attempt only to avoid the - // per-retry amplification this mechanism is trying to kill. Then bounded retry without eviction. - if (currentRetryAttempt === 0) { - Logger.logAlert( - `Unclassified storage error. provider: ${Storage.getStorageProvider().name}. name: ${error?.name}. message: ${error?.message}. onyxMethod: ${onyxMethod.name}.`, - ); + if (errorClass === StorageErrorClass.INVALID_DATA) { + Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`); + throw error; } - // @ts-expect-error No overload matches this call. - return onyxMethod(defaultParams, nextRetryAttempt); - } - - // CAPACITY: feed the session-level circuit breaker before evicting. The per-operation budget above - // cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns - // a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The - // already-open case returned silently at the top of this function.) - if (StorageCircuitBreaker.recordCapacityFailure()) { - // This failure tripped the breaker; it already emitted its single alert. Stop here. - return Promise.resolve(); - } - - // Find the least recently accessed evictable key that we can remove. Never evict an in-flight - // key — its cache value is the merge base this retry depends on, so dropping it would truncate - // the write to just the delta and diverge cache from storage. - const keyForRemoval = cache.getKeyForEviction(inFlightKeys); - if (!keyForRemoval) { - // If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case, - // then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we - // will allow this write to be skipped. - Logger.logAlert( - `Out of storage. But found no acceptable keys to remove. Error: ${error}`, - ); - return reportStorageQuota(error); - } - - // Remove the least recently accessed key and retry. - Logger.logInfo( - `Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`, - ); - reportStorageQuota(error); - - return remove(keyForRemoval).then(() => { - // Mark the eviction only once the deletion has actually completed, immediately before the - // retry it pairs with. Recording earlier lets a concurrent write's capacity failure consume - // the marker as a no-progress cycle while this deletion is still pending and may yet free - // space — so the verdict belongs to the retry that follows the deletion, not the eviction call. - StorageCircuitBreaker.recordEviction(); - // @ts-expect-error No overload matches this call. - return onyxMethod(defaultParams, nextRetryAttempt); - }); + + if (errorClass === StorageErrorClass.TRANSIENT || errorClass === StorageErrorClass.FATAL) { + Logger.logInfo(`Storage operation skipped retry; ${errorClass} errors are handled by the connection layer. Error: ${error}. onyxMethod: ${onyxMethod.name}.`); + return Promise.resolve(); + } + + if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) { + Logger.logAlert(`Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`); + return Promise.resolve(); + } + + if (errorClass === StorageErrorClass.UNKNOWN) { + // UNKNOWN is the blind spot: the active provider's classifier did not recognize this error, so + // we cannot route it to a real recovery strategy. Log the full error shape (name + message + + // provider) once per operation so telemetry can reveal what lives in UNKNOWN, letting us promote + // recurring cases into TRANSIENT/CAPACITY/FATAL. Logged on the first attempt only to avoid the + // per-retry amplification this mechanism is trying to kill. Then bounded retry without eviction. + if (currentRetryAttempt === 0) { + Logger.logAlert(`Unclassified storage error. provider: ${Storage.getStorageProvider().name}. name: ${error?.name}. message: ${error?.message}. onyxMethod: ${onyxMethod.name}.`); + } + // @ts-expect-error No overload matches this call. + return onyxMethod(defaultParams, nextRetryAttempt); + } + + // CAPACITY: feed the session-level circuit breaker before evicting. The per-operation budget above + // cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns + // a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The + // already-open case returned silently at the top of this function.) + if (StorageCircuitBreaker.recordCapacityFailure()) { + // This failure tripped the breaker; it already emitted its single alert. Stop here. + return Promise.resolve(); + } + + // Find the least recently accessed evictable key that we can remove. Never evict an in-flight + // key — its cache value is the merge base this retry depends on, so dropping it would truncate + // the write to just the delta and diverge cache from storage. + const keyForRemoval = cache.getKeyForEviction(inFlightKeys); + if (!keyForRemoval) { + // If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case, + // then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we + // will allow this write to be skipped. + Logger.logAlert(`Out of storage. But found no acceptable keys to remove. Error: ${error}`); + return reportStorageQuota(error); + } + + // Remove the least recently accessed key and retry. + Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`); + reportStorageQuota(error); + + return remove(keyForRemoval).then(() => { + // Mark the eviction only once the deletion has actually completed, immediately before the + // retry it pairs with. Recording earlier lets a concurrent write's capacity failure consume + // the marker as a no-progress cycle while this deletion is still pending and may yet free + // space — so the verdict belongs to the retry that follows the deletion, not the eviction call. + StorageCircuitBreaker.recordEviction(); + // @ts-expect-error No overload matches this call. + return onyxMethod(defaultParams, nextRetryAttempt); + }); } /** * Notifies subscribers and writes current value to cache */ -function broadcastUpdate( - key: TKey, - value: OnyxValue, - hasChanged?: boolean, -): void { - if (!hasChanged) { - return; - } +function broadcastUpdate(key: TKey, value: OnyxValue, hasChanged?: boolean): void { + if (!hasChanged) { + return; + } - cache.set(key, value); - keyChanged(key, value); + cache.set(key, value); + keyChanged(key, value); } function hasPendingMergeForKey(key: OnyxKey): boolean { - return !!mergeQueue[key]; + return !!mergeQueue[key]; } /** @@ -1049,34 +906,27 @@ function hasPendingMergeForKey(key: OnyxKey): boolean { * @return an array of key - value pairs <[key, value]> */ function prepareKeyValuePairsForStorage( - data: Record>, - shouldRemoveNestedNulls?: boolean, - replaceNullPatches?: MultiMergeReplaceNullPatches, - isProcessingCollectionUpdate?: boolean, + data: Record>, + shouldRemoveNestedNulls?: boolean, + replaceNullPatches?: MultiMergeReplaceNullPatches, + isProcessingCollectionUpdate?: boolean, ): StorageKeyValuePair[] { - const pairs: StorageKeyValuePair[] = []; + const pairs: StorageKeyValuePair[] = []; - for (const [key, value] of Object.entries(data)) { - if (value === null) { - remove(key, isProcessingCollectionUpdate); - continue; - } + for (const [key, value] of Object.entries(data)) { + if (value === null) { + remove(key, isProcessingCollectionUpdate); + continue; + } + + const valueWithoutNestedNullValues = shouldRemoveNestedNulls ?? true ? utils.removeNestedNullValues(value) : value; - const valueWithoutNestedNullValues = - (shouldRemoveNestedNulls ?? true) - ? utils.removeNestedNullValues(value) - : value; - - if (valueWithoutNestedNullValues !== undefined) { - pairs.push([ - key, - valueWithoutNestedNullValues, - replaceNullPatches?.[key], - ]); + if (valueWithoutNestedNullValues !== undefined) { + pairs.push([key, valueWithoutNestedNullValues, replaceNullPatches?.[key]]); + } } - } - return pairs; + return pairs; } /** @@ -1085,11 +935,8 @@ function prepareKeyValuePairsForStorage( * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ -function mergeChanges< - TValue extends OnyxInput, - TChange extends OnyxInput, ->(changes: TChange[], existingValue?: TValue): FastMergeResult { - return mergeInternal("merge", changes, existingValue); +function mergeChanges, TChange extends OnyxInput>(changes: TChange[], existingValue?: TValue): FastMergeResult { + return mergeInternal('merge', changes, existingValue); } /** @@ -1099,11 +946,8 @@ function mergeChanges< * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ -function mergeAndMarkChanges< - TValue extends OnyxInput, - TChange extends OnyxInput, ->(changes: TChange[], existingValue?: TValue): FastMergeResult { - return mergeInternal("mark", changes, existingValue); +function mergeAndMarkChanges, TChange extends OnyxInput>(changes: TChange[], existingValue?: TValue): FastMergeResult { + return mergeInternal('mark', changes, existingValue); } /** @@ -1112,182 +956,144 @@ function mergeAndMarkChanges< * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ -function mergeInternal< - TValue extends OnyxInput, - TChange extends OnyxInput, ->( - mode: "merge" | "mark", - changes: TChange[], - existingValue?: TValue, -): FastMergeResult { - const lastChange = changes?.at(-1); - - if (Array.isArray(lastChange)) { - return { result: lastChange, replaceNullPatches: [] }; - } - - if (changes.some((change) => change && typeof change === "object")) { - // Object values are then merged one after the other - return changes.reduce>( - (modifiedData, change) => { - const options: FastMergeOptions = - mode === "merge" - ? { shouldRemoveNestedNulls: true, objectRemovalMode: "replace" } - : { objectRemovalMode: "mark" }; - const { result, replaceNullPatches } = utils.fastMerge( - modifiedData.result, - change, - options, +function mergeInternal, TChange extends OnyxInput>(mode: 'merge' | 'mark', changes: TChange[], existingValue?: TValue): FastMergeResult { + const lastChange = changes?.at(-1); + + if (Array.isArray(lastChange)) { + return {result: lastChange, replaceNullPatches: []}; + } + + if (changes.some((change) => change && typeof change === 'object')) { + // Object values are then merged one after the other + return changes.reduce>( + (modifiedData, change) => { + const options: FastMergeOptions = mode === 'merge' ? {shouldRemoveNestedNulls: true, objectRemovalMode: 'replace'} : {objectRemovalMode: 'mark'}; + const {result, replaceNullPatches} = utils.fastMerge(modifiedData.result, change, options); + + // eslint-disable-next-line no-param-reassign + modifiedData.result = result; + // eslint-disable-next-line no-param-reassign + modifiedData.replaceNullPatches = [...modifiedData.replaceNullPatches, ...replaceNullPatches]; + + return modifiedData; + }, + { + result: (existingValue ?? {}) as TChange, + replaceNullPatches: [], + }, ); + } - // eslint-disable-next-line no-param-reassign - modifiedData.result = result; - // eslint-disable-next-line no-param-reassign - modifiedData.replaceNullPatches = [ - ...modifiedData.replaceNullPatches, - ...replaceNullPatches, - ]; - - return modifiedData; - }, - { - result: (existingValue ?? {}) as TChange, - replaceNullPatches: [], - }, - ); - } - - // If we have anything else we can't merge it so we'll - // simply return the last value that was queued - if (lastChange === undefined) { - return { result: (existingValue ?? {}) as TChange, replaceNullPatches: [] }; - } - return { result: lastChange, replaceNullPatches: [] }; + // If we have anything else we can't merge it so we'll + // simply return the last value that was queued + if (lastChange === undefined) { + return {result: (existingValue ?? {}) as TChange, replaceNullPatches: []}; + } + return {result: lastChange, replaceNullPatches: []}; } /** * Merge user provided default key value pairs. */ function initializeWithDefaultKeyStates(): Promise { - // Eagerly load the entire database into cache in a single batch read. - // This is faster than lazy-loading individual keys because: - // 1. One DB transaction instead of hundreds - // 2. All subsequent reads are synchronous cache hits - return Storage.getAll() - .then((pairs) => { - const allDataFromStorage: Record = {}; - for (const [key, value] of pairs) { - // RAM-only keys should not be cached from storage as they may have stale persisted data - // from before the key was migrated to RAM-only. - if (OnyxKeys.isRamOnlyKey(key)) { - continue; - } - - // Skip collection members that are marked as skippable - if ( - skippableCollectionMemberIDs.size && - OnyxKeys.getCollectionKey(key) - ) { - const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); - - if (skippableCollectionMemberIDs.has(collectionMemberID)) { - continue; - } - } + // Eagerly load the entire database into cache in a single batch read. + // This is faster than lazy-loading individual keys because: + // 1. One DB transaction instead of hundreds + // 2. All subsequent reads are synchronous cache hits + return Storage.getAll() + .then((pairs) => { + const allDataFromStorage: Record = {}; + for (const [key, value] of pairs) { + // RAM-only keys should not be cached from storage as they may have stale persisted data + // from before the key was migrated to RAM-only. + if (OnyxKeys.isRamOnlyKey(key)) { + continue; + } + + // Skip collection members that are marked as skippable + if (skippableCollectionMemberIDs.size && OnyxKeys.getCollectionKey(key)) { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); + + if (skippableCollectionMemberIDs.has(collectionMemberID)) { + continue; + } + } + + allDataFromStorage[key] = value; + } - allDataFromStorage[key] = value; - } - - // Load all storage data into cache silently (no subscriber notifications) - cache.setAllKeys(Object.keys(allDataFromStorage)); - cache.merge(allDataFromStorage); - - // For keys that have a developer-defined default (via `initialKeyStates`), merge the - // persisted value with the default so new properties added in code updates are applied - // without wiping user data that already exists in storage. - const defaultKeysFromStorage = Object.keys(defaultKeyStates).reduce( - (obj: Record, key) => { - if (key in allDataFromStorage) { - // eslint-disable-next-line no-param-reassign - obj[key] = allDataFromStorage[key]; - } - return obj; - }, - {}, - ); - - const merged = utils.fastMerge(defaultKeysFromStorage, defaultKeyStates, { - shouldRemoveNestedNulls: true, - }).result; - cache.merge(merged ?? {}); - - // Notify subscribers about default key states so that any subscriber that connected - // before init (e.g. during module load) receives the merged default values immediately - for (const [key, value] of Object.entries(merged ?? {})) { - keyChanged(key, value); - } - }) - .catch((error) => { - Logger.logAlert( - `Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`, - ); - - // Populate the key index so getAllKeys() returns correct results for default keys. - // Without this, subscribers that check getAllKeys() would see an empty set even - // though we have default values in cache. - cache.setAllKeys(Object.keys(defaultKeyStates)); - - // Boot with defaults so the app renders instead of deadlocking. - // Users will get a fresh-install experience but the app won't be bricked. - cache.merge(defaultKeyStates); - - // Notify subscribers about default key states so that any subscriber that connected - // before init (e.g. during module load) receives the merged default values immediately - for (const [key, value] of Object.entries(defaultKeyStates)) { - keyChanged(key, value); - } - }); + // Load all storage data into cache silently (no subscriber notifications) + cache.setAllKeys(Object.keys(allDataFromStorage)); + cache.merge(allDataFromStorage); + + // For keys that have a developer-defined default (via `initialKeyStates`), merge the + // persisted value with the default so new properties added in code updates are applied + // without wiping user data that already exists in storage. + const defaultKeysFromStorage = Object.keys(defaultKeyStates).reduce((obj: Record, key) => { + if (key in allDataFromStorage) { + // eslint-disable-next-line no-param-reassign + obj[key] = allDataFromStorage[key]; + } + return obj; + }, {}); + + const merged = utils.fastMerge(defaultKeysFromStorage, defaultKeyStates, { + shouldRemoveNestedNulls: true, + }).result; + cache.merge(merged ?? {}); + + // Notify subscribers about default key states so that any subscriber that connected + // before init (e.g. during module load) receives the merged default values immediately + for (const [key, value] of Object.entries(merged ?? {})) { + keyChanged(key, value); + } + }) + .catch((error) => { + Logger.logAlert(`Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`); + + // Populate the key index so getAllKeys() returns correct results for default keys. + // Without this, subscribers that check getAllKeys() would see an empty set even + // though we have default values in cache. + cache.setAllKeys(Object.keys(defaultKeyStates)); + + // Boot with defaults so the app renders instead of deadlocking. + // Users will get a fresh-install experience but the app won't be bricked. + cache.merge(defaultKeyStates); + + // Notify subscribers about default key states so that any subscriber that connected + // before init (e.g. during module load) receives the merged default values immediately + for (const [key, value] of Object.entries(defaultKeyStates)) { + keyChanged(key, value); + } + }); } /** * Validate the collection is not empty and has a correct type before applying mergeCollection() */ -function isValidNonEmptyCollectionForMerge( - collection: OnyxMergeCollectionInput, -): boolean { - return ( - typeof collection === "object" && - !Array.isArray(collection) && - !utils.isEmptyObject(collection) - ); +function isValidNonEmptyCollectionForMerge(collection: OnyxMergeCollectionInput): boolean { + return typeof collection === 'object' && !Array.isArray(collection) && !utils.isEmptyObject(collection); } /** * Verify if all the collection keys belong to the same parent */ -function doAllCollectionItemsBelongToSameParent( - collectionKey: TKey, - collectionKeys: string[], -): boolean { - let hasCollectionKeyCheckFailed = false; - for (const dataKey of collectionKeys) { - if (OnyxKeys.isKeyMatch(collectionKey, dataKey)) { - continue; - } +function doAllCollectionItemsBelongToSameParent(collectionKey: TKey, collectionKeys: string[]): boolean { + let hasCollectionKeyCheckFailed = false; + for (const dataKey of collectionKeys) { + if (OnyxKeys.isKeyMatch(collectionKey, dataKey)) { + continue; + } - if (process.env.NODE_ENV === "development") { - throw new Error( - `Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`, - ); - } + if (process.env.NODE_ENV === 'development') { + throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`); + } - hasCollectionKeyCheckFailed = true; - Logger.logAlert( - `Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`, - ); - } + hasCollectionKeyCheckFailed = true; + Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`); + } - return !hasCollectionKeyCheckFailed; + return !hasCollectionKeyCheckFailed; } /** @@ -1296,99 +1102,86 @@ function doAllCollectionItemsBelongToSameParent( * @param connectOptions The options object that will define the behavior of the connection. * @returns The subscription ID to use when calling `OnyxUtils.unsubscribeFromKey()`. */ -function subscribeToKey( - connectOptions: ConnectOptions, -): number { - const mapping = connectOptions as CallbackToStateMapping; - const subscriptionID = lastSubscriptionID++; - callbackToStateMapping[subscriptionID] = - mapping as CallbackToStateMapping; - callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID; - - // When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID - // to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key), - // We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs. - storeKeyBySubscriptions( - mapping.key, - callbackToStateMapping[subscriptionID].subscriptionID, - ); - - // Commit connection only after init passes - deferredInitTask.promise - // This first .then() adds a microtask tick for compatibility reasons and - // to ensure subscribers don't receive an extra initial callback before Onyx.update() data arrives. - .then(() => undefined) - .then(() => { - // Performance improvement - // If the mapping is connected to an onyx key that is not a collection - // we can skip the call to getAllKeys() and return an array with a single item - if ( - !!mapping.key && - typeof mapping.key === "string" && - !OnyxKeys.isCollectionKey(mapping.key) && - cache.getAllKeys().has(mapping.key) - ) { - return new Set([mapping.key]); - } - return getAllKeys(); - }) - .then((keys) => { - // We search all the keys in storage to see if any are a "match" for the subscriber we are connecting so that we - // can send data back to the subscriber. Note that multiple keys can match as a subscriber could either be - // subscribed to a "collection key" or a single key. - const matchingKeys: string[] = []; - - // Performance optimization: For single key subscriptions, avoid O(n) iteration - if (!OnyxKeys.isCollectionKey(mapping.key)) { - if (keys.has(mapping.key)) { - matchingKeys.push(mapping.key); - } - } else { - // Collection case - need to iterate through all keys to find matches (O(n)) - for (const key of keys) { - if (!OnyxKeys.isKeyMatch(mapping.key, key)) { - continue; - } - matchingKeys.push(key); - } - } - // If the key being connected to does not exist we initialize the value with null. For subscribers that connected - // directly via connect() they will simply get a null value sent to them without any information about which key matched - // since there are none matched. - if (matchingKeys.length === 0) { - if (mapping.key) { - cache.addNullishStorageKey(mapping.key); - } - - const matchedKey = OnyxKeys.isCollectionKey(mapping.key) - ? mapping.key - : undefined; - - // Here we cannot use batching because the nullish value is expected to be set immediately for default props - // or they will be undefined. - sendDataToConnection(mapping, matchedKey); - return; - } - - // When using a callback subscriber, a subscription to a collection key combines all matching - // member values into a single object and makes one call with the whole collection object. - if (typeof mapping.callback === "function") { - if (OnyxKeys.isCollectionKey(mapping.key)) { - getCollectionDataAndSendAsObject(matchingKeys, mapping); - return; - } +function subscribeToKey(connectOptions: ConnectOptions): number { + const mapping = connectOptions as CallbackToStateMapping; + const subscriptionID = lastSubscriptionID++; + callbackToStateMapping[subscriptionID] = mapping as CallbackToStateMapping; + callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID; + + // When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID + // to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key), + // We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs. + storeKeyBySubscriptions(mapping.key, callbackToStateMapping[subscriptionID].subscriptionID); + + // Commit connection only after init passes + deferredInitTask.promise + // This first .then() adds a microtask tick for compatibility reasons and + // to ensure subscribers don't receive an extra initial callback before Onyx.update() data arrives. + .then(() => undefined) + .then(() => { + // Performance improvement + // If the mapping is connected to an onyx key that is not a collection + // we can skip the call to getAllKeys() and return an array with a single item + if (!!mapping.key && typeof mapping.key === 'string' && !OnyxKeys.isCollectionKey(mapping.key) && cache.getAllKeys().has(mapping.key)) { + return new Set([mapping.key]); + } + return getAllKeys(); + }) + .then((keys) => { + // We search all the keys in storage to see if any are a "match" for the subscriber we are connecting so that we + // can send data back to the subscriber. Note that multiple keys can match as a subscriber could either be + // subscribed to a "collection key" or a single key. + const matchingKeys: string[] = []; + + // Performance optimization: For single key subscriptions, avoid O(n) iteration + if (!OnyxKeys.isCollectionKey(mapping.key)) { + if (keys.has(mapping.key)) { + matchingKeys.push(mapping.key); + } + } else { + // Collection case - need to iterate through all keys to find matches (O(n)) + for (const key of keys) { + if (!OnyxKeys.isKeyMatch(mapping.key, key)) { + continue; + } + matchingKeys.push(key); + } + } + // If the key being connected to does not exist we initialize the value with null. For subscribers that connected + // directly via connect() they will simply get a null value sent to them without any information about which key matched + // since there are none matched. + if (matchingKeys.length === 0) { + if (mapping.key) { + cache.addNullishStorageKey(mapping.key); + } + + const matchedKey = OnyxKeys.isCollectionKey(mapping.key) ? mapping.key : undefined; + + // Here we cannot use batching because the nullish value is expected to be set immediately for default props + // or they will be undefined. + sendDataToConnection(mapping, matchedKey); + return; + } - // If we are not subscribed to a collection key then there's only a single key to send an update for. - get(mapping.key).then(() => sendDataToConnection(mapping, mapping.key)); - return; - } + // When using a callback subscriber, a subscription to a collection key combines all matching + // member values into a single object and makes one call with the whole collection object. + if (typeof mapping.callback === 'function') { + if (OnyxKeys.isCollectionKey(mapping.key)) { + getCollectionDataAndSendAsObject(matchingKeys, mapping); + return; + } + + // If we are not subscribed to a collection key then there's only a single key to send an update for. + get(mapping.key).then(() => sendDataToConnection(mapping, mapping.key)); + return; + } - console.error("Warning: Onyx.connect() was found without a callback"); - }); + console.error('Warning: Onyx.connect() was found without a callback'); + }); - // The subscriptionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed - // by calling OnyxUtils.unsubscribeFromKey(subscriptionID). - return subscriptionID; + // The subscriptionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed + // by calling OnyxUtils.unsubscribeFromKey(subscriptionID). + return subscriptionID; } /** @@ -1397,92 +1190,81 @@ function subscribeToKey( * @param subscriptionID Subscription ID returned by calling `OnyxUtils.subscribeToKey()`. */ function unsubscribeFromKey(subscriptionID: number): void { - if (!callbackToStateMapping[subscriptionID]) { - return; - } + if (!callbackToStateMapping[subscriptionID]) { + return; + } - deleteKeyBySubscriptions(subscriptionID); - delete callbackToStateMapping[subscriptionID]; + deleteKeyBySubscriptions(subscriptionID); + delete callbackToStateMapping[subscriptionID]; } -function updateSnapshots( - data: Array>, - mergeFn: typeof Onyx.merge, -): Array<() => Promise> { - const snapshotCollectionKey = getSnapshotKey(); - if (!snapshotCollectionKey) return []; +function updateSnapshots(data: Array>, mergeFn: typeof Onyx.merge): Array<() => Promise> { + const snapshotCollectionKey = getSnapshotKey(); + if (!snapshotCollectionKey) return []; - const promises: Array<() => Promise> = []; + const promises: Array<() => Promise> = []; - const snapshotCollection = getCachedCollection(snapshotCollectionKey); + const snapshotCollection = getCachedCollection(snapshotCollectionKey); - for (const [snapshotEntryKey, snapshotEntryValue] of Object.entries( - snapshotCollection, - )) { - // Snapshots may not be present in cache. We don't know how to update them so we skip. - if (!snapshotEntryValue) { - continue; - } + for (const [snapshotEntryKey, snapshotEntryValue] of Object.entries(snapshotCollection)) { + // Snapshots may not be present in cache. We don't know how to update them so we skip. + if (!snapshotEntryValue) { + continue; + } - let updatedData: Record = {}; - - for (const { key, value } of data) { - // snapshots are normal keys so we want to skip update if they are written to Onyx - if (OnyxKeys.isCollectionMemberKey(snapshotCollectionKey, key)) { - continue; - } - - if ( - typeof snapshotEntryValue !== "object" || - !("data" in snapshotEntryValue) - ) { - continue; - } - - const snapshotData = snapshotEntryValue.data; - if (!snapshotData || !snapshotData[key]) { - continue; - } - - if (Array.isArray(value) || Array.isArray(snapshotData[key])) { - updatedData[key] = value ?? []; - continue; - } - - if (value === null) { - updatedData[key] = value; - continue; - } - - const oldValue = updatedData[key] ?? {}; - - // Snapshot entries are stored as a "shape" of the last known data per key, so by default we only - // merge fields that already exist in the snapshot to avoid unintentionally bloating snapshot data. - // Some clients need specific fields (like pending status) even when they are missing in the snapshot, - // so we allow an explicit, opt-in list of keys to always include during snapshot merges. - const snapshotExistingKeys = Object.keys(snapshotData[key] || {}); - const allowedNewKeys = getSnapshotMergeKeys(); - const keysToCopy = new Set([...snapshotExistingKeys, ...allowedNewKeys]); - const newValue = - typeof value === "object" && value !== null - ? utils.pick(value as Record, [...keysToCopy]) - : {}; - - updatedData = { - ...updatedData, - [key]: Object.assign(oldValue, newValue), - }; - } + let updatedData: Record = {}; - // Skip the update if there's no data to be merged - if (utils.isEmptyObject(updatedData)) { - continue; - } + for (const {key, value} of data) { + // snapshots are normal keys so we want to skip update if they are written to Onyx + if (OnyxKeys.isCollectionMemberKey(snapshotCollectionKey, key)) { + continue; + } + + if (typeof snapshotEntryValue !== 'object' || !('data' in snapshotEntryValue)) { + continue; + } + + const snapshotData = snapshotEntryValue.data; + if (!snapshotData || !snapshotData[key]) { + continue; + } + + if (Array.isArray(value) || Array.isArray(snapshotData[key])) { + updatedData[key] = value ?? []; + continue; + } + + if (value === null) { + updatedData[key] = value; + continue; + } + + const oldValue = updatedData[key] ?? {}; + + // Snapshot entries are stored as a "shape" of the last known data per key, so by default we only + // merge fields that already exist in the snapshot to avoid unintentionally bloating snapshot data. + // Some clients need specific fields (like pending status) even when they are missing in the snapshot, + // so we allow an explicit, opt-in list of keys to always include during snapshot merges. + const snapshotExistingKeys = Object.keys(snapshotData[key] || {}); + const allowedNewKeys = getSnapshotMergeKeys(); + const keysToCopy = new Set([...snapshotExistingKeys, ...allowedNewKeys]); + const newValue = typeof value === 'object' && value !== null ? utils.pick(value as Record, [...keysToCopy]) : {}; + + updatedData = { + ...updatedData, + [key]: Object.assign(oldValue, newValue), + }; + } + + // Skip the update if there's no data to be merged + if (utils.isEmptyObject(updatedData)) { + continue; + } - promises.push(() => mergeFn(snapshotEntryKey, { data: updatedData })); - } + promises.push(() => mergeFn(snapshotEntryKey, {data: updatedData})); + } - return promises; + return promises; } /** @@ -1496,120 +1278,84 @@ function updateSnapshots( * @param params.options optional configuration object * @param retryAttempt retry attempt */ -function setWithRetry( - { key, value, options }: SetParams, - retryAttempt?: number, -): Promise { - // When we use Onyx.set to set a key we want to clear the current delta changes from Onyx.merge that were queued - // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. - if (OnyxUtils.hasPendingMergeForKey(key)) { - delete OnyxUtils.getMergeQueue()[key]; - } - - if (skippableCollectionMemberIDs.size) { - try { - const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); - if (skippableCollectionMemberIDs.has(collectionMemberID)) { - // The key is a skippable one, so we set the new value to null. - // eslint-disable-next-line no-param-reassign - value = null; - } - } catch (e) { - // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. +function setWithRetry({key, value, options}: SetParams, retryAttempt?: number): Promise { + // When we use Onyx.set to set a key we want to clear the current delta changes from Onyx.merge that were queued + // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. + if (OnyxUtils.hasPendingMergeForKey(key)) { + delete OnyxUtils.getMergeQueue()[key]; } - } - - // Onyx.set will ignore `undefined` values as inputs, therefore we can return early. - if (value === undefined) { - return Promise.resolve(); - } - - const existingValue = cache.get(key); - - // If the existing value as well as the new value are null, we can return early. - if (existingValue === undefined && value === null) { - return Promise.resolve(); - } - - // Check if the value is compatible with the existing value in the storage - const { - isCompatible, - existingValueType, - newValueType, - isEmptyArrayCoercion, - } = utils.checkCompatibilityWithExistingValue(value, existingValue); - if (isEmptyArrayCoercion) { - // Setting an object over empty array isn't semantically correct, but we allow it - // in case we accidentally encoded an empty object as an empty array in PHP. If you're - // looking at a bugbot from this message, we're probably missing that key in OnyxKeys::KEYS_REQUIRING_EMPTY_OBJECT - Logger.logAlert( - `[ENSURE_BUGBOT] Onyx setWithRetry called on key "${key}" whose existing value is an empty array. Will coerce to object.`, - ); - } - if (!isCompatible) { - Logger.logAlert( - logMessages.incompatibleUpdateAlert( - key, - "set", - existingValueType, - newValueType, - ), - ); - return Promise.resolve(); - } - - // If the change is null, we can just delete the key. - // Therefore, we don't need to further broadcast and update the value so we can return early. - if (value === null) { - OnyxUtils.remove(key); - OnyxUtils.logKeyRemoved(OnyxUtils.METHOD.SET, key); - return Promise.resolve(); - } - - const valueWithoutNestedNullValues = utils.removeNestedNullValues( - value, - ) as OnyxValue; - const hasChanged = options?.skipCacheCheck - ? true - : cache.hasValueChanged(key, valueWithoutNestedNullValues); - - OnyxUtils.logKeyChanged(OnyxUtils.METHOD.SET, key, value, hasChanged); - - // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. - OnyxUtils.broadcastUpdate(key, valueWithoutNestedNullValues, hasChanged); - - // If the value has not changed and this isn't a retry attempt, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. - if (!hasChanged && !retryAttempt) { - return Promise.resolve(); - } - - // If a key is a RAM-only key or a member of RAM-only collection, we skip the step that modifies the storage - if (OnyxKeys.isRamOnlyKey(key)) { - OnyxUtils.sendActionToDevTools( - OnyxUtils.METHOD.SET, - key, - valueWithoutNestedNullValues, - ); - return Promise.resolve(); - } - - return Storage.setItem(key, valueWithoutNestedNullValues) - .then(() => StorageCircuitBreaker.recordWriteSuccess()) - .catch((error) => - OnyxUtils.retryOperation( - error, - setWithRetry, - { key, value: valueWithoutNestedNullValues, options }, - retryAttempt, - ), - ) - .then(() => { - OnyxUtils.sendActionToDevTools( - OnyxUtils.METHOD.SET, - key, - valueWithoutNestedNullValues, - ); - }); + + if (skippableCollectionMemberIDs.size) { + try { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); + if (skippableCollectionMemberIDs.has(collectionMemberID)) { + // The key is a skippable one, so we set the new value to null. + // eslint-disable-next-line no-param-reassign + value = null; + } + } catch (e) { + // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. + } + } + + // Onyx.set will ignore `undefined` values as inputs, therefore we can return early. + if (value === undefined) { + return Promise.resolve(); + } + + const existingValue = cache.get(key); + + // If the existing value as well as the new value are null, we can return early. + if (existingValue === undefined && value === null) { + return Promise.resolve(); + } + + // Check if the value is compatible with the existing value in the storage + const {isCompatible, existingValueType, newValueType, isEmptyArrayCoercion} = utils.checkCompatibilityWithExistingValue(value, existingValue); + if (isEmptyArrayCoercion) { + // Setting an object over empty array isn't semantically correct, but we allow it + // in case we accidentally encoded an empty object as an empty array in PHP. If you're + // looking at a bugbot from this message, we're probably missing that key in OnyxKeys::KEYS_REQUIRING_EMPTY_OBJECT + Logger.logAlert(`[ENSURE_BUGBOT] Onyx setWithRetry called on key "${key}" whose existing value is an empty array. Will coerce to object.`); + } + if (!isCompatible) { + Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'set', existingValueType, newValueType)); + return Promise.resolve(); + } + + // If the change is null, we can just delete the key. + // Therefore, we don't need to further broadcast and update the value so we can return early. + if (value === null) { + OnyxUtils.remove(key); + OnyxUtils.logKeyRemoved(OnyxUtils.METHOD.SET, key); + return Promise.resolve(); + } + + const valueWithoutNestedNullValues = utils.removeNestedNullValues(value) as OnyxValue; + const hasChanged = options?.skipCacheCheck ? true : cache.hasValueChanged(key, valueWithoutNestedNullValues); + + OnyxUtils.logKeyChanged(OnyxUtils.METHOD.SET, key, value, hasChanged); + + // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. + OnyxUtils.broadcastUpdate(key, valueWithoutNestedNullValues, hasChanged); + + // If the value has not changed and this isn't a retry attempt, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. + if (!hasChanged && !retryAttempt) { + return Promise.resolve(); + } + + // If a key is a RAM-only key or a member of RAM-only collection, we skip the step that modifies the storage + if (OnyxKeys.isRamOnlyKey(key)) { + OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues); + return Promise.resolve(); + } + + return Storage.setItem(key, valueWithoutNestedNullValues) + .then(() => StorageCircuitBreaker.recordWriteSuccess()) + .catch((error) => OnyxUtils.retryOperation(error, setWithRetry, {key, value: valueWithoutNestedNullValues, options}, retryAttempt)) + .then(() => { + OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues); + }); } /** @@ -1620,117 +1366,97 @@ function setWithRetry( * @param data object keyed by ONYXKEYS and the values to set * @param retryAttempt retry attempt */ -function multiSetWithRetry( - data: OnyxMultiSetInput, - retryAttempt?: number, -): Promise { - let newData = data; +function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Promise { + let newData = data; - if (skippableCollectionMemberIDs.size) { - newData = Object.keys(newData).reduce((result: OnyxMultiSetInput, key) => { - try { - const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); - // If the collection member key is a skippable one we set its value to null. - // eslint-disable-next-line no-param-reassign - result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) - ? newData[key] - : null; - } catch { - // The key is not a collection one or something went wrong during split, so we assign the data to result anyway. - // eslint-disable-next-line no-param-reassign - result[key] = newData[key]; - } + if (skippableCollectionMemberIDs.size) { + newData = Object.keys(newData).reduce((result: OnyxMultiSetInput, key) => { + try { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); + // If the collection member key is a skippable one we set its value to null. + // eslint-disable-next-line no-param-reassign + result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) ? newData[key] : null; + } catch { + // The key is not a collection one or something went wrong during split, so we assign the data to result anyway. + // eslint-disable-next-line no-param-reassign + result[key] = newData[key]; + } - return result; - }, {}); - } - - const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage( - newData, - true, - ); - - // Group collection members by their parent collection key so each collection can be notified - // via a single batched keysChanged() call instead of one keyChanged() per member. For each - // collection, `partial` holds the new values being set and `previous` holds the cached values - // from before the set, which keysChanged() uses to skip subscribers whose value didn't change. - const collectionBatches = new Map< - string, - { - partial: Record>; - previous: Record>; + return result; + }, {}); } - >(); - for (const [key, value] of keyValuePairsToSet) { - // When we use multiSet to set a key we want to clear the current delta changes from Onyx.merge that were queued - // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. - if (OnyxUtils.hasPendingMergeForKey(key)) { - delete OnyxUtils.getMergeQueue()[key]; - } + const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true); + + // Group collection members by their parent collection key so each collection can be notified + // via a single batched keysChanged() call instead of one keyChanged() per member. For each + // collection, `partial` holds the new values being set and `previous` holds the cached values + // from before the set, which keysChanged() uses to skip subscribers whose value didn't change. + const collectionBatches = new Map< + string, + { + partial: Record>; + previous: Record>; + } + >(); - const collectionKey = OnyxKeys.getCollectionKey(key); - if (collectionKey && OnyxKeys.isCollectionMemberKey(collectionKey, key)) { - // Capture the previous cached value BEFORE calling cache.set() so keysChanged() - // can diff old vs new per-member. - const previousValue = cache.get(key); - cache.set(key, value); - - let batch = collectionBatches.get(collectionKey); - if (!batch) { - batch = { partial: {}, previous: {} }; - collectionBatches.set(collectionKey, batch); - } - batch.partial[key] = value; - batch.previous[key] = previousValue; - } else { - // Non-collection keys are notified inline (cache.set + keyChanged in iteration order) - // so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache - // and subscriber state, matching the original per-key notification semantics. - cache.set(key, value); - // Skip subscriber notification on retry — already notified on attempt 0. - // Collection-root subscribers re-fire on every keyChanged by contract. - if (!retryAttempt) { - keyChanged(key, value); - } + for (const [key, value] of keyValuePairsToSet) { + // When we use multiSet to set a key we want to clear the current delta changes from Onyx.merge that were queued + // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. + if (OnyxUtils.hasPendingMergeForKey(key)) { + delete OnyxUtils.getMergeQueue()[key]; + } + + const collectionKey = OnyxKeys.getCollectionKey(key); + if (collectionKey && OnyxKeys.isCollectionMemberKey(collectionKey, key)) { + // Capture the previous cached value BEFORE calling cache.set() so keysChanged() + // can diff old vs new per-member. + const previousValue = cache.get(key); + cache.set(key, value); + + let batch = collectionBatches.get(collectionKey); + if (!batch) { + batch = {partial: {}, previous: {}}; + collectionBatches.set(collectionKey, batch); + } + batch.partial[key] = value; + batch.previous[key] = previousValue; + } else { + // Non-collection keys are notified inline (cache.set + keyChanged in iteration order) + // so re-entrant callbacks (e.g. Onyx.set inside a callback) see consistent cache + // and subscriber state, matching the original per-key notification semantics. + cache.set(key, value); + // Skip subscriber notification on retry — already notified on attempt 0. + // Collection-root subscribers re-fire on every keyChanged by contract. + if (!retryAttempt) { + keyChanged(key, value); + } + } } - } - - // One keysChanged() per collection — fires each collection-level subscriber once and lets - // keysChanged() internally decide which individual member subscribers need notification. - // Skip on retry — already notified on attempt 0 (see same-reason comment above). - if (!retryAttempt) { - for (const [collectionKey, batch] of collectionBatches) { - keysChanged(collectionKey, batch.partial, batch.previous); + + // One keysChanged() per collection — fires each collection-level subscriber once and lets + // keysChanged() internally decide which individual member subscribers need notification. + // Skip on retry — already notified on attempt 0 (see same-reason comment above). + if (!retryAttempt) { + for (const [collectionKey, batch] of collectionBatches) { + keysChanged(collectionKey, batch.partial, batch.previous); + } } - } - - const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => { - const [key] = keyValuePair; - // Filter out the RAM-only key value pairs, as they should not be saved to storage - return !OnyxKeys.isRamOnlyKey(key); - }); - - const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key)); - - return Storage.multiSet(keyValuePairsToStore) - .then(() => StorageCircuitBreaker.recordWriteSuccess()) - .catch((error) => - OnyxUtils.retryOperation( - error, - multiSetWithRetry, - newData, - retryAttempt, - inFlightKeys, - ), - ) - .then(() => { - OnyxUtils.sendActionToDevTools( - OnyxUtils.METHOD.MULTI_SET, - undefined, - newData, - ); + + const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => { + const [key] = keyValuePair; + // Filter out the RAM-only key value pairs, as they should not be saved to storage + return !OnyxKeys.isRamOnlyKey(key); }); + + const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key)); + + return Storage.multiSet(keyValuePairsToStore) + .then(() => StorageCircuitBreaker.recordWriteSuccess()) + .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys)) + .then(() => { + OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData); + }); } /** @@ -1744,113 +1470,74 @@ function multiSetWithRetry( * @param params.collection Object collection keyed by individual collection member keys and values * @param retryAttempt retry attempt */ -function setCollectionWithRetry( - { collectionKey, collection }: SetCollectionParams, - retryAttempt?: number, -): Promise { - let resultCollection: OnyxInputKeyValueMapping = collection; - let resultCollectionKeys = Object.keys(resultCollection); - - // Confirm all the collection keys belong to the same parent - if ( - !OnyxUtils.doAllCollectionItemsBelongToSameParent( - collectionKey, - resultCollectionKeys, - ) - ) { - Logger.logAlert( - `setCollection called with keys that do not belong to the same parent ${collectionKey}. Skipping this update.`, - ); - return Promise.resolve(); - } +function setCollectionWithRetry({collectionKey, collection}: SetCollectionParams, retryAttempt?: number): Promise { + let resultCollection: OnyxInputKeyValueMapping = collection; + let resultCollectionKeys = Object.keys(resultCollection); - if (skippableCollectionMemberIDs.size) { - resultCollection = resultCollectionKeys.reduce( - (result: OnyxInputKeyValueMapping, key) => { - try { - const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey( - key, - collectionKey, - ); - // If the collection member key is a skippable one we set its value to null. - // eslint-disable-next-line no-param-reassign - result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) - ? resultCollection[key] - : null; - } catch { - // Something went wrong during split, so we assign the data to result anyway. - // eslint-disable-next-line no-param-reassign - result[key] = resultCollection[key]; - } + // Confirm all the collection keys belong to the same parent + if (!OnyxUtils.doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) { + Logger.logAlert(`setCollection called with keys that do not belong to the same parent ${collectionKey}. Skipping this update.`); + return Promise.resolve(); + } - return result; - }, - {}, - ); - } - resultCollectionKeys = Object.keys(resultCollection); + if (skippableCollectionMemberIDs.size) { + resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => { + try { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key, collectionKey); + // If the collection member key is a skippable one we set its value to null. + // eslint-disable-next-line no-param-reassign + result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) ? resultCollection[key] : null; + } catch { + // Something went wrong during split, so we assign the data to result anyway. + // eslint-disable-next-line no-param-reassign + result[key] = resultCollection[key]; + } - return OnyxUtils.getAllKeys().then((persistedKeys) => { - const mutableCollection: OnyxInputKeyValueMapping = { ...resultCollection }; + return result; + }, {}); + } + resultCollectionKeys = Object.keys(resultCollection); - for (const key of persistedKeys) { - if (!key.startsWith(collectionKey)) { - continue; - } - if (resultCollectionKeys.includes(key)) { - continue; - } + return OnyxUtils.getAllKeys().then((persistedKeys) => { + const mutableCollection: OnyxInputKeyValueMapping = {...resultCollection}; - mutableCollection[key] = null; - } + for (const key of persistedKeys) { + if (!key.startsWith(collectionKey)) { + continue; + } + if (resultCollectionKeys.includes(key)) { + continue; + } - const keyValuePairs = OnyxUtils.prepareKeyValuePairsForStorage( - mutableCollection, - true, - undefined, - true, - ); - const previousCollection = OnyxUtils.getCachedCollection(collectionKey); + mutableCollection[key] = null; + } - for (const [key, value] of keyValuePairs) cache.set(key, value); + const keyValuePairs = OnyxUtils.prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true); + const previousCollection = OnyxUtils.getCachedCollection(collectionKey); - // Skip subscriber notification on retry — already notified on attempt 0. - // Collection-root subscribers re-fire on every keysChanged by contract. - if (!retryAttempt) { - keysChanged(collectionKey, mutableCollection, previousCollection); - } + for (const [key, value] of keyValuePairs) cache.set(key, value); - // RAM-only keys are not supposed to be saved to storage - if (OnyxKeys.isRamOnlyKey(collectionKey)) { - OnyxUtils.sendActionToDevTools( - OnyxUtils.METHOD.SET_COLLECTION, - undefined, - mutableCollection, - ); - return; - } + // Skip subscriber notification on retry — already notified on attempt 0. + // Collection-root subscribers re-fire on every keysChanged by contract. + if (!retryAttempt) { + keysChanged(collectionKey, mutableCollection, previousCollection); + } - const inFlightKeys = new Set(keyValuePairs.map(([key]) => key)); - - return Storage.multiSet(keyValuePairs) - .then(() => StorageCircuitBreaker.recordWriteSuccess()) - .catch((error) => - OnyxUtils.retryOperation( - error, - setCollectionWithRetry, - { collectionKey, collection }, - retryAttempt, - inFlightKeys, - ), - ) - .then(() => { - OnyxUtils.sendActionToDevTools( - OnyxUtils.METHOD.SET_COLLECTION, - undefined, - mutableCollection, - ); - }); - }); + // RAM-only keys are not supposed to be saved to storage + if (OnyxKeys.isRamOnlyKey(collectionKey)) { + OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection); + return; + } + + const inFlightKeys = new Set(keyValuePairs.map(([key]) => key)); + + return Storage.multiSet(keyValuePairs) + .then(() => StorageCircuitBreaker.recordWriteSuccess()) + .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt, inFlightKeys)) + .then(() => { + OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection); + }); + }); } /** @@ -1867,229 +1554,164 @@ function setCollectionWithRetry( * @param retryAttempt retry attempt */ function mergeCollectionWithPatches( - { - collectionKey, - collection, - mergeReplaceNullPatches, - isProcessingCollectionUpdate = false, - }: MergeCollectionWithPatchesParams, - retryAttempt?: number, + {collectionKey, collection, mergeReplaceNullPatches, isProcessingCollectionUpdate = false}: MergeCollectionWithPatchesParams, + retryAttempt?: number, ): Promise { - if (!isValidNonEmptyCollectionForMerge(collection)) { - Logger.logInfo( - "mergeCollection() called with invalid or empty value. Skipping this update.", - ); - return Promise.resolve(); - } - - let resultCollection: OnyxInputKeyValueMapping = collection; - let resultCollectionKeys = Object.keys(resultCollection); - - // Confirm all the collection keys belong to the same parent - if ( - !doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys) - ) { - return Promise.resolve(); - } - - if (skippableCollectionMemberIDs.size) { - resultCollection = resultCollectionKeys.reduce( - (result: OnyxInputKeyValueMapping, key) => { - try { - const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey( - key, - collectionKey, - ); - // If the collection member key is a skippable one we set its value to null. - // eslint-disable-next-line no-param-reassign - result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) - ? resultCollection[key] - : null; - } catch { - // Something went wrong during split, so we assign the data to result anyway. - // eslint-disable-next-line no-param-reassign - result[key] = resultCollection[key]; - } + if (!isValidNonEmptyCollectionForMerge(collection)) { + Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.'); + return Promise.resolve(); + } - return result; - }, - {}, - ); - } - resultCollectionKeys = Object.keys(resultCollection); - - return getAllKeys() - .then((persistedKeys) => { - // Split to keys that exist in storage and keys that don't - const keys = resultCollectionKeys.filter((key) => { - if (resultCollection[key] === null) { - remove(key, isProcessingCollectionUpdate); - return false; - } - return true; - }); - - const existingKeys = keys.filter((key) => persistedKeys.has(key)); - - const cachedCollectionForExistingKeys = getCachedCollection( - collectionKey, - existingKeys, - ); - - const existingKeyCollection = existingKeys.reduce( - (obj: OnyxInputKeyValueMapping, key) => { - const { - isCompatible, - existingValueType, - newValueType, - isEmptyArrayCoercion, - } = utils.checkCompatibilityWithExistingValue( - resultCollection[key], - cachedCollectionForExistingKeys[key], - ); - - if (isEmptyArrayCoercion) { - // Merging an object into an empty array isn't semantically correct, but we allow it - // in case we accidentally encoded an empty object as an empty array in PHP. If you're - // looking at a bugbot from this message, we're probably missing that key in OnyxKeys::KEYS_REQUIRING_EMPTY_OBJECT - Logger.logAlert( - `[ENSURE_BUGBOT] Onyx mergeCollection called on key "${key}" whose existing value is an empty array. Will coerce to object.`, - ); - } - if (!isCompatible) { - Logger.logAlert( - logMessages.incompatibleUpdateAlert( - key, - "mergeCollection", - existingValueType, - newValueType, - ), - ); - return obj; - } - - // eslint-disable-next-line no-param-reassign - obj[key] = resultCollection[key]; - return obj; - }, - {}, - ) as Record>; - - const newCollection: Record> = {}; - for (const key of keys) { - if (persistedKeys.has(key)) { - continue; - } - newCollection[key] = resultCollection[key]; - } - - // When (multi-)merging the values with the existing values in storage, - // we don't want to remove nested null values from the data that we pass to the storage layer, - // because the storage layer uses them to remove nested keys from storage natively. - const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage( - existingKeyCollection, - false, - mergeReplaceNullPatches, - ); - - // We can safely remove nested null values when using (multi-)set, - // because we will simply overwrite the existing values in storage. - const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage( - newCollection, - true, - ); - - // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates - const finalMergedCollection = { - ...existingKeyCollection, - ...newCollection, - }; - - // Pre-warm cache for cache-miss existingKeys so cache.merge() merges the new delta into - // the real previous storage value. Fast path (all warm) skips the pre-warm to preserve - // promise-chain depth; slow path batches the misses into one Storage.multiGet. - const hasColdExistingKey = existingKeys.some( - (key) => !cache.hasCacheForKey(key), - ); - // Swallow pre-warm read failures so a transient Storage.multiGet rejection doesn't - // skip the cache.merge() + keysChanged() below. Subscribers still see the merge even - // when storage reads fail. - const prewarmPromise = hasColdExistingKey - ? multiGet(existingKeys).catch((err) => - Logger.logInfo( - `mergeCollectionWithPatches pre-warm failed; proceeding with cache-only merge. Error: ${err}`, - ), - ) - : Promise.resolve(); - return prewarmPromise.then(() => { - // Snapshot previous values from the (now-warm) cache for keysChanged's diff, then update - // cache and notify subscribers synchronously BEFORE issuing storage writes. This matches - // the cache-first / storage-second invariant followed by every other Onyx write method - // (setWithRetry, applyMerge, setCollectionWithRetry, partialSetCollection, clear), - // ensuring subscribers still reflect the merged data even if the subsequent storage - // write fails. - const previousCollection = getCachedCollection( - collectionKey, - existingKeys, - ); - cache.merge(finalMergedCollection); - // Skip subscriber notification on retry — already notified on attempt 0. - // Collection-root subscribers re-fire on every keysChanged by contract. - if (!retryAttempt) { - keysChanged(collectionKey, finalMergedCollection, previousCollection); - } + let resultCollection: OnyxInputKeyValueMapping = collection; + let resultCollectionKeys = Object.keys(resultCollection); - const promises = []; - - // New keys go through multiSet and existing keys through multiMerge. multiMerge on a - // missing key stores the value just like multiSet across all backends; splitting them lets - // multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys). - // We can skip this step for RAM-only keys as they should never be saved to storage - if ( - !OnyxKeys.isRamOnlyKey(collectionKey) && - keyValuePairsForExistingCollection.length > 0 - ) { - promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); - } + // Confirm all the collection keys belong to the same parent + if (!doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) { + return Promise.resolve(); + } - // We can skip this step for RAM-only keys as they should never be saved to storage - if ( - !OnyxKeys.isRamOnlyKey(collectionKey) && - keyValuePairsForNewCollection.length > 0 - ) { - promises.push(Storage.multiSet(keyValuePairsForNewCollection)); - } + if (skippableCollectionMemberIDs.size) { + resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => { + try { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key, collectionKey); + // If the collection member key is a skippable one we set its value to null. + // eslint-disable-next-line no-param-reassign + result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) ? resultCollection[key] : null; + } catch { + // Something went wrong during split, so we assign the data to result anyway. + // eslint-disable-next-line no-param-reassign + result[key] = resultCollection[key]; + } - const inFlightKeys = new Set( - Object.keys(finalMergedCollection), - ); + return result; + }, {}); + } + resultCollectionKeys = Object.keys(resultCollection); + + return getAllKeys() + .then((persistedKeys) => { + // Split to keys that exist in storage and keys that don't + const keys = resultCollectionKeys.filter((key) => { + if (resultCollection[key] === null) { + remove(key, isProcessingCollectionUpdate); + return false; + } + return true; + }); + + const existingKeys = keys.filter((key) => persistedKeys.has(key)); + + const cachedCollectionForExistingKeys = getCachedCollection(collectionKey, existingKeys); + + const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => { + const {isCompatible, existingValueType, newValueType, isEmptyArrayCoercion} = utils.checkCompatibilityWithExistingValue( + resultCollection[key], + cachedCollectionForExistingKeys[key], + ); + + if (isEmptyArrayCoercion) { + // Merging an object into an empty array isn't semantically correct, but we allow it + // in case we accidentally encoded an empty object as an empty array in PHP. If you're + // looking at a bugbot from this message, we're probably missing that key in OnyxKeys::KEYS_REQUIRING_EMPTY_OBJECT + Logger.logAlert(`[ENSURE_BUGBOT] Onyx mergeCollection called on key "${key}" whose existing value is an empty array. Will coerce to object.`); + } + if (!isCompatible) { + Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType)); + return obj; + } + + // eslint-disable-next-line no-param-reassign + obj[key] = resultCollection[key]; + return obj; + }, {}) as Record>; + + const newCollection: Record> = {}; + for (const key of keys) { + if (persistedKeys.has(key)) { + continue; + } + newCollection[key] = resultCollection[key]; + } - return Promise.all(promises) - .then(() => StorageCircuitBreaker.recordWriteSuccess()) - .catch((error) => - retryOperation( - error, - mergeCollectionWithPatches, - { - collectionKey, - collection: resultCollection as OnyxMergeCollectionInput, - mergeReplaceNullPatches, - isProcessingCollectionUpdate, - }, - retryAttempt, - inFlightKeys, - ), - ) - .then(() => { - sendActionToDevTools( - METHOD.MERGE_COLLECTION, - undefined, - resultCollection, - ); - }); - }); - }) - .then(() => undefined); + // When (multi-)merging the values with the existing values in storage, + // we don't want to remove nested null values from the data that we pass to the storage layer, + // because the storage layer uses them to remove nested keys from storage natively. + const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches); + + // We can safely remove nested null values when using (multi-)set, + // because we will simply overwrite the existing values in storage. + const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true); + + // finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates + const finalMergedCollection = { + ...existingKeyCollection, + ...newCollection, + }; + + // Pre-warm cache for cache-miss existingKeys so cache.merge() merges the new delta into + // the real previous storage value. Fast path (all warm) skips the pre-warm to preserve + // promise-chain depth; slow path batches the misses into one Storage.multiGet. + const hasColdExistingKey = existingKeys.some((key) => !cache.hasCacheForKey(key)); + // Swallow pre-warm read failures so a transient Storage.multiGet rejection doesn't + // skip the cache.merge() + keysChanged() below. Subscribers still see the merge even + // when storage reads fail. + const prewarmPromise = hasColdExistingKey + ? multiGet(existingKeys).catch((err) => Logger.logInfo(`mergeCollectionWithPatches pre-warm failed; proceeding with cache-only merge. Error: ${err}`)) + : Promise.resolve(); + return prewarmPromise.then(() => { + // Snapshot previous values from the (now-warm) cache for keysChanged's diff, then update + // cache and notify subscribers synchronously BEFORE issuing storage writes. This matches + // the cache-first / storage-second invariant followed by every other Onyx write method + // (setWithRetry, applyMerge, setCollectionWithRetry, partialSetCollection, clear), + // ensuring subscribers still reflect the merged data even if the subsequent storage + // write fails. + const previousCollection = getCachedCollection(collectionKey, existingKeys); + cache.merge(finalMergedCollection); + // Skip subscriber notification on retry — already notified on attempt 0. + // Collection-root subscribers re-fire on every keysChanged by contract. + if (!retryAttempt) { + keysChanged(collectionKey, finalMergedCollection, previousCollection); + } + + const promises = []; + + // New keys go through multiSet and existing keys through multiMerge. multiMerge on a + // missing key stores the value just like multiSet across all backends; splitting them lets + // multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys). + // We can skip this step for RAM-only keys as they should never be saved to storage + if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { + promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); + } + + // We can skip this step for RAM-only keys as they should never be saved to storage + if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) { + promises.push(Storage.multiSet(keyValuePairsForNewCollection)); + } + + const inFlightKeys = new Set(Object.keys(finalMergedCollection)); + + return Promise.all(promises) + .then(() => StorageCircuitBreaker.recordWriteSuccess()) + .catch((error) => + retryOperation( + error, + mergeCollectionWithPatches, + { + collectionKey, + collection: resultCollection as OnyxMergeCollectionInput, + mergeReplaceNullPatches, + isProcessingCollectionUpdate, + }, + retryAttempt, + inFlightKeys, + ), + ) + .then(() => { + sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection); + }); + }); + }) + .then(() => undefined); } /** @@ -2102,179 +1724,133 @@ function mergeCollectionWithPatches( * @param params.collection Object collection keyed by individual collection member keys and values * @param retryAttempt retry attempt */ -function partialSetCollection( - { collectionKey, collection }: SetCollectionParams, - retryAttempt?: number, -): Promise { - let resultCollection: OnyxInputKeyValueMapping = collection; - let resultCollectionKeys = Object.keys(resultCollection); - - // Confirm all the collection keys belong to the same parent - if ( - !doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys) - ) { - Logger.logAlert( - `setCollection called with keys that do not belong to the same parent ${collectionKey}. Skipping this update.`, - ); - return Promise.resolve(); - } +function partialSetCollection({collectionKey, collection}: SetCollectionParams, retryAttempt?: number): Promise { + let resultCollection: OnyxInputKeyValueMapping = collection; + let resultCollectionKeys = Object.keys(resultCollection); - if (skippableCollectionMemberIDs.size) { - resultCollection = resultCollectionKeys.reduce( - (result: OnyxInputKeyValueMapping, key) => { - try { - const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey( - key, - collectionKey, - ); - // If the collection member key is a skippable one we set its value to null. - // eslint-disable-next-line no-param-reassign - result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) - ? resultCollection[key] - : null; - } catch { - // Something went wrong during split, so we assign the data to result anyway. - // eslint-disable-next-line no-param-reassign - result[key] = resultCollection[key]; - } + // Confirm all the collection keys belong to the same parent + if (!doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) { + Logger.logAlert(`setCollection called with keys that do not belong to the same parent ${collectionKey}. Skipping this update.`); + return Promise.resolve(); + } - return result; - }, - {}, - ); - } - resultCollectionKeys = Object.keys(resultCollection); + if (skippableCollectionMemberIDs.size) { + resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => { + try { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key, collectionKey); + // If the collection member key is a skippable one we set its value to null. + // eslint-disable-next-line no-param-reassign + result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) ? resultCollection[key] : null; + } catch { + // Something went wrong during split, so we assign the data to result anyway. + // eslint-disable-next-line no-param-reassign + result[key] = resultCollection[key]; + } - return getAllKeys().then((persistedKeys) => { - const mutableCollection: OnyxInputKeyValueMapping = { ...resultCollection }; - const existingKeys = resultCollectionKeys.filter((key) => - persistedKeys.has(key), - ); - const previousCollection = getCachedCollection(collectionKey, existingKeys); - const keyValuePairs = prepareKeyValuePairsForStorage( - mutableCollection, - true, - undefined, - true, - ); + return result; + }, {}); + } + resultCollectionKeys = Object.keys(resultCollection); - for (const [key, value] of keyValuePairs) cache.set(key, value); + return getAllKeys().then((persistedKeys) => { + const mutableCollection: OnyxInputKeyValueMapping = {...resultCollection}; + const existingKeys = resultCollectionKeys.filter((key) => persistedKeys.has(key)); + const previousCollection = getCachedCollection(collectionKey, existingKeys); + const keyValuePairs = prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true); - // Skip subscriber notification on retry — already notified on attempt 0. - // Collection-root subscribers re-fire on every keysChanged by contract. - if (!retryAttempt) { - keysChanged(collectionKey, mutableCollection, previousCollection); - } + for (const [key, value] of keyValuePairs) cache.set(key, value); - if (OnyxKeys.isRamOnlyKey(collectionKey)) { - sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); - return; - } + // Skip subscriber notification on retry — already notified on attempt 0. + // Collection-root subscribers re-fire on every keysChanged by contract. + if (!retryAttempt) { + keysChanged(collectionKey, mutableCollection, previousCollection); + } - const inFlightKeys = new Set(keyValuePairs.map(([key]) => key)); - - return Storage.multiSet(keyValuePairs) - .then(() => StorageCircuitBreaker.recordWriteSuccess()) - .catch((error) => - retryOperation( - error, - partialSetCollection, - { collectionKey, collection }, - retryAttempt, - inFlightKeys, - ), - ) - .then(() => { - sendActionToDevTools( - METHOD.SET_COLLECTION, - undefined, - mutableCollection, - ); - }); - }); + if (OnyxKeys.isRamOnlyKey(collectionKey)) { + sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); + return; + } + + const inFlightKeys = new Set(keyValuePairs.map(([key]) => key)); + + return Storage.multiSet(keyValuePairs) + .then(() => StorageCircuitBreaker.recordWriteSuccess()) + .catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt, inFlightKeys)) + .then(() => { + sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); + }); + }); } -function logKeyChanged( - onyxMethod: Extract, - key: OnyxKey, - value: unknown, - hasChanged: boolean, -) { - Logger.logInfo( - `${onyxMethod} called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(",")}` : ""} hasChanged: ${hasChanged}`, - ); +function logKeyChanged(onyxMethod: Extract, key: OnyxKey, value: unknown, hasChanged: boolean) { + Logger.logInfo(`${onyxMethod} called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`); } -function logKeyRemoved( - onyxMethod: Extract, - key: OnyxKey, -) { - Logger.logInfo( - `${onyxMethod} called for key: ${key} => null passed, so key was removed`, - ); +function logKeyRemoved(onyxMethod: Extract, key: OnyxKey) { + Logger.logInfo(`${onyxMethod} called for key: ${key} => null passed, so key was removed`); } /** * Clear internal variables used in this file, useful in test environments. */ function clearOnyxUtilsInternals() { - mergeQueue = {}; - mergeQueuePromise = {}; - callbackToStateMapping = {}; - onyxKeyToSubscriptionIDs = new Map(); - lastConnectionCallbackData = new Map(); + mergeQueue = {}; + mergeQueuePromise = {}; + callbackToStateMapping = {}; + onyxKeyToSubscriptionIDs = new Map(); + lastConnectionCallbackData = new Map(); } const OnyxUtils = { - METHOD, - getMergeQueue, - getMergeQueuePromise, - getDefaultKeyStates, - getDeferredInitTask, - afterInit, - initStoreValues, - sendActionToDevTools, - get, - getAllKeys, - tryGetCachedValue, - getCachedCollection, - keysChanged, - keyChanged, - sendDataToConnection, - getCollectionDataAndSendAsObject, - remove, - reportStorageQuota, - retryOperation, - broadcastUpdate, - hasPendingMergeForKey, - prepareKeyValuePairsForStorage, - mergeChanges, - mergeAndMarkChanges, - initializeWithDefaultKeyStates, - getSnapshotKey, - multiGet, - tupleGet, - isValidNonEmptyCollectionForMerge, - doAllCollectionItemsBelongToSameParent, - subscribeToKey, - unsubscribeFromKey, - getSkippableCollectionMemberIDs, - setSkippableCollectionMemberIDs, - getSnapshotMergeKeys, - setSnapshotMergeKeys, - storeKeyBySubscriptions, - deleteKeyBySubscriptions, - reduceCollectionWithSelector, - updateSnapshots, - mergeCollectionWithPatches, - partialSetCollection, - logKeyChanged, - logKeyRemoved, - setWithRetry, - multiSetWithRetry, - setCollectionWithRetry, + METHOD, + getMergeQueue, + getMergeQueuePromise, + getDefaultKeyStates, + getDeferredInitTask, + afterInit, + initStoreValues, + sendActionToDevTools, + get, + getAllKeys, + tryGetCachedValue, + getCachedCollection, + keysChanged, + keyChanged, + sendDataToConnection, + getCollectionDataAndSendAsObject, + remove, + reportStorageQuota, + retryOperation, + broadcastUpdate, + hasPendingMergeForKey, + prepareKeyValuePairsForStorage, + mergeChanges, + mergeAndMarkChanges, + initializeWithDefaultKeyStates, + getSnapshotKey, + multiGet, + tupleGet, + isValidNonEmptyCollectionForMerge, + doAllCollectionItemsBelongToSameParent, + subscribeToKey, + unsubscribeFromKey, + getSkippableCollectionMemberIDs, + setSkippableCollectionMemberIDs, + getSnapshotMergeKeys, + setSnapshotMergeKeys, + storeKeyBySubscriptions, + deleteKeyBySubscriptions, + reduceCollectionWithSelector, + updateSnapshots, + mergeCollectionWithPatches, + partialSetCollection, + logKeyChanged, + logKeyRemoved, + setWithRetry, + multiSetWithRetry, + setCollectionWithRetry, }; -export type { OnyxMethod }; +export type {OnyxMethod}; export default OnyxUtils; -export { clearOnyxUtilsInternals }; +export {clearOnyxUtilsInternals}; diff --git a/lib/lodash.bindall.d.ts b/lib/lodash.bindall.d.ts index 14126e85d..21433f245 100644 --- a/lib/lodash.bindall.d.ts +++ b/lib/lodash.bindall.d.ts @@ -1,7 +1,4 @@ -declare module "lodash.bindall" { - function bindAll( - object: T, - ...methodNames: Array - ): T; - export default bindAll; +declare module 'lodash.bindall' { + function bindAll(object: T, ...methodNames: Array): T; + export default bindAll; } diff --git a/lib/types.ts b/lib/types.ts index e9d99c7f6..fe63a30e3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,7 +1,7 @@ -import type {Merge} from 'type-fest'; -import type OnyxUtils from './OnyxUtils'; -import type {OnyxMethod} from './OnyxUtils'; -import type {FastMergeReplaceNullPatch} from './utils'; +import type { Merge } from "type-fest"; +import type OnyxUtils from "./OnyxUtils"; +import type { OnyxMethod } from "./OnyxUtils"; +import type { FastMergeReplaceNullPatch } from "./utils"; /** * Utility type that excludes `null` from the type `TValue`. @@ -18,7 +18,7 @@ type NonUndefined = TValue extends undefined ? never : TValue; * and those values can either be of type `TValue` or further nested `DeepRecord` instances. */ type DeepRecord = { - [key: string]: TValue | DeepRecord; + [key: string]: TValue | DeepRecord; }; /** @@ -34,12 +34,12 @@ type DeepRecord = { * In case of conflicting properties, the ones from CustomTypeOptions are prioritized. */ type TypeOptions = Merge< - { - keys: string; - collectionKeys: string; - values: Record; - }, - CustomTypeOptions + { + keys: string; + collectionKeys: string; + values: Record; + }, + CustomTypeOptions >; /** @@ -88,17 +88,18 @@ type TypeOptions = Merge< * } * ``` */ -type CustomTypeOptions = Record; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- must remain an interface so consumers can augment via declare module +interface CustomTypeOptions {} /** * Represents a string union of all Onyx normal keys. */ -type Key = TypeOptions['keys']; +type Key = TypeOptions["keys"]; /** * Represents a string union of all Onyx collection keys. */ -type CollectionKeyBase = TypeOptions['collectionKeys']; +type CollectionKeyBase = TypeOptions["collectionKeys"]; /** * Represents a literal string union of all Onyx collection keys. @@ -120,7 +121,9 @@ type OnyxKey = Key | CollectionKey; * The type `TKey` extends `OnyxKey` and it is the key used to access a value in `KeyValueMapping`. * `TReturnType` is the type of the returned value from the selector function. */ -type Selector = (value: OnyxEntry) => TReturnType; +type Selector = ( + value: OnyxEntry, +) => TReturnType; /** * Represents a single Onyx entry, that can be either `TOnyxValue` or `undefined` if it doesn't exist. @@ -132,7 +135,9 @@ type OnyxEntry = TOnyxValue | undefined; * Represents an Onyx collection of entries, that can be either a record of `TOnyxValue`s or `undefined` if it is empty or doesn't exist. * It can be used to specify collection data retrieved from Onyx. */ -type OnyxCollection = OnyxEntry>; +type OnyxCollection = OnyxEntry< + Record +>; /** * Represents a mapping of Onyx keys to values, where keys are either normal or collection Onyx keys @@ -144,31 +149,38 @@ type OnyxCollection = OnyxEntry = string extends TKey ? unknown : TKey extends CollectionKeyBase ? OnyxCollection : OnyxEntry; +type OnyxValue = string extends TKey + ? unknown + : TKey extends CollectionKeyBase + ? OnyxCollection + : OnyxEntry; /** Utility type to extract `TOnyxValue` from `OnyxCollection` */ -type ExtractOnyxCollectionValue = TOnyxCollection extends NonNullable> ? U : never; +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 - | ((...args: any[]) => unknown) - | Map - | Set - | ReadonlyMap - | ReadonlySet - | unknown[] - | readonly unknown[]; + | BuiltIns + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | ((...args: any[]) => unknown) + | Map + | Set + | ReadonlyMap + | ReadonlySet + | unknown[] + | readonly unknown[]; /** * Create a type from another type with all keys and nested keys set to optional or null. @@ -189,13 +201,17 @@ type NonTransformableTypes = * * settings = applySavedSettings({textEditor: {fontWeight: 500, fontColor: null}}); */ -type NullishDeep = T extends NonTransformableTypes ? T : T extends Record ? NullishObjectDeep : T; +type NullishDeep = T extends NonTransformableTypes + ? T + : T extends Record + ? NullishObjectDeep + : T; /** * Same as `NullishDeep`, but accepts only records as inputs. Internal helper for `NullishDeep`. */ type NullishObjectDeep> = { - [KeyType in keyof ObjectType]?: NullishDeep | null; + [KeyType in keyof ObjectType]?: NullishDeep | null; }; /** @@ -207,23 +223,32 @@ 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 = Record<`${TKey}${string}`, TValue>; +type Collection = Record< + `${TKey}${string}`, + TValue +>; /** 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! type BaseConnectOptions = { - /** - * If set to `false`, the connection won't be reused between other subscribers that are listening to the same Onyx key - * with the same connect configurations. - */ - reuseConnection?: boolean; + /** + * If set to `false`, the connection won't be reused between other subscribers that are listening to the same Onyx key + * with the same connect configurations. + */ + reuseConnection?: boolean; }; /** Represents the callback function used in `Onyx.connect()` method with a regular key. */ -type DefaultConnectCallback = (value: OnyxEntry, key: TKey) => void; +type DefaultConnectCallback = ( + value: OnyxEntry, + key: TKey, +) => void; /** Represents the callback function used in `Onyx.connect()` method with a collection key. */ -type CollectionConnectCallback = (value: NonUndefined>, key: TKey) => void; +type CollectionConnectCallback = ( + value: NonUndefined>, + key: TKey, +) => void; /** * Represents the options used in `Onyx.connect()` method. @@ -235,15 +260,20 @@ type CollectionConnectCallback = (value: NonUndefined = BaseConnectOptions & { - /** The Onyx key to subscribe to. */ - key: TKey; - - /** A function that will be called when the Onyx data we are subscribed changes. */ - callback?: (value: TKey extends CollectionKeyBase ? NonUndefined> : OnyxEntry, key: TKey) => void; + /** The Onyx key to subscribe to. */ + key: TKey; + + /** A function that will be called when the Onyx data we are subscribed changes. */ + callback?: ( + value: TKey extends CollectionKeyBase + ? NonUndefined> + : OnyxEntry, + key: TKey, + ) => void; }; type CallbackToStateMapping = ConnectOptions & { - subscriptionID: number; + subscriptionID: number; }; /** @@ -255,14 +285,18 @@ type OnyxInputValue = TOnyxValue | null; /** * Represents an Onyx collection input, that can be either a record of `TOnyxValue`s or `null` if the key should be deleted. */ -type OnyxCollectionInputValue = OnyxInputValue>; +type OnyxCollectionInputValue = OnyxInputValue< + Record +>; /** * Represents an input value that can be passed to Onyx methods, that can be either `TOnyxValue` or `null`. * Setting a key to `null` will remove the key from the store. * `undefined` is not allowed for setting values, because it will have no effect on the data. */ -type OnyxInput = OnyxInputValue>; +type OnyxInput = OnyxInputValue< + NullishDeep +>; /** * Represents a mapping object where each `OnyxKey` maps to either a value of its corresponding type in `KeyValueMapping` or `null`. @@ -271,7 +305,7 @@ type OnyxInput = OnyxInputValue; + [TKey in OnyxKey]: OnyxInput; }; /** @@ -292,16 +326,24 @@ 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>>; +type OnyxMergeCollectionInput = Collection< + TKey, + NonNullable> +>; /** * This represents the value that can be passed to `Onyx.setCollection` and to `Onyx.update` with the method "SET_COLLECTION" */ -type OnyxSetCollectionInput = Collection>; +type OnyxSetCollectionInput = Collection< + TKey, + OnyxInput +>; type OnyxMethodMap = typeof OnyxUtils.METHOD; -type ExpandOnyxKeys = TKey extends CollectionKeyBase ? NoInfer<`${TKey}${string}`> : TKey; +type ExpandOnyxKeys = TKey extends CollectionKeyBase + ? NoInfer<`${TKey}${string}`> + : TKey; /** * OnyxUpdate type includes all onyx methods used in OnyxMethodValueMap. @@ -309,122 +351,122 @@ type ExpandOnyxKeys = TKey extends CollectionKeyBase ? NoI * Otherwise it will show static type errors. */ type OnyxUpdate = { - // ⚠️ DO NOT CHANGE THIS TYPE, UNLESS YOU KNOW WHAT YOU ARE DOING. ⚠️ - [K in TKey]: - | { - onyxMethod: typeof OnyxUtils.METHOD.SET; - key: ExpandOnyxKeys; - value: OnyxSetInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; - key: ExpandOnyxKeys; - value: OnyxMultiSetInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.MERGE; - key: ExpandOnyxKeys; - value: OnyxMergeInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.CLEAR; - key: ExpandOnyxKeys; - value?: never; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; - key: K; - value: OnyxMergeCollectionInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.SET_COLLECTION; - key: K; - value: OnyxSetCollectionInput; - }; + // ⚠️ DO NOT CHANGE THIS TYPE, UNLESS YOU KNOW WHAT YOU ARE DOING. ⚠️ + [K in TKey]: + | { + onyxMethod: typeof OnyxUtils.METHOD.SET; + key: ExpandOnyxKeys; + value: OnyxSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; + key: ExpandOnyxKeys; + value: OnyxMultiSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE; + key: ExpandOnyxKeys; + value: OnyxMergeInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.CLEAR; + key: ExpandOnyxKeys; + value?: never; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; + key: K; + value: OnyxMergeCollectionInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.SET_COLLECTION; + key: K; + value: OnyxSetCollectionInput; + }; }[TKey]; /** * Represents the options used in `Onyx.set()` method. */ type SetOptions = { - /** Skip the deep equality check against the cached value. Improves performance for large objects. */ - skipCacheCheck?: boolean; + /** Skip the deep equality check against the cached value. Improves performance for large objects. */ + skipCacheCheck?: boolean; }; type SetParams = { - key: TKey; - value: OnyxSetInput; - options?: SetOptions; + key: TKey; + value: OnyxSetInput; + options?: SetOptions; }; type SetCollectionParams = { - collectionKey: TKey; - collection: OnyxSetCollectionInput; + collectionKey: TKey; + collection: OnyxSetCollectionInput; }; type MergeCollectionWithPatchesParams = { - collectionKey: TKey; - collection: OnyxMergeCollectionInput; - mergeReplaceNullPatches?: MultiMergeReplaceNullPatches; - isProcessingCollectionUpdate?: boolean; + collectionKey: TKey; + collection: OnyxMergeCollectionInput; + mergeReplaceNullPatches?: MultiMergeReplaceNullPatches; + isProcessingCollectionUpdate?: boolean; }; type RetriableOnyxOperation = - | typeof OnyxUtils.setWithRetry - | typeof OnyxUtils.multiSetWithRetry - | typeof OnyxUtils.setCollectionWithRetry - | typeof OnyxUtils.mergeCollectionWithPatches - | typeof OnyxUtils.partialSetCollection; + | typeof OnyxUtils.setWithRetry + | typeof OnyxUtils.multiSetWithRetry + | typeof OnyxUtils.setCollectionWithRetry + | typeof OnyxUtils.mergeCollectionWithPatches + | typeof OnyxUtils.partialSetCollection; /** * Represents the options used in `Onyx.init()` method. */ type InitOptions = { - /** `ONYXKEYS` constants object */ - keys?: DeepRecord; - - /** initial data to set when `init()` and `clear()` is called */ - initialKeyStates?: Partial; - - /** - * This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged - * as "safe" for removal. - */ - evictableKeys?: OnyxKey[]; - - /** - * Auto synchronize storage events between multiple instances - * of Onyx running in different tabs/windows. Defaults to true for platforms that support local storage (web/desktop) - */ - shouldSyncMultipleInstances?: boolean; - - /** - * If enabled, it will connect to Redux DevTools Extension for debugging. - * This allows you to see all Onyx state changes in the Redux DevTools. - * @default true - */ - enableDevTools?: boolean; - - /** - * Array of collection member IDs that Onyx should silently ignore across all operations. - * This prevents keys formed from invalid or default IDs (e.g. "-1", "0", "undefined") from - * polluting cache or triggering subscriber notifications. - */ - skippableCollectionMemberIDs?: string[]; - - /** - * Array of keys that when provided to Onyx are flagged as RAM-only keys, and thus are not saved to disk. - */ - ramOnlyKeys?: OnyxKey[]; - - /** - * A list of field names that should always be merged into snapshot entries even if those fields are - * missing in the snapshot. Snapshots are saved "views" of a key's data used to populate read-only - * or cached lists, and by default Onyx only merges fields that already exist in that saved view. - * Use this to opt-in to additional fields that must appear in snapshots (for example, pending flags) - * without hardcoding app-specific logic inside Onyx. - */ - snapshotMergeKeys?: string[]; + /** `ONYXKEYS` constants object */ + keys?: DeepRecord; + + /** initial data to set when `init()` and `clear()` is called */ + initialKeyStates?: Partial; + + /** + * This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged + * as "safe" for removal. + */ + evictableKeys?: OnyxKey[]; + + /** + * Auto synchronize storage events between multiple instances + * of Onyx running in different tabs/windows. Defaults to true for platforms that support local storage (web/desktop) + */ + shouldSyncMultipleInstances?: boolean; + + /** + * If enabled, it will connect to Redux DevTools Extension for debugging. + * This allows you to see all Onyx state changes in the Redux DevTools. + * @default true + */ + enableDevTools?: boolean; + + /** + * Array of collection member IDs that Onyx should silently ignore across all operations. + * This prevents keys formed from invalid or default IDs (e.g. "-1", "0", "undefined") from + * polluting cache or triggering subscriber notifications. + */ + skippableCollectionMemberIDs?: string[]; + + /** + * Array of keys that when provided to Onyx are flagged as RAM-only keys, and thus are not saved to disk. + */ + ramOnlyKeys?: OnyxKey[]; + + /** + * A list of field names that should always be merged into snapshot entries even if those fields are + * missing in the snapshot. Snapshots are saved "views" of a key's data used to populate read-only + * or cached lists, and by default Onyx only merges fields that already exist in that saved view. + * Use this to opt-in to additional fields that must appear in snapshots (for example, pending flags) + * without hardcoding app-specific logic inside Onyx. + */ + snapshotMergeKeys?: string[]; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -434,58 +476,61 @@ type GenericFunction = (...args: any[]) => any; * Represents a 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. */ -type MultiMergeReplaceNullPatches = Record; +type MultiMergeReplaceNullPatches = Record< + OnyxKey, + FastMergeReplaceNullPatch[] +>; /** * Represents a combination of Merge and Set operations that should be executed in Onyx */ type MixedOperationsQueue = { - merge: OnyxInputKeyValueMapping; - mergeReplaceNullPatches: MultiMergeReplaceNullPatches; - set: OnyxInputKeyValueMapping; + merge: OnyxInputKeyValueMapping; + mergeReplaceNullPatches: MultiMergeReplaceNullPatches; + set: OnyxInputKeyValueMapping; }; export type { - BaseConnectOptions, - Collection, - CollectionConnectCallback, - CollectionKey, - CollectionKeyBase, - ConnectOptions, - CustomTypeOptions, - DeepRecord, - DefaultConnectCallback, - ExtractOnyxCollectionValue, - GenericFunction, - InitOptions, - Key, - KeyValueMapping, - CallbackToStateMapping, - NonNull, - NonUndefined, - OnyxInputKeyValueMapping, - NullishDeep, - OnyxCollection, - OnyxEntry, - OnyxKey, - OnyxInputValue, - OnyxCollectionInputValue, - OnyxInput, - OnyxSetInput, - OnyxMultiSetInput, - OnyxMergeInput, - OnyxMergeCollectionInput, - OnyxSetCollectionInput, - OnyxMethod, - OnyxMethodMap, - OnyxUpdate, - OnyxValue, - Selector, - SetOptions, - SetParams, - SetCollectionParams, - MergeCollectionWithPatchesParams, - MultiMergeReplaceNullPatches, - MixedOperationsQueue, - RetriableOnyxOperation, + BaseConnectOptions, + Collection, + CollectionConnectCallback, + CollectionKey, + CollectionKeyBase, + ConnectOptions, + CustomTypeOptions, + DeepRecord, + DefaultConnectCallback, + ExtractOnyxCollectionValue, + GenericFunction, + InitOptions, + Key, + KeyValueMapping, + CallbackToStateMapping, + NonNull, + NonUndefined, + OnyxInputKeyValueMapping, + NullishDeep, + OnyxCollection, + OnyxEntry, + OnyxKey, + OnyxInputValue, + OnyxCollectionInputValue, + OnyxInput, + OnyxSetInput, + OnyxMultiSetInput, + OnyxMergeInput, + OnyxMergeCollectionInput, + OnyxSetCollectionInput, + OnyxMethod, + OnyxMethodMap, + OnyxUpdate, + OnyxValue, + Selector, + SetOptions, + SetParams, + SetCollectionParams, + MergeCollectionWithPatchesParams, + MultiMergeReplaceNullPatches, + MixedOperationsQueue, + RetriableOnyxOperation, }; diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index 6c5e0333c..92f1cbb39 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -69,11 +69,11 @@ describe('DevTools', () => { }); it('Sets the default state correctly', () => { const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.defaultState).toEqual(initialKeyStates); + expect(devToolsInstance['defaultState']).toEqual(initialKeyStates); }); it('Sets the internal state correctly', () => { const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.state).toEqual(initialKeyStates); + expect(devToolsInstance['state']).toEqual(initialKeyStates); }); }); @@ -91,7 +91,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.set(ONYX_KEYS.SOME_KEY, 3); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.state).toEqual({ + expect(devToolsInstance['state']).toEqual({ ...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3, }); @@ -112,7 +112,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.state).toEqual(mergedObject); + expect(devToolsInstance['state']).toEqual(mergedObject); }); }); @@ -130,7 +130,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.state).toEqual(mergedCollection); + expect(devToolsInstance['state']).toEqual(mergedCollection); }); }); @@ -148,7 +148,7 @@ describe('DevTools', () => { it('Sets the internal state correctly', async () => { await Onyx.multiSet(exampleCollection); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.state).toEqual(mergedCollection); + expect(devToolsInstance['state']).toEqual(mergedCollection); }); }); @@ -166,7 +166,7 @@ describe('DevTools', () => { await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); await Onyx.clear([ONYX_KEYS.NUM_KEY]); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.state).toEqual({ + expect(devToolsInstance['state']).toEqual({ ...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2, }); @@ -176,7 +176,7 @@ describe('DevTools', () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance.state).toEqual({ + expect(devToolsInstance['state']).toEqual({ ...initialKeyStates, ...exampleCollection, }); diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index 648a0c874..5b5b87b88 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -1,610 +1,570 @@ -import { act } from "@testing-library/react-native"; -import Onyx from "../../lib"; -import type { Connection } from "../../lib/OnyxConnectionManager"; -import connectionManager from "../../lib/OnyxConnectionManager"; -import StorageMock from "../../lib/storage"; -import waitForPromisesToResolve from "../utils/waitForPromisesToResolve"; +import {act} from '@testing-library/react-native'; +import Onyx from '../../lib'; +import type {Connection} from '../../lib/OnyxConnectionManager'; +import connectionManager from '../../lib/OnyxConnectionManager'; +import StorageMock from '../../lib/storage'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -const connectionsMap = connectionManager.connectionsMap; -const generateConnectionID = - connectionManager.generateConnectionID.bind(connectionManager); -const getSessionID = () => connectionManager.sessionID; +// eslint-disable-next-line dot-notation +const connectionsMap = connectionManager['connectionsMap']; +// eslint-disable-next-line dot-notation +const generateConnectionID = connectionManager['generateConnectionID'].bind(connectionManager); +// eslint-disable-next-line dot-notation +const getSessionID = () => connectionManager['sessionID']; const ONYXKEYS = { - TEST_KEY: "test", - TEST_KEY_2: "test2", - COLLECTION: { - TEST_KEY: "test_", - TEST_KEY_2: "test2_", - }, + TEST_KEY: 'test', + TEST_KEY_2: 'test2', + COLLECTION: { + TEST_KEY: 'test_', + TEST_KEY_2: 'test2_', + }, }; Onyx.init({ - keys: ONYXKEYS, + keys: ONYXKEYS, }); beforeEach(() => Onyx.clear()); -describe("OnyxConnectionManager", () => { - // Always use a "fresh" instance - beforeEach(() => { - connectionManager.disconnectAll(); - }); - - describe("generateConnectionID", () => { - it("should generate a stable connection ID", async () => { - const connectionID = generateConnectionID({ key: ONYXKEYS.TEST_KEY }); - expect(connectionID).toEqual( - `onyxKey=${ONYXKEYS.TEST_KEY},sessionID=${getSessionID()}`, - ); +describe('OnyxConnectionManager', () => { + // Always use a "fresh" instance + beforeEach(() => { + connectionManager.disconnectAll(); }); - it("should generate a stable, reusable connection ID for collection keys", async () => { - const connectionID = generateConnectionID({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - }); - expect(connectionID).toEqual( - `onyxKey=${ONYXKEYS.COLLECTION.TEST_KEY},sessionID=${getSessionID()}`, - ); - }); + describe('generateConnectionID', () => { + it('should generate a stable connection ID', async () => { + const connectionID = generateConnectionID({key: ONYXKEYS.TEST_KEY}); + expect(connectionID).toEqual(`onyxKey=${ONYXKEYS.TEST_KEY},sessionID=${getSessionID()}`); + }); - it("should generate unique connection IDs if certain options are passed", async () => { - const connectionID1 = generateConnectionID({ - key: ONYXKEYS.TEST_KEY, - reuseConnection: false, - }); - const connectionID2 = generateConnectionID({ - key: ONYXKEYS.TEST_KEY, - reuseConnection: false, - }); - expect( - connectionID1.startsWith( - `onyxKey=${ONYXKEYS.TEST_KEY},sessionID=${getSessionID()},uniqueID=`, - ), - ).toBeTruthy(); - expect( - connectionID2.startsWith( - `onyxKey=${ONYXKEYS.TEST_KEY},sessionID=${getSessionID()},uniqueID=`, - ), - ).toBeTruthy(); - expect(connectionID1).not.toEqual(connectionID2); - }); + it('should generate a stable, reusable connection ID for collection keys', async () => { + const connectionID = generateConnectionID({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + }); + expect(connectionID).toEqual(`onyxKey=${ONYXKEYS.COLLECTION.TEST_KEY},sessionID=${getSessionID()}`); + }); - it("should generate an unique connection ID if the session ID is changed", async () => { - const connectionID1 = generateConnectionID({ key: ONYXKEYS.TEST_KEY }); - connectionManager.refreshSessionID(); - const connectionID2 = generateConnectionID({ key: ONYXKEYS.TEST_KEY }); + it('should generate unique connection IDs if certain options are passed', async () => { + const connectionID1 = generateConnectionID({ + key: ONYXKEYS.TEST_KEY, + reuseConnection: false, + }); + const connectionID2 = generateConnectionID({ + key: ONYXKEYS.TEST_KEY, + reuseConnection: false, + }); + expect(connectionID1.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},sessionID=${getSessionID()},uniqueID=`)).toBeTruthy(); + expect(connectionID2.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},sessionID=${getSessionID()},uniqueID=`)).toBeTruthy(); + expect(connectionID1).not.toEqual(connectionID2); + }); + + it('should generate an unique connection ID if the session ID is changed', async () => { + const connectionID1 = generateConnectionID({key: ONYXKEYS.TEST_KEY}); + connectionManager.refreshSessionID(); + const connectionID2 = generateConnectionID({key: ONYXKEYS.TEST_KEY}); - expect(connectionID1).not.toEqual(connectionID2); + expect(connectionID1).not.toEqual(connectionID2); + }); }); - }); - describe("connect / disconnect", () => { - it("should connect to a key and fire the callback with its value", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); + describe('connect / disconnect', () => { + it('should connect to a key and fire the callback with its value', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); - const callback1 = jest.fn(); - const connection = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback1, - }); + const callback1 = jest.fn(); + const connection = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); - expect(connectionsMap.has(connection.id)).toBeTruthy(); + expect(connectionsMap.has(connection.id)).toBeTruthy(); - await act(async () => waitForPromisesToResolve()); + await act(async () => waitForPromisesToResolve()); - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); - connectionManager.disconnect(connection); + connectionManager.disconnect(connection); - expect(connectionsMap.size).toEqual(0); - }); + expect(connectionsMap.size).toEqual(0); + }); - it("should connect two times to the same key and fire both callbacks with its value", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); + it('should connect two times to the same key and fire both callbacks with its value', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); - const callback1 = jest.fn(); - const connection1 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback1, - }); + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); - const callback2 = jest.fn(); - const connection2 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback2, - }); + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); - expect(connection1.id).toEqual(connection2.id); - expect(connectionsMap.size).toEqual(1); - expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connection1.id).toEqual(connection2.id); + expect(connectionsMap.size).toEqual(1); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); - await act(async () => waitForPromisesToResolve()); + await act(async () => waitForPromisesToResolve()); - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); - expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); - connectionManager.disconnect(connection1); - connectionManager.disconnect(connection2); + connectionManager.disconnect(connection1); + connectionManager.disconnect(connection2); - expect(connectionsMap.size).toEqual(0); - }); + expect(connectionsMap.size).toEqual(0); + }); - it("should connect two times to the same collection key, reuse the connection, and fire both callbacks with the whole collection object", async () => { - const obj1 = { id: "entry1_id", name: "entry1_name" }; - const obj2 = { id: "entry2_id", name: "entry2_name" }; - const collection = { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: obj2, - }; - await StorageMock.multiSet([ - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, obj1], - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, obj2], - ]); - - const callback1 = jest.fn(); - const connection1 = connectionManager.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: callback1, - }); - - const callback2 = jest.fn(); - const connection2 = connectionManager.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: callback2, - }); - - // Collection-root connections are now always snapshot mode and are reused. - expect(connection1.id).toEqual(connection2.id); - expect(connectionsMap.size).toEqual(1); - expect(connectionsMap.has(connection1.id)).toBeTruthy(); - - await act(async () => waitForPromisesToResolve()); - - // Both subscribers share the connection and receive the whole collection object. - expect(callback1).toHaveBeenCalledWith( - collection, - ONYXKEYS.COLLECTION.TEST_KEY, - ); - expect(callback2).toHaveBeenCalledWith( - collection, - ONYXKEYS.COLLECTION.TEST_KEY, - ); - - connectionManager.disconnect(connection1); - connectionManager.disconnect(connection2); - - expect(connectionsMap.size).toEqual(0); - }); + it('should connect two times to the same collection key, reuse the connection, and fire both callbacks with the whole collection object', async () => { + const obj1 = {id: 'entry1_id', name: 'entry1_name'}; + const obj2 = {id: 'entry2_id', name: 'entry2_name'}; + const collection = { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: obj2, + }; + await StorageMock.multiSet([ + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, obj1], + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, obj2], + ]); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: callback1, + }); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: callback2, + }); + + // Collection-root connections are now always snapshot mode and are reused. + expect(connection1.id).toEqual(connection2.id); + expect(connectionsMap.size).toEqual(1); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + + await act(async () => waitForPromisesToResolve()); + + // Both subscribers share the connection and receive the whole collection object. + expect(callback1).toHaveBeenCalledWith(collection, ONYXKEYS.COLLECTION.TEST_KEY); + expect(callback2).toHaveBeenCalledWith(collection, ONYXKEYS.COLLECTION.TEST_KEY); + + connectionManager.disconnect(connection1); + connectionManager.disconnect(connection2); + + expect(connectionsMap.size).toEqual(0); + }); - it("should connect to a key, connect some times more after first connection is made, and fire all subsequent callbacks immediately with its value", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); - - const callback1 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback1, - }); - - await act(async () => waitForPromisesToResolve()); - - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); - - const callback2 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback2, - }); - - const callback3 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback3, - }); - - const callback4 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback4, - }); - - await act(async () => waitForPromisesToResolve()); - - expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); - expect(callback3).toHaveBeenCalledTimes(1); - expect(callback3).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); - expect(callback4).toHaveBeenCalledTimes(1); - expect(callback4).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); - }); + it('should connect to a key, connect some times more after first connection is made, and fire all subsequent callbacks immediately with its value', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + + const callback2 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); + + const callback3 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback3, + }); + + const callback4 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback4, + }); + + await act(async () => waitForPromisesToResolve()); + + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(callback3).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(callback4).toHaveBeenCalledTimes(1); + expect(callback4).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + }); - it("should have the connection object already defined when triggering the callback of the second connection to the same key", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); + it('should have the connection object already defined when triggering the callback of the second connection to the same key', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); - const callback1 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback1, - }); + const callback1 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); - await act(async () => waitForPromisesToResolve()); + await act(async () => waitForPromisesToResolve()); - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); - const callback2 = jest.fn(); - const connection2 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: (...params) => { - callback2(...params); - connectionManager.disconnect(connection2); - }, - }); + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: (...params) => { + callback2(...params); + connectionManager.disconnect(connection2); + }, + }); - await act(async () => waitForPromisesToResolve()); + await act(async () => waitForPromisesToResolve()); - expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); - expect(connectionsMap.size).toEqual(1); - }); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(connectionsMap.size).toEqual(1); + }); - it("should create a separate connection to the same key when setting reuseConnection to false", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); - - const callback1 = jest.fn(); - const connection1 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback1, - }); - - const callback2 = jest.fn(); - const connection2 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - reuseConnection: false, - callback: callback2, - }); - - expect(connection1.id).not.toEqual(connection2.id); - expect(connectionsMap.size).toEqual(2); - expect(connectionsMap.has(connection1.id)).toBeTruthy(); - expect(connectionsMap.has(connection2.id)).toBeTruthy(); - }); + it('should create a separate connection to the same key when setting reuseConnection to false', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + reuseConnection: false, + callback: callback2, + }); + + expect(connection1.id).not.toEqual(connection2.id); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection2.id)).toBeTruthy(); + }); - it("should reuse the connection to the same collection key and deliver the whole collection object to all subscribers", async () => { - const collection = { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry1_name", - }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { - id: "entry2_id", - name: "entry2_name", - }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: { - id: "entry3_id", - name: "entry3_name", - }, - }; - - Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, collection); - - await act(async () => waitForPromisesToResolve()); - - const callback1 = jest.fn(); - const connection1 = connectionManager.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: callback1, - }); - - await act(async () => waitForPromisesToResolve()); - - expect(callback1).toHaveBeenCalledWith( - collection, - ONYXKEYS.COLLECTION.TEST_KEY, - ); - - const callback2 = jest.fn(); - const connection2 = connectionManager.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: callback2, - }); - - expect(connection1.id).toEqual(connection2.id); - expect(connectionsMap.size).toEqual(1); - expect(connectionsMap.has(connection1.id)).toBeTruthy(); - - await act(async () => waitForPromisesToResolve()); - - expect(callback2).toHaveBeenCalledWith( - collection, - ONYXKEYS.COLLECTION.TEST_KEY, - ); - }); + it('should reuse the connection to the same collection key and deliver the whole collection object to all subscribers', async () => { + const collection = { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry3`]: { + id: 'entry3_id', + name: 'entry3_name', + }, + }; + + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TEST_KEY, collection); + + await act(async () => waitForPromisesToResolve()); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: callback1, + }); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledWith(collection, ONYXKEYS.COLLECTION.TEST_KEY); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: callback2, + }); + + expect(connection1.id).toEqual(connection2.id); + expect(connectionsMap.size).toEqual(1); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + + await act(async () => waitForPromisesToResolve()); + + expect(callback2).toHaveBeenCalledWith(collection, ONYXKEYS.COLLECTION.TEST_KEY); + }); - it("should not throw any errors when passing an undefined connection or trying to access an inexistent one inside disconnect()", () => { - expect(connectionsMap.size).toEqual(0); + it('should not throw any errors when passing an undefined connection or trying to access an inexistent one inside disconnect()', () => { + expect(connectionsMap.size).toEqual(0); - expect(() => { - connectionManager.disconnect(undefined as unknown as Connection); - }).not.toThrow(); + expect(() => { + connectionManager.disconnect(undefined as unknown as Connection); + }).not.toThrow(); - expect(() => { - connectionManager.disconnect({ - id: "connectionID1", - callbackID: "callbackID1", + expect(() => { + connectionManager.disconnect({ + id: 'connectionID1', + callbackID: 'callbackID1', + }); + }).not.toThrow(); }); - }).not.toThrow(); - }); - it("should create a separate connection for the same key after a Onyx.clear() call", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); - - const callback1 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback1, - }); - expect(connectionsMap.size).toEqual(1); - - await act(async () => waitForPromisesToResolve()); - - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith("test", ONYXKEYS.TEST_KEY); - callback1.mockReset(); - - await act(async () => Onyx.clear()); - - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith(undefined, ONYXKEYS.TEST_KEY); - callback1.mockReset(); - - const callback2 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback2, - }); - - const callback3 = jest.fn(); - connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback3, - }); - - // We expect to have two connections for ONYXKEYS.TEST_KEY, one for the first subscription before Onyx.clear(), - // and the other for the two subscriptions with the same key after Onyx.clear(). - expect(connectionsMap.size).toEqual(2); - - await act(async () => waitForPromisesToResolve()); - - expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith(undefined, undefined); - expect(callback3).toHaveBeenCalledTimes(1); - expect(callback3).toHaveBeenCalledWith(undefined, undefined); - callback1.mockReset(); - callback2.mockReset(); - callback3.mockReset(); - - Onyx.merge(ONYXKEYS.TEST_KEY, "test2"); - await act(async () => waitForPromisesToResolve()); - - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith("test2", ONYXKEYS.TEST_KEY); - expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith("test2", ONYXKEYS.TEST_KEY); - expect(callback3).toHaveBeenCalledTimes(1); - expect(callback3).toHaveBeenCalledWith("test2", ONYXKEYS.TEST_KEY); - }); - }); - - describe("unsubscribeFromKey", () => { - it("should clean up the correct subscription ID from lastConnectionCallbackData on disconnect", async () => { - const deleteSpy = jest.spyOn(Map.prototype, "delete"); - - const connectionA = Onyx.connect({ - key: ONYXKEYS.TEST_KEY, - callback: jest.fn(), - reuseConnection: false, - }); - Onyx.connect({ - key: ONYXKEYS.TEST_KEY, - callback: jest.fn(), - reuseConnection: false, - }); - await act(async () => waitForPromisesToResolve()); - - const subscriptionIdA = connectionsMap.get( - connectionA.id, - )?.subscriptionID; - - await Onyx.set(ONYXKEYS.TEST_KEY, "value1"); - await act(async () => waitForPromisesToResolve()); - - deleteSpy.mockClear(); - Onyx.disconnect(connectionA); - - const numericDeleteArgs = deleteSpy.mock.calls - .map((call) => call[0]) - .filter((arg): arg is number => typeof arg === "number"); - expect(numericDeleteArgs).toContain(subscriptionIdA); - - deleteSpy.mockRestore(); + it('should create a separate connection for the same key after a Onyx.clear() call', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); + expect(connectionsMap.size).toEqual(1); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + callback1.mockReset(); + + await act(async () => Onyx.clear()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith(undefined, ONYXKEYS.TEST_KEY); + callback1.mockReset(); + + const callback2 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); + + const callback3 = jest.fn(); + connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback3, + }); + + // We expect to have two connections for ONYXKEYS.TEST_KEY, one for the first subscription before Onyx.clear(), + // and the other for the two subscriptions with the same key after Onyx.clear(). + expect(connectionsMap.size).toEqual(2); + + await act(async () => waitForPromisesToResolve()); + + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith(undefined, undefined); + expect(callback3).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledWith(undefined, undefined); + callback1.mockReset(); + callback2.mockReset(); + callback3.mockReset(); + + Onyx.merge(ONYXKEYS.TEST_KEY, 'test2'); + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test2', ONYXKEYS.TEST_KEY); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test2', ONYXKEYS.TEST_KEY); + expect(callback3).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledWith('test2', ONYXKEYS.TEST_KEY); + }); }); - it("should remove the subscription ID from onyxKeyToSubscriptionIDs on disconnect", async () => { - const setSpy = jest.spyOn(Map.prototype, "set"); - - const connectionA = Onyx.connect({ - key: ONYXKEYS.TEST_KEY, - callback: jest.fn(), - reuseConnection: false, - }); - const connectionB = Onyx.connect({ - key: ONYXKEYS.TEST_KEY, - callback: jest.fn(), - reuseConnection: false, - }); - await act(async () => waitForPromisesToResolve()); - - const subscriptionIdA = connectionsMap.get( - connectionA.id, - )?.subscriptionID; - const subscriptionIdB = connectionsMap.get( - connectionB.id, - )?.subscriptionID; - - setSpy.mockClear(); - Onyx.disconnect(connectionA); - - const setCallsForKey = setSpy.mock.calls.filter( - (call) => call[0] === ONYXKEYS.TEST_KEY, - ); - expect(setCallsForKey.length).toBeGreaterThan(0); - - const updatedIDs = setCallsForKey.at( - setCallsForKey.length - 1, - )[1] as number[]; - expect(updatedIDs).not.toContain(subscriptionIdA); - expect(updatedIDs).toContain(subscriptionIdB); - - setSpy.mockRestore(); - Onyx.disconnect(connectionB); - }); - }); + describe('unsubscribeFromKey', () => { + it('should clean up the correct subscription ID from lastConnectionCallbackData on disconnect', async () => { + const deleteSpy = jest.spyOn(Map.prototype, 'delete'); - describe("disconnectAll", () => { - it("should disconnect all connections", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); - await StorageMock.setItem(ONYXKEYS.TEST_KEY_2, "test2"); + const connectionA = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); + Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); + await act(async () => waitForPromisesToResolve()); - const callback1 = jest.fn(); - const connection1 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback1, - }); + const subscriptionIdA = connectionsMap.get(connectionA.id)?.subscriptionID; - const callback2 = jest.fn(); - const connection2 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callback2, - }); + await Onyx.set(ONYXKEYS.TEST_KEY, 'value1'); + await act(async () => waitForPromisesToResolve()); - const callback3 = jest.fn(); - const connection3 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY_2, - callback: callback3, - }); + deleteSpy.mockClear(); + Onyx.disconnect(connectionA); + + const numericDeleteArgs = deleteSpy.mock.calls.map((call) => call[0]).filter((arg): arg is number => typeof arg === 'number'); + expect(numericDeleteArgs).toContain(subscriptionIdA); + + deleteSpy.mockRestore(); + }); - expect(connection1.id).toEqual(connection2.id); - expect(connectionsMap.size).toEqual(2); - expect(connectionsMap.has(connection1.id)).toBeTruthy(); - expect(connectionsMap.has(connection3.id)).toBeTruthy(); + it('should remove the subscription ID from onyxKeyToSubscriptionIDs on disconnect', async () => { + const setSpy = jest.spyOn(Map.prototype, 'set'); - await act(async () => waitForPromisesToResolve()); + const connectionA = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); + const connectionB = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + reuseConnection: false, + }); + await act(async () => waitForPromisesToResolve()); - connectionManager.disconnectAll(); + const subscriptionIdA = connectionsMap.get(connectionA.id)?.subscriptionID; + const subscriptionIdB = connectionsMap.get(connectionB.id)?.subscriptionID; - expect(connectionsMap.size).toEqual(0); + setSpy.mockClear(); + Onyx.disconnect(connectionA); + + const setCallsForKey = setSpy.mock.calls.filter((call) => call[0] === ONYXKEYS.TEST_KEY); + expect(setCallsForKey.length).toBeGreaterThan(0); + + const updatedIDs = setCallsForKey.at(setCallsForKey.length - 1)[1] as number[]; + expect(updatedIDs).not.toContain(subscriptionIdA); + expect(updatedIDs).toContain(subscriptionIdB); + + setSpy.mockRestore(); + Onyx.disconnect(connectionB); + }); }); - }); - describe("refreshSessionID", () => { - it("should create a separate connection for the same key if the session ID changes", async () => { - await StorageMock.setItem(ONYXKEYS.TEST_KEY, "test"); - await StorageMock.setItem(ONYXKEYS.TEST_KEY_2, "test2"); + describe('disconnectAll', () => { + it('should disconnect all connections', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + await StorageMock.setItem(ONYXKEYS.TEST_KEY_2, 'test2'); - const connection1 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: jest.fn(), - }); + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback1, + }); - expect(connectionsMap.size).toEqual(1); + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callback2, + }); - connectionManager.refreshSessionID(); + const callback3 = jest.fn(); + const connection3 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY_2, + callback: callback3, + }); - const connection2 = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback: jest.fn(), - }); + expect(connection1.id).toEqual(connection2.id); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection3.id)).toBeTruthy(); - expect(connectionsMap.size).toEqual(2); - expect(connectionsMap.has(connection1.id)).toBeTruthy(); - expect(connectionsMap.has(connection2.id)).toBeTruthy(); + await act(async () => waitForPromisesToResolve()); + + connectionManager.disconnectAll(); + + expect(connectionsMap.size).toEqual(0); + }); }); - }); - - describe("collection callback arguments", () => { - it("should call collection-root callbacks with only the value and key", async () => { - const obj1 = { id: "entry1_id", name: "entry1_name" }; - const obj2 = { id: "entry2_id", name: "entry2_name" }; - - const callback = jest.fn(); - const connection = connectionManager.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback, - }); - - await act(async () => waitForPromisesToResolve()); - - // Initial callback with undefined values - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith( - undefined, - ONYXKEYS.COLLECTION.TEST_KEY, - ); - - // Reset mock to test the next update - callback.mockReset(); - - // Update with first object - await Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, obj1); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith( - { [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1 }, - ONYXKEYS.COLLECTION.TEST_KEY, - ); - - // Reset mock to test the next update - callback.mockReset(); - - // Update with second object - await Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, obj2); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith( - { - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1, - [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: obj2, - }, - ONYXKEYS.COLLECTION.TEST_KEY, - ); - - connectionManager.disconnect(connection); + + describe('refreshSessionID', () => { + it('should create a separate connection for the same key if the session ID changes', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + await StorageMock.setItem(ONYXKEYS.TEST_KEY_2, 'test2'); + + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + }); + + expect(connectionsMap.size).toEqual(1); + + connectionManager.refreshSessionID(); + + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: jest.fn(), + }); + + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection2.id)).toBeTruthy(); + }); }); - it("should call regular (non-collection) key callbacks with only the value and key", async () => { - const obj1 = { id: "entry1_id", name: "entry1_name" }; + describe('collection callback arguments', () => { + it('should call collection-root callbacks with only the value and key', async () => { + const obj1 = {id: 'entry1_id', name: 'entry1_name'}; + const obj2 = {id: 'entry2_id', name: 'entry2_name'}; - const callback = jest.fn(); - const connection = connectionManager.connect({ - key: ONYXKEYS.TEST_KEY, - callback, - }); + const callback = jest.fn(); + const connection = connectionManager.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback, + }); - await act(async () => waitForPromisesToResolve()); + await act(async () => waitForPromisesToResolve()); - // Update with object - await Onyx.merge(ONYXKEYS.TEST_KEY, obj1); + // Initial callback with undefined values + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(undefined, ONYXKEYS.COLLECTION.TEST_KEY); - expect(callback).toHaveBeenCalledWith(obj1, ONYXKEYS.TEST_KEY); + // Reset mock to test the next update + callback.mockReset(); - connectionManager.disconnect(connection); + // Update with first object + await Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, obj1); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({[`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1}, ONYXKEYS.COLLECTION.TEST_KEY); + + // Reset mock to test the next update + callback.mockReset(); + + // Update with second object + await Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, obj2); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: obj2, + }, + ONYXKEYS.COLLECTION.TEST_KEY, + ); + + connectionManager.disconnect(connection); + }); + + it('should call regular (non-collection) key callbacks with only the value and key', async () => { + const obj1 = {id: 'entry1_id', name: 'entry1_name'}; + + const callback = jest.fn(); + const connection = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback, + }); + + await act(async () => waitForPromisesToResolve()); + + // Update with object + await Onyx.merge(ONYXKEYS.TEST_KEY, obj1); + + expect(callback).toHaveBeenCalledWith(obj1, ONYXKEYS.TEST_KEY); + + connectionManager.disconnect(connection); + }); }); - }); }); diff --git a/tests/unit/collectionHydrationTest.ts b/tests/unit/collectionHydrationTest.ts index 383d95a1d..5c1982629 100644 --- a/tests/unit/collectionHydrationTest.ts +++ b/tests/unit/collectionHydrationTest.ts @@ -1,125 +1,119 @@ -import StorageMock from "../../lib/storage"; -import Onyx from "../../lib"; -import waitForPromisesToResolve from "../utils/waitForPromisesToResolve"; +import StorageMock from '../../lib/storage'; +import Onyx from '../../lib'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; const ONYX_KEYS = { - COLLECTION: { - TEST_KEY: "test_", - }, - SINGLE_KEY: "single", + COLLECTION: { + TEST_KEY: 'test_', + }, + SINGLE_KEY: 'single', }; -describe("Collection hydration with connect() followed by immediate set()", () => { - beforeEach(async () => { - // ===== Session 1 ===== - // Data is written to persistent storage (simulates a previous app session). - await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { - id: 1, - title: "Test One", +describe('Collection hydration with connect() followed by immediate set()', () => { + beforeEach(async () => { + // ===== Session 1 ===== + // Data is written to persistent storage (simulates a previous app session). + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { + id: 1, + title: 'Test One', + }); + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`, { + id: 2, + title: 'Test Two', + }); + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`, { + id: 3, + title: 'Test Three', + }); + await StorageMock.setItem(ONYX_KEYS.SINGLE_KEY, {title: 'old'}); + + // ===== Session 2 ===== + // App restarts. Onyx.init() calls getAllKeys() which populates storageKeys + // with all 3 keys, but their values are NOT read into cache yet. + Onyx.init({keys: ONYX_KEYS}); }); - await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`, { - id: 2, - title: "Test Two", - }); - await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`, { - id: 3, - title: "Test Three", - }); - await StorageMock.setItem(ONYX_KEYS.SINGLE_KEY, { title: "old" }); - - // ===== Session 2 ===== - // App restarts. Onyx.init() calls getAllKeys() which populates storageKeys - // with all 3 keys, but their values are NOT read into cache yet. - Onyx.init({ keys: ONYX_KEYS }); - }); - afterEach(() => Onyx.clear()); + afterEach(() => Onyx.clear()); - test("collection connect should deliver full collection from storage", async () => { - const mockCallback = jest.fn(); + test('collection connect should deliver full collection from storage', async () => { + const mockCallback = jest.fn(); - // A component connects to the collection (starts async hydration via multiGet). - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: mockCallback, - }); + // A component connects to the collection (starts async hydration via multiGet). + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: mockCallback, + }); - Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { - id: 1, - title: "Updated Test One", - }); + Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { + id: 1, + title: 'Updated Test One', + }); - await waitForPromisesToResolve(); - - // The subscriber should eventually receive ALL collection members. - // The async hydration reads test_2 and test_3 from storage. - const lastCall = mockCallback.mock.calls.at( - mockCallback.mock.calls.length - 1, - )[0]; - expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`); - expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`); - expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`); - - // Verify the updated value is present (not stale) - expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]).toEqual({ - id: 1, - title: "Updated Test One", - }); - }); + await waitForPromisesToResolve(); - test("single key: set() with non-shallow-equal value should not be overwritten by stale hydration", async () => { - const mockCallback = jest.fn(); + // The subscriber should eventually receive ALL collection members. + // The async hydration reads test_2 and test_3 from storage. + const lastCall = mockCallback.mock.calls.at(mockCallback.mock.calls.length - 1)[0]; + expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`); + expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}2`); + expect(lastCall).toHaveProperty(`${ONYX_KEYS.COLLECTION.TEST_KEY}3`); - Onyx.connect({ - key: ONYX_KEYS.SINGLE_KEY, - callback: mockCallback, + // Verify the updated value is present (not stale) + expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]).toEqual({ + id: 1, + title: 'Updated Test One', + }); }); - // Immediately update the key with a non-shallow-equal - Onyx.set(ONYX_KEYS.SINGLE_KEY, { title: "new" }); + test('single key: set() with non-shallow-equal value should not be overwritten by stale hydration', async () => { + const mockCallback = jest.fn(); - await waitForPromisesToResolve(); + Onyx.connect({ + key: ONYX_KEYS.SINGLE_KEY, + callback: mockCallback, + }); - // The LAST value delivered to the subscriber must be the fresh one, not the stale storage value - const lastValue = mockCallback.mock.calls.at( - mockCallback.mock.calls.length - 1, - )[0]; - expect(lastValue).toEqual({ title: "new" }); - }); + // Immediately update the key with a non-shallow-equal + Onyx.set(ONYX_KEYS.SINGLE_KEY, {title: 'new'}); - test("collection key: set() with non-shallow-equal value should not be regressed by hydration multiGet", async () => { - const mockCallback = jest.fn(); + await waitForPromisesToResolve(); - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: mockCallback, + // The LAST value delivered to the subscriber must be the fresh one, not the stale storage value + const lastValue = mockCallback.mock.calls.at(mockCallback.mock.calls.length - 1)[0]; + expect(lastValue).toEqual({title: 'new'}); }); - // Update key 1 with a non-shallow-equal value while hydration multiGet is in-flight - Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { - id: 1, - title: "Freshly Updated", - }); - - await waitForPromisesToResolve(); - - const lastCall = mockCallback.mock.calls.at( - mockCallback.mock.calls.length - 1, - )[0]; - - // The final collection snapshot must have the fresh value, not the stale storage one - expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]).toEqual({ - id: 1, - title: "Freshly Updated", - }); - // Other members should still be present from storage - expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]).toEqual({ - id: 2, - title: "Test Two", - }); - expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}3`]).toEqual({ - id: 3, - title: "Test Three", + test('collection key: set() with non-shallow-equal value should not be regressed by hydration multiGet', async () => { + const mockCallback = jest.fn(); + + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: mockCallback, + }); + + // Update key 1 with a non-shallow-equal value while hydration multiGet is in-flight + Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}1`, { + id: 1, + title: 'Freshly Updated', + }); + + await waitForPromisesToResolve(); + + const lastCall = mockCallback.mock.calls.at(mockCallback.mock.calls.length - 1)[0]; + + // The final collection snapshot must have the fresh value, not the stale storage one + expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]).toEqual({ + id: 1, + title: 'Freshly Updated', + }); + // Other members should still be present from storage + expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}2`]).toEqual({ + id: 2, + title: 'Test Two', + }); + expect(lastCall[`${ONYX_KEYS.COLLECTION.TEST_KEY}3`]).toEqual({ + id: 3, + title: 'Test Three', + }); }); - }); }); diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index 95f35946d..ec89fe650 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -1,3912 +1,3624 @@ -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 { OnyxCollection, OnyxKey, OnyxUpdate } from "../../lib/types"; -import type { GenericDeepRecord } from "../types"; -import type GenericCollection from "../utils/GenericCollection"; -import type { Connection } from "../../lib/OnyxConnectionManager"; -import createDeferredTask from "../../lib/createDeferredTask"; +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 {OnyxCollection, OnyxKey, OnyxUpdate} from '../../lib/types'; +import type {GenericDeepRecord} from '../types'; +import type GenericCollection from '../utils/GenericCollection'; +import type {Connection} from '../../lib/OnyxConnectionManager'; +import createDeferredTask from '../../lib/createDeferredTask'; const ONYX_KEYS = { - TEST_KEY: "test", - OTHER_TEST: "otherTest", - // Special case: this key is not a collection key, but it has an underscore in its name - KEY_WITH_UNDERSCORE: "nvp_test", - COLLECTION: { - TEST_KEY: "test_", - TEST_CONNECT_COLLECTION: "testConnectCollection_", - TEST_POLICY: "testPolicy_", - TEST_UPDATE: "testUpdate_", - PEOPLE: "people_", - ANIMALS: "animals_", - SNAPSHOT: "snapshot_", - ROUTES: "routes_", - RAM_ONLY_COLLECTION: "ramOnlyCollection_", - }, - RAM_ONLY_TEST_KEY: "ramOnlyKey", - RAM_ONLY_WITH_INITIAL_VALUE: "ramOnlyWithInitialValue", + TEST_KEY: 'test', + OTHER_TEST: 'otherTest', + // Special case: this key is not a collection key, but it has an underscore in its name + KEY_WITH_UNDERSCORE: 'nvp_test', + COLLECTION: { + TEST_KEY: 'test_', + TEST_CONNECT_COLLECTION: 'testConnectCollection_', + TEST_POLICY: 'testPolicy_', + TEST_UPDATE: 'testUpdate_', + PEOPLE: 'people_', + ANIMALS: 'animals_', + SNAPSHOT: 'snapshot_', + ROUTES: 'routes_', + RAM_ONLY_COLLECTION: 'ramOnlyCollection_', + }, + RAM_ONLY_TEST_KEY: 'ramOnlyKey', + RAM_ONLY_WITH_INITIAL_VALUE: 'ramOnlyWithInitialValue', }; -describe("Onyx", () => { - beforeAll(() => { - Onyx.init({ - keys: ONYX_KEYS, - initialKeyStates: { - [ONYX_KEYS.OTHER_TEST]: 42, - [ONYX_KEYS.KEY_WITH_UNDERSCORE]: "default", - [ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: "default", - }, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - skippableCollectionMemberIDs: ["skippable-id"], - snapshotMergeKeys: ["pendingAction", "pendingFields"], - }); - }); - - let connection: Connection | undefined; - - let cache: typeof OnyxCache; - - beforeEach(() => { - cache = require("../../lib/OnyxCache").default; - }); - - afterEach(() => { - if (connection) { - Onyx.disconnect(connection); - } - return Onyx.clear(); - }); - - it("should remove key value from OnyxCache/Storage when set is called with null value", () => - Onyx.set(ONYX_KEYS.OTHER_TEST, 42) - .then(() => OnyxUtils.getAllKeys()) - .then((keys) => { - expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(true); - return Onyx.set(ONYX_KEYS.OTHER_TEST, null); - }) - // Checks if cache value is removed. - .then(() => { - expect(cache.get(ONYX_KEYS.OTHER_TEST)).toBeUndefined(); - return OnyxUtils.getAllKeys(); - }) - .then((keys) => { - expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(false); - })); - - it("should restore a key with initial state if the key was set to null and Onyx.clear() is called", () => - Onyx.set(ONYX_KEYS.OTHER_TEST, 42) - .then(() => Onyx.set(ONYX_KEYS.OTHER_TEST, null)) - .then(() => Onyx.clear()) - .then(() => { - expect(cache.get(ONYX_KEYS.OTHER_TEST)).toBe(42); - })); - - it("should set a simple key", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - // Set a simple key - return Onyx.set(ONYX_KEYS.TEST_KEY, "test").then(() => { - expect(testKeyValue).toBe("test"); - }); - }); - - it("should not set the key if the value is incompatible (array vs object)", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, ["test"]) - .then(() => { - expect(testKeyValue).toStrictEqual(["test"]); - return Onyx.set(ONYX_KEYS.TEST_KEY, { test: "test" }); - }) - .then(() => { - expect(testKeyValue).toStrictEqual(["test"]); - }); - }); - - it("shouldn't call a connection twice when setting a value", () => { - const mockCallback = jest.fn(); - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: mockCallback, - // True is the default, just setting it here to be explicit - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, "test").then(() => { - expect(mockCallback).toHaveBeenCalledTimes(1); - }); - }); - - it("should merge an object with another object", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { test1: "test1" }) - .then(() => { - expect(testKeyValue).toEqual({ test1: "test1" }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { test2: "test2" }); - }) - .then(() => { - expect(testKeyValue).toEqual({ test1: "test1", test2: "test2" }); - }); - }); - - it("should not merge if the value is incompatible (array vs object)", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.merge(ONYX_KEYS.TEST_KEY, ["test"]) - .then(() => { - expect(testKeyValue).toStrictEqual(["test"]); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { test2: "test2" }); - }) - .then(() => { - expect(testKeyValue).toStrictEqual(["test"]); - }); - }); - - it("should merge an object into an empty array (treating [] as {})", async () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); +describe('Onyx', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYX_KEYS, + initialKeyStates: { + [ONYX_KEYS.OTHER_TEST]: 42, + [ONYX_KEYS.KEY_WITH_UNDERSCORE]: 'default', + [ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default', + }, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + skippableCollectionMemberIDs: ['skippable-id'], + snapshotMergeKeys: ['pendingAction', 'pendingFields'], + }); + }); - await Onyx.set(ONYX_KEYS.TEST_KEY, []); - expect(testKeyValue).toStrictEqual([]); - await Onyx.merge(ONYX_KEYS.TEST_KEY, { test: "value" }); - expect(testKeyValue).toStrictEqual({ test: "value" }); - }); - - it("should still reject merging an object into a non-empty array", async () => { - let testKeyValue: unknown; + let connection: Connection | undefined; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.merge(ONYX_KEYS.TEST_KEY, ["existing"]); - expect(testKeyValue).toStrictEqual(["existing"]); - await Onyx.merge(ONYX_KEYS.TEST_KEY, { test: "value" }); - expect(testKeyValue).toStrictEqual(["existing"]); - }); - - it("should mergeCollection an object into an empty array (treating [] as {})", async () => { - const collectionKey = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.set(collectionKey, []); - expect( - (testKeyValue as Record)?.[collectionKey], - ).toStrictEqual([]); - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [collectionKey]: { test: "value" }, - }); - expect( - (testKeyValue as Record)?.[collectionKey], - ).toStrictEqual({ test: "value" }); - }); - - it("should set an object over an empty array (treating [] as {})", async () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.set(ONYX_KEYS.TEST_KEY, []); - expect(testKeyValue).toStrictEqual([]); - await Onyx.set(ONYX_KEYS.TEST_KEY, { test: "value" }); - expect(testKeyValue).toStrictEqual({ test: "value" }); - }); - - it("should notify subscribers when data has been cleared", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - const mockCallback = jest.fn(); - const otherTestConnection = Onyx.connect({ - key: ONYX_KEYS.OTHER_TEST, - callback: mockCallback, - }); - - return waitForPromisesToResolve() - .then(() => { - expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith(42, ONYX_KEYS.OTHER_TEST); - mockCallback.mockClear(); - }) - .then(() => Onyx.set(ONYX_KEYS.TEST_KEY, "test")) - .then(() => { - expect(testKeyValue).toBe("test"); - return Onyx.clear(); - }) - .then(() => waitForPromisesToResolve()) - .then(() => { - // Test key should be cleared - expect(testKeyValue).toBeUndefined(); - - // Expect that the connection to a key with a default value that wasn't changed is not called on clear - expect(mockCallback).toHaveBeenCalledTimes(0); - - return Onyx.disconnect(otherTestConnection); - }); - }); - - it("should notify key subscribers that use a underscore in their name", () => { - const mockCallback = jest.fn(); - connection = Onyx.connect({ - key: ONYX_KEYS.KEY_WITH_UNDERSCORE, - callback: mockCallback, - }); - - return waitForPromisesToResolve() - .then(() => mockCallback.mockReset()) - .then(() => Onyx.set(ONYX_KEYS.KEY_WITH_UNDERSCORE, "test")) - .then(() => { - expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenLastCalledWith( - "test", - ONYX_KEYS.KEY_WITH_UNDERSCORE, - ); - mockCallback.mockReset(); - return Onyx.clear(); - }) - .then(() => { - expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith( - "default", - ONYX_KEYS.KEY_WITH_UNDERSCORE, - ); - }) - .then(() => Onyx.set(ONYX_KEYS.KEY_WITH_UNDERSCORE, "default")) - .then(() => mockCallback.mockReset()) - .then(() => Onyx.set(ONYX_KEYS.KEY_WITH_UNDERSCORE, "test")) - .then(() => { - expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith( - "test", - ONYX_KEYS.KEY_WITH_UNDERSCORE, - ); - }); - }); + let cache: typeof OnyxCache; - it("should not notify subscribers after they have disconnected", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, + beforeEach(() => { + cache = require('../../lib/OnyxCache').default; }); - return Onyx.set(ONYX_KEYS.TEST_KEY, "test") - .then(() => { - expect(testKeyValue).toBe("test"); + afterEach(() => { if (connection) { - Onyx.disconnect(connection); + Onyx.disconnect(connection); } - return Onyx.set(ONYX_KEYS.TEST_KEY, "test updated"); - }) - .then(() => { - // Test value has not changed - expect(testKeyValue).toBe("test"); - }); - }); - - it("should merge arrays by replacing previous value with new value", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, ["test1"]) - .then(() => { - expect(testKeyValue).toStrictEqual(["test1"]); - return Onyx.merge(ONYX_KEYS.TEST_KEY, ["test2", "test3", "test4"]); - }) - .then(waitForPromisesToResolve) - .then(() => { - expect(testKeyValue).toStrictEqual(["test2", "test3", "test4"]); - }); - }); - - it("should merge 2 objects when it has no initial stored value for test key", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - Onyx.merge(ONYX_KEYS.TEST_KEY, { test1: "test1" }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { test2: "test2" }).then(() => { - expect(testKeyValue).toStrictEqual({ test1: "test1", test2: "test2" }); - }); - }); - - it("should merge 2 arrays when it has no initial stored value for test key", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - Onyx.merge(ONYX_KEYS.TEST_KEY, ["test1"]); - return Onyx.merge(ONYX_KEYS.TEST_KEY, ["test2"]).then(() => { - expect(testKeyValue).toEqual(["test2"]); - }); - }); - - it("should remove keys that are set to null when merging", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { - test1: { - test2: "test2", - test3: { - test4: "test4", - }, - }, - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: { - test2: "test2", - test3: { - test4: "test4", + return Onyx.clear(); + }); + + it('should remove key value from OnyxCache/Storage when set is called with null value', () => + Onyx.set(ONYX_KEYS.OTHER_TEST, 42) + .then(() => OnyxUtils.getAllKeys()) + .then((keys) => { + expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(true); + return Onyx.set(ONYX_KEYS.OTHER_TEST, null); + }) + // Checks if cache value is removed. + .then(() => { + expect(cache.get(ONYX_KEYS.OTHER_TEST)).toBeUndefined(); + return OnyxUtils.getAllKeys(); + }) + .then((keys) => { + expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(false); + })); + + it('should restore a key with initial state if the key was set to null and Onyx.clear() is called', () => + Onyx.set(ONYX_KEYS.OTHER_TEST, 42) + .then(() => Onyx.set(ONYX_KEYS.OTHER_TEST, null)) + .then(() => Onyx.clear()) + .then(() => { + expect(cache.get(ONYX_KEYS.OTHER_TEST)).toBe(42); + })); + + it('should set a simple key', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; }, - }, }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { - test1: { - test3: { - test4: null, + // Set a simple key + return Onyx.set(ONYX_KEYS.TEST_KEY, 'test').then(() => { + expect(testKeyValue).toBe('test'); + }); + }); + + it('should not set the key if the value is incompatible (array vs object)', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; }, - }, }); - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: { - test2: "test2", - test3: {}, - }, + + return Onyx.set(ONYX_KEYS.TEST_KEY, ['test']) + .then(() => { + expect(testKeyValue).toStrictEqual(['test']); + return Onyx.set(ONYX_KEYS.TEST_KEY, {test: 'test'}); + }) + .then(() => { + expect(testKeyValue).toStrictEqual(['test']); + }); + }); + + it("shouldn't call a connection twice when setting a value", () => { + const mockCallback = jest.fn(); + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: mockCallback, + // True is the default, just setting it here to be explicit + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, 'test').then(() => { + expect(mockCallback).toHaveBeenCalledTimes(1); }); + }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { - test1: { - test3: null, - }, - }); - }) - .then(() => { - expect(testKeyValue).toEqual({ test1: { test2: "test2" } }); - - return Onyx.merge(ONYX_KEYS.TEST_KEY, { test1: null }); - }) - .then(() => { - expect(testKeyValue).toEqual({}); - }); - }); - - it("should ignore top-level and remove nested `undefined` values in Onyx.set", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { - test1: { - test2: "test2", - test3: "test3", - }, - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: { - test2: "test2", - test3: "test3", - }, + it('should merge an object with another object', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: 'test1'}) + .then(() => { + expect(testKeyValue).toEqual({test1: 'test1'}); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {test2: 'test2'}); + }) + .then(() => { + expect(testKeyValue).toEqual({test1: 'test1', test2: 'test2'}); + }); + }); + + it('should not merge if the value is incompatible (array vs object)', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.merge(ONYX_KEYS.TEST_KEY, ['test']) + .then(() => { + expect(testKeyValue).toStrictEqual(['test']); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {test2: 'test2'}); + }) + .then(() => { + expect(testKeyValue).toStrictEqual(['test']); + }); + }); + + it('should merge an object into an empty array (treating [] as {})', async () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + await Onyx.set(ONYX_KEYS.TEST_KEY, []); + expect(testKeyValue).toStrictEqual([]); + await Onyx.merge(ONYX_KEYS.TEST_KEY, {test: 'value'}); + expect(testKeyValue).toStrictEqual({test: 'value'}); + }); + + it('should still reject merging an object into a non-empty array', async () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + await Onyx.merge(ONYX_KEYS.TEST_KEY, ['existing']); + expect(testKeyValue).toStrictEqual(['existing']); + await Onyx.merge(ONYX_KEYS.TEST_KEY, {test: 'value'}); + expect(testKeyValue).toStrictEqual(['existing']); + }); + + it('should mergeCollection an object into an empty array (treating [] as {})', async () => { + const collectionKey = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + await Onyx.set(collectionKey, []); + expect((testKeyValue as Record)?.[collectionKey]).toStrictEqual([]); + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [collectionKey]: {test: 'value'}, + }); + expect((testKeyValue as Record)?.[collectionKey]).toStrictEqual({test: 'value'}); + }); + + it('should set an object over an empty array (treating [] as {})', async () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + await Onyx.set(ONYX_KEYS.TEST_KEY, []); + expect(testKeyValue).toStrictEqual([]); + await Onyx.set(ONYX_KEYS.TEST_KEY, {test: 'value'}); + expect(testKeyValue).toStrictEqual({test: 'value'}); + }); + + it('should notify subscribers when data has been cleared', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + const mockCallback = jest.fn(); + const otherTestConnection = Onyx.connect({ + key: ONYX_KEYS.OTHER_TEST, + callback: mockCallback, + }); + + return waitForPromisesToResolve() + .then(() => { + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(42, ONYX_KEYS.OTHER_TEST); + mockCallback.mockClear(); + }) + .then(() => Onyx.set(ONYX_KEYS.TEST_KEY, 'test')) + .then(() => { + expect(testKeyValue).toBe('test'); + return Onyx.clear(); + }) + .then(() => waitForPromisesToResolve()) + .then(() => { + // Test key should be cleared + expect(testKeyValue).toBeUndefined(); + + // Expect that the connection to a key with a default value that wasn't changed is not called on clear + expect(mockCallback).toHaveBeenCalledTimes(0); + + return Onyx.disconnect(otherTestConnection); + }); + }); + + it('should notify key subscribers that use a underscore in their name', () => { + const mockCallback = jest.fn(); + connection = Onyx.connect({ + key: ONYX_KEYS.KEY_WITH_UNDERSCORE, + callback: mockCallback, + }); + + return waitForPromisesToResolve() + .then(() => mockCallback.mockReset()) + .then(() => Onyx.set(ONYX_KEYS.KEY_WITH_UNDERSCORE, 'test')) + .then(() => { + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenLastCalledWith('test', ONYX_KEYS.KEY_WITH_UNDERSCORE); + mockCallback.mockReset(); + return Onyx.clear(); + }) + .then(() => { + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith('default', ONYX_KEYS.KEY_WITH_UNDERSCORE); + }) + .then(() => Onyx.set(ONYX_KEYS.KEY_WITH_UNDERSCORE, 'default')) + .then(() => mockCallback.mockReset()) + .then(() => Onyx.set(ONYX_KEYS.KEY_WITH_UNDERSCORE, 'test')) + .then(() => { + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith('test', ONYX_KEYS.KEY_WITH_UNDERSCORE); + }); + }); + + it('should not notify subscribers after they have disconnected', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, 'test') + .then(() => { + expect(testKeyValue).toBe('test'); + if (connection) { + Onyx.disconnect(connection); + } + return Onyx.set(ONYX_KEYS.TEST_KEY, 'test updated'); + }) + .then(() => { + // Test value has not changed + expect(testKeyValue).toBe('test'); + }); + }); + + it('should merge arrays by replacing previous value with new value', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, ['test1']) + .then(() => { + expect(testKeyValue).toStrictEqual(['test1']); + return Onyx.merge(ONYX_KEYS.TEST_KEY, ['test2', 'test3', 'test4']); + }) + .then(waitForPromisesToResolve) + .then(() => { + expect(testKeyValue).toStrictEqual(['test2', 'test3', 'test4']); + }); + }); + + it('should merge 2 objects when it has no initial stored value for test key', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + Onyx.merge(ONYX_KEYS.TEST_KEY, {test1: 'test1'}); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {test2: 'test2'}).then(() => { + expect(testKeyValue).toStrictEqual({test1: 'test1', test2: 'test2'}); + }); + }); + + it('should merge 2 arrays when it has no initial stored value for test key', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + Onyx.merge(ONYX_KEYS.TEST_KEY, ['test1']); + return Onyx.merge(ONYX_KEYS.TEST_KEY, ['test2']).then(() => { + expect(testKeyValue).toEqual(['test2']); + }); + }); + + it('should remove keys that are set to null when merging', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, }); return Onyx.set(ONYX_KEYS.TEST_KEY, { - test1: { - test2: undefined, - test3: "test3", - }, - }); - }) - .then(() => { - expect(testKeyValue).toEqual({ test1: { test3: "test3" } }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { test1: undefined }); - }) - .then(() => { - expect(testKeyValue).toEqual({}); - - return Onyx.set(ONYX_KEYS.TEST_KEY, undefined); - }) - .then(() => { - expect(testKeyValue).toEqual({}); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { test1: undefined }); - }); - }); - - it("should ignore top-level and remove nested `undefined` values in Onyx.multiSet", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - let otherTestKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.OTHER_TEST, - callback: (value) => { - otherTestKeyValue = value; - }, - }); - - return Onyx.multiSet({ - [ONYX_KEYS.TEST_KEY]: { - test1: "test1", - test2: "test2", - }, - [ONYX_KEYS.OTHER_TEST]: "otherTest", - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: "test1", - test2: "test2", - }); - expect(otherTestKeyValue).toEqual("otherTest"); + test1: { + test2: 'test2', + test3: { + test4: 'test4', + }, + }, + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: { + test2: 'test2', + test3: { + test4: 'test4', + }, + }, + }); + + return Onyx.merge(ONYX_KEYS.TEST_KEY, { + test1: { + test3: { + test4: null, + }, + }, + }); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: { + test2: 'test2', + test3: {}, + }, + }); + + return Onyx.merge(ONYX_KEYS.TEST_KEY, { + test1: { + test3: null, + }, + }); + }) + .then(() => { + expect(testKeyValue).toEqual({test1: {test2: 'test2'}}); + + return Onyx.merge(ONYX_KEYS.TEST_KEY, {test1: null}); + }) + .then(() => { + expect(testKeyValue).toEqual({}); + }); + }); + + it('should ignore top-level and remove nested `undefined` values in Onyx.set', () => { + let testKeyValue: unknown; - return Onyx.multiSet({ - [ONYX_KEYS.TEST_KEY]: { - test1: "test1", - test2: undefined, - }, - [ONYX_KEYS.OTHER_TEST]: undefined, + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, { + test1: { + test2: 'test2', + test3: 'test3', + }, + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: { + test2: 'test2', + test3: 'test3', + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, { + test1: { + test2: undefined, + test3: 'test3', + }, + }); + }) + .then(() => { + expect(testKeyValue).toEqual({test1: {test3: 'test3'}}); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: undefined}); + }) + .then(() => { + expect(testKeyValue).toEqual({}); + + return Onyx.set(ONYX_KEYS.TEST_KEY, undefined); + }) + .then(() => { + expect(testKeyValue).toEqual({}); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: undefined}); + }); + }); + + it('should ignore top-level and remove nested `undefined` values in Onyx.multiSet', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, }); - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: "test1", + + let otherTestKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.OTHER_TEST, + callback: (value) => { + otherTestKeyValue = value; + }, }); - expect(otherTestKeyValue).toEqual("otherTest"); return Onyx.multiSet({ - [ONYX_KEYS.TEST_KEY]: null, - [ONYX_KEYS.OTHER_TEST]: null, - }); - }) - .then(() => { - expect(testKeyValue).toEqual(undefined); - expect(otherTestKeyValue).toEqual(undefined); - }); - }); - - it("should ignore top-level and remove nested `undefined` values in Onyx.merge", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { - test1: { - test2: "test2", - test3: "test3", - }, - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: { - test2: "test2", - test3: "test3", - }, + [ONYX_KEYS.TEST_KEY]: { + test1: 'test1', + test2: 'test2', + }, + [ONYX_KEYS.OTHER_TEST]: 'otherTest', + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: 'test1', + test2: 'test2', + }); + expect(otherTestKeyValue).toEqual('otherTest'); + + return Onyx.multiSet({ + [ONYX_KEYS.TEST_KEY]: { + test1: 'test1', + test2: undefined, + }, + [ONYX_KEYS.OTHER_TEST]: undefined, + }); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: 'test1', + }); + expect(otherTestKeyValue).toEqual('otherTest'); + + return Onyx.multiSet({ + [ONYX_KEYS.TEST_KEY]: null, + [ONYX_KEYS.OTHER_TEST]: null, + }); + }) + .then(() => { + expect(testKeyValue).toEqual(undefined); + expect(otherTestKeyValue).toEqual(undefined); + }); + }); + + it('should ignore top-level and remove nested `undefined` values in Onyx.merge', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { - test1: { - test2: undefined, - }, - }); - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: { test2: "test2", test3: "test3" }, - }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { test1: undefined }); - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: { test2: "test2", test3: "test3" }, - }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: { test2: "test2", test3: "test3" }, - }); - }); - }); - - it("should ignore top-level and remove nested `undefined` values in Onyx.mergeCollection", () => { - let result: OnyxCollection; - - const routineRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}routine`; - const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`; - const workRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}work`; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => (result = value), - }); - - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [routineRoute]: { - waypoints: { - 1: "Home", - 2: "Work", - 3: undefined, - }, - }, - [holidayRoute]: { - waypoints: undefined, - }, - [workRoute]: undefined, - }).then(() => { - expect(result).toEqual({ - [routineRoute]: { - waypoints: { - 1: "Home", - 2: "Work", - }, - }, - [holidayRoute]: {}, - [workRoute]: undefined, - }); - }); - }); - - it("should overwrite an array key nested inside an object", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - Onyx.merge(ONYX_KEYS.TEST_KEY, { something: [1, 2, 3] }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { something: [4] }).then(() => { - expect(testKeyValue).toEqual({ something: [4] }); - }); - }); - - it("should overwrite an array key nested inside an object", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - Onyx.merge(ONYX_KEYS.TEST_KEY, { something: [1, 2, 3] }); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { something: [4] }).then(() => { - expect(testKeyValue).toEqual({ something: [4] }); - }); - }); - - it("should properly set and merge when using mergeCollection", async () => { - const mockCallback = jest.fn(); - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: mockCallback, - }); - await waitForPromisesToResolve(); - - mockCallback.mockReset(); - - // The first time we call mergeCollection we'll be doing a multiSet internally - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - test_1: { - ID: 123, - value: "one", - }, - test_2: { - ID: 234, - value: "two", - }, - test_3: { - ID: 345, - value: "three", - }, - }) - .then(() => - // 2 key values to update and 2 new keys to add. - // MergeCollection will perform a mix of multiSet and multiMerge - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - test_1: { - ID: 123, - value: "five", - }, - test_2: { - ID: 234, - value: "four", - }, - test_3: ["abc", "xyz"], // This shouldn't be merged since it's an array, and the original value is an object {ID, value} - test_4: { - ID: 456, - value: "two", - }, - test_5: { - ID: 567, - value: "one", - }, - }), - ) - .then(() => { - // Callback fires once per mergeCollection with the full collection object. - expect(mockCallback).toHaveBeenCalledTimes(2); - expect(mockCallback).toHaveBeenNthCalledWith( - 1, - { - test_1: { ID: 123, value: "one" }, - test_2: { ID: 234, value: "two" }, - test_3: { ID: 345, value: "three" }, - }, - ONYX_KEYS.COLLECTION.TEST_KEY, - ); - expect(mockCallback).toHaveBeenNthCalledWith( - 2, - { - test_1: { ID: 123, value: "five" }, - test_2: { ID: 234, value: "four" }, - // test_3 unchanged (incompatible array merge rejected) - test_3: { ID: 345, value: "three" }, - test_4: { ID: 456, value: "two" }, - test_5: { ID: 567, value: "one" }, - }, - ONYX_KEYS.COLLECTION.TEST_KEY, - ); - }); - }); - - it("should skip the update when a key not belonging to collection key is present in mergeCollection", () => { - const valuesReceived: Record = {}; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (data, key) => (valuesReceived[key] = data), - }); - - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - test_1: { ID: 123 }, - notMyTest: { beep: "boop" }, - }).then(() => { - expect(valuesReceived).toEqual({}); - }); - }); - - it("should return full object to callback when calling mergeCollection()", () => { - let lastCollectionObject: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => (lastCollectionObject = value), - }); - - return Onyx.multiSet({ - test_1: { - existingData: "test", - }, - test_2: { - existingData: "test", - }, - }) - .then(() => - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - test_1: { - ID: 123, - value: "one", - }, - test_2: { - ID: 234, - value: "two", - }, - }), - ) - .then(() => { - expect(lastCollectionObject).toEqual({ - test_1: { - ID: 123, - value: "one", - existingData: "test", - }, - test_2: { - ID: 234, - value: "two", - existingData: "test", - }, - }); - }); - }); - - it("should only trigger collection callback once when mergeCollection is called", async () => { - const mockCallback = jest.fn(); - const key1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; - const key2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`; - const key3 = `${ONYX_KEYS.COLLECTION.TEST_KEY}3`; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: mockCallback, - }); - - await waitForPromisesToResolve(); - expect(mockCallback).toHaveBeenCalledTimes(1); - mockCallback.mockClear(); - - // Call mergeCollection with multiple items - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [key1]: { id: "1", name: "Item 1" }, - [key2]: { id: "2", name: "Item 2" }, - [key3]: { id: "3", name: "Item 3" }, - }); - - // Should only be called once - expect(mockCallback).toHaveBeenCalledTimes(1); - - // Should receive the entire merged collection - const receivedData = mockCallback.mock.calls.at(0)[0]; - expect(receivedData).toEqual( - expect.objectContaining({ - [key1]: { id: "1", name: "Item 1" }, - [key2]: { id: "2", name: "Item 2" }, - [key3]: { id: "3", name: "Item 3" }, - }), - ); - }); - - it("should only trigger collection callback once when mergeCollection is called with null values", async () => { - const mockCallback = jest.fn(); - const key1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; - const key2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`; - const key3 = `${ONYX_KEYS.COLLECTION.TEST_KEY}3`; - - // Set up initial data - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [key1]: { id: "1", name: "Item 1" }, - [key2]: { id: "2", name: "Item 2" }, - [key3]: { id: "3", name: "Item 3" }, - }); - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: mockCallback, - }); - - await waitForPromisesToResolve(); - expect(mockCallback).toHaveBeenCalledTimes(1); - mockCallback.mockClear(); - - // Call mergeCollection with mixed null and data values - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [key1]: null, - [key2]: { id: "2", name: "Updated Item 2" }, - [key3]: null, - }); - - // Should only be called once - expect(mockCallback).toHaveBeenCalledTimes(1); - - // Should receive filtered collection - const receivedData = mockCallback.mock.calls.at(0)[0]; - expect(receivedData).toEqual({ - [key2]: { id: "2", name: "Updated Item 2" }, - }); - - // Verify removed items are not present - expect(receivedData[key1]).toBeUndefined(); - expect(receivedData[key3]).toBeUndefined(); - }); - - it("should use update data object to set/merge keys", () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - let otherTestKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.OTHER_TEST, - callback: (value) => { - otherTestKeyValue = value; - }, - }); - - return waitForPromisesToResolve() - .then(() => { - // Given the initial Onyx state: {test: true, otherTest: {test1: 'test1'}} - Onyx.set(ONYX_KEYS.TEST_KEY, true); - Onyx.set(ONYX_KEYS.OTHER_TEST, { test1: "test1" }); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(testKeyValue).toBe(true); - expect(otherTestKeyValue).toEqual({ test1: "test1" }); - - // When we pass a data object to Onyx.update - return Onyx.update([ - { - onyxMethod: "set", + + return Onyx.set(ONYX_KEYS.TEST_KEY, { + test1: { + test2: 'test2', + test3: 'test3', + }, + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: { + test2: 'test2', + test3: 'test3', + }, + }); + return Onyx.merge(ONYX_KEYS.TEST_KEY, { + test1: { + test2: undefined, + }, + }); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: {test2: 'test2', test3: 'test3'}, + }); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {test1: undefined}); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: {test2: 'test2', test3: 'test3'}, + }); + return Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: {test2: 'test2', test3: 'test3'}, + }); + }); + }); + + it('should ignore top-level and remove nested `undefined` values in Onyx.mergeCollection', () => { + let result: OnyxCollection; + + const routineRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}routine`; + const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`; + const workRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}work`; + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => (result = value), + }); + + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [routineRoute]: { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: undefined, + }, + }, + [holidayRoute]: { + waypoints: undefined, + }, + [workRoute]: undefined, + } as GenericCollection).then(() => { + expect(result).toEqual({ + [routineRoute]: { + waypoints: { + 1: 'Home', + 2: 'Work', + }, + }, + [holidayRoute]: {}, + [workRoute]: undefined, + }); + }); + }); + + it('should overwrite an array key nested inside an object', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, - value: "one", - }, - { - onyxMethod: "merge", - key: ONYX_KEYS.OTHER_TEST, - value: { test2: "test2" }, - }, - ]); - }) - .then(() => { - // Then the final Onyx state should be {test: 'one', otherTest: {test1: 'test1', test2: 'test2'}} - expect(testKeyValue).toBe("one"); - expect(otherTestKeyValue).toEqual({ test1: "test1", test2: "test2" }); - }); - }); - - it("should use update data object to merge a collection of keys", () => { - const mockCallback = jest.fn(); - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: mockCallback, - }); - - return waitForPromisesToResolve() - .then(() => { - mockCallback.mockReset(); + callback: (value) => { + testKeyValue = value; + }, + }); - // Given the initial Onyx state: {test_1: {existingData: 'test',}, test_2: {existingData: 'test',}} - Onyx.multiSet({ - test_1: { - existingData: "test", - }, - test_2: { - existingData: "test", - }, - }); - return waitForPromisesToResolve(); - }) - .then(() => { - // The collection callback receives the whole collection object. - expect( - mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0], - ).toEqual({ - test_1: { existingData: "test" }, - test_2: { existingData: "test" }, + Onyx.merge(ONYX_KEYS.TEST_KEY, {something: [1, 2, 3]}); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {something: [4]}).then(() => { + expect(testKeyValue).toEqual({something: [4]}); }); - mockCallback.mockReset(); + }); + + it('should overwrite an array key nested inside an object', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + Onyx.merge(ONYX_KEYS.TEST_KEY, {something: [1, 2, 3]}); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {something: [4]}).then(() => { + expect(testKeyValue).toEqual({something: [4]}); + }); + }); - // When we pass a mergeCollection data object to Onyx.update - return Onyx.update([ - { - onyxMethod: "mergecollection", + it('should properly set and merge when using mergeCollection', async () => { + const mockCallback = jest.fn(); + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, - value: { - test_1: { + callback: mockCallback, + }); + await waitForPromisesToResolve(); + + mockCallback.mockReset(); + + // The first time we call mergeCollection we'll be doing a multiSet internally + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: { ID: 123, - value: "one", - }, - test_2: { + value: 'one', + }, + test_2: { ID: 234, - value: "two", - }, - test_3: { + value: 'two', + }, + test_3: { ID: 345, - value: "three", - }, + value: 'three', }, - }, - ]); - }) - .then(() => { - // mergeCollection fires the collection object once with all 3 merged members. - expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback.mock.calls[0][0]).toEqual({ - test_1: { ID: 123, value: "one", existingData: "test" }, - test_2: { ID: 234, value: "two", existingData: "test" }, - test_3: { ID: 345, value: "three" }, + }) + .then(() => + // 2 key values to update and 2 new keys to add. + // MergeCollection will perform a mix of multiSet and multiMerge + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: { + ID: 123, + value: 'five', + }, + test_2: { + ID: 234, + value: 'four', + }, + test_3: ['abc', 'xyz'], // This shouldn't be merged since it's an array, and the original value is an object {ID, value} + test_4: { + ID: 456, + value: 'two', + }, + test_5: { + ID: 567, + value: 'one', + }, + }), + ) + .then(() => { + // Callback fires once per mergeCollection with the full collection object. + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenNthCalledWith( + 1, + { + test_1: {ID: 123, value: 'one'}, + test_2: {ID: 234, value: 'two'}, + test_3: {ID: 345, value: 'three'}, + }, + ONYX_KEYS.COLLECTION.TEST_KEY, + ); + expect(mockCallback).toHaveBeenNthCalledWith( + 2, + { + test_1: {ID: 123, value: 'five'}, + test_2: {ID: 234, value: 'four'}, + // test_3 unchanged (incompatible array merge rejected) + test_3: {ID: 345, value: 'three'}, + test_4: {ID: 456, value: 'two'}, + test_5: {ID: 567, value: 'one'}, + }, + ONYX_KEYS.COLLECTION.TEST_KEY, + ); + }); + }); + + it('should skip the update when a key not belonging to collection key is present in mergeCollection', () => { + const valuesReceived: Record = {}; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (data, key) => (valuesReceived[key] = data), }); - expect(mockCallback.mock.calls[0][1]).toBe( - ONYX_KEYS.COLLECTION.TEST_KEY, - ); - }); - }); - - it("should properly set all keys provided in a multiSet called via update", () => { - let lastCollectionObject: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => (lastCollectionObject = value), - }); - - return Onyx.multiSet({ - test_1: { - existingData: "test", - }, - test_2: { - existingData: "test", - }, - }) - .then(() => - Onyx.update([ - { - onyxMethod: "multiset", - value: { - test_1: { - ID: 123, - value: "one", - }, - test_2: { - ID: 234, - value: "two", - }, + + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: {ID: 123}, + notMyTest: {beep: 'boop'}, + }).then(() => { + expect(valuesReceived).toEqual({}); + }); + }); + + it('should return full object to callback when calling mergeCollection()', () => { + let lastCollectionObject: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => (lastCollectionObject = value), + }); + + return Onyx.multiSet({ + test_1: { + existingData: 'test', + }, + test_2: { + existingData: 'test', }, - }, - ] as unknown as Array>), - ) - .then(() => { - expect(lastCollectionObject).toEqual({ - test_1: { - ID: 123, - value: "one", - }, - test_2: { - ID: 234, - value: "two", - }, - }); - }); - }); - - it("should return all collection keys as a single object", () => { - const mockCallback = jest.fn(); - - // Given some initial collection data - const initialCollectionData = { - testConnectCollection_1: { - ID: 123, - value: "one", - }, - testConnectCollection_2: { - ID: 234, - value: "two", - }, - testConnectCollection_3: { - ID: 345, - value: "three", - }, - }; - - return Onyx.mergeCollection( - ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, - initialCollectionData as GenericCollection, - ) - .then(() => { - // When we connect to that collection + }) + .then(() => + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + test_1: { + ID: 123, + value: 'one', + }, + test_2: { + ID: 234, + value: 'two', + }, + }), + ) + .then(() => { + expect(lastCollectionObject).toEqual({ + test_1: { + ID: 123, + value: 'one', + existingData: 'test', + }, + test_2: { + ID: 234, + value: 'two', + existingData: 'test', + }, + }); + }); + }); + + it('should only trigger collection callback once when mergeCollection is called', async () => { + const mockCallback = jest.fn(); + const key1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; + const key2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`; + const key3 = `${ONYX_KEYS.COLLECTION.TEST_KEY}3`; + connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, - callback: mockCallback, + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: mockCallback, }); - return waitForPromisesToResolve(); - }) - .then(() => { - // Then we expect the callback to be called only once and the initial stored value to be initialCollectionData + + await waitForPromisesToResolve(); expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith( - initialCollectionData, - ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, + mockCallback.mockClear(); + + // Call mergeCollection with multiple items + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [key1]: {id: '1', name: 'Item 1'}, + [key2]: {id: '2', name: 'Item 2'}, + [key3]: {id: '3', name: 'Item 3'}, + } as GenericCollection); + + // Should only be called once + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Should receive the entire merged collection + const receivedData = mockCallback.mock.calls.at(0)[0]; + expect(receivedData).toEqual( + expect.objectContaining({ + [key1]: {id: '1', name: 'Item 1'}, + [key2]: {id: '2', name: 'Item 2'}, + [key3]: {id: '3', name: 'Item 3'}, + }), ); - }); - }); - - it("should return all collection keys as a single object when updating a collection key", () => { - const mockCallback = jest.fn(); - const collectionUpdate = { - testPolicy_1: { ID: 234, value: "one" }, - testPolicy_2: { ID: 123, value: "two" }, - }; - - // Given an Onyx.connect call to a collection key - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_POLICY, - callback: mockCallback, - }); - return ( - waitForPromisesToResolve() - // When mergeCollection is called with an updated collection - .then(() => - Onyx.mergeCollection( - ONYX_KEYS.COLLECTION.TEST_POLICY, - collectionUpdate as GenericCollection, - ), - ) - .then(() => { - // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update - expect(mockCallback).toHaveBeenCalledTimes(2); - - // AND the value for the first call should be null since the collection was not initialized at that point - expect(mockCallback).toHaveBeenNthCalledWith( - 1, - undefined, - ONYX_KEYS.COLLECTION.TEST_POLICY, - ); - - // AND the value for the second call should be collectionUpdate since the collection was updated - expect(mockCallback).toHaveBeenNthCalledWith( - 2, - collectionUpdate, - ONYX_KEYS.COLLECTION.TEST_POLICY, - ); - }) - ); - }); - - it("should send a value to Onyx.connect() when subscribing to a specific collection member key and keysChanged() is called", () => { - const mockCallback = jest.fn(); - const collectionUpdate = { - testPolicy_1: { ID: 234, value: "one" }, - testPolicy_2: { ID: 123, value: "two" }, - }; - - // Given an Onyx.connect call subscribing to a single collection member key - connection = Onyx.connect({ - key: `${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, - callback: mockCallback, - }); - return ( - waitForPromisesToResolve() - // When mergeCollection is called with an updated collection - .then(() => - Onyx.mergeCollection( - ONYX_KEYS.COLLECTION.TEST_POLICY, - collectionUpdate as GenericCollection, - ), - ) - .then(() => { - // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update - expect(mockCallback).toHaveBeenCalledTimes(2); - - // AND the value for the first call should be null since the collection was not initialized at that point - expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined); - - // AND the value for the second call should be collectionUpdate since the collection was updated - expect(mockCallback).toHaveBeenNthCalledWith( - 2, - collectionUpdate.testPolicy_1, - "testPolicy_1", - ); - }) - ); - }); - - it("should return all collection keys as a single object when a single collection member key is updated", () => { - const mockCallback = jest.fn(); - const collectionUpdate = { - testPolicy_1: { ID: 234, value: "one" }, - }; - - // Given an Onyx.connect call to a collection key - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_POLICY, - callback: mockCallback, - }); - return ( - waitForPromisesToResolve() - // When mergeCollection is called with an updated collection - .then(() => - Onyx.merge( - `${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, - collectionUpdate.testPolicy_1, - ), - ) - .then(() => { - // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update - expect(mockCallback).toHaveBeenCalledTimes(2); - - // AND the value for the second call should be collectionUpdate - expect(mockCallback).toHaveBeenNthCalledWith( - 1, - undefined, - ONYX_KEYS.COLLECTION.TEST_POLICY, - ); - expect(mockCallback).toHaveBeenNthCalledWith( - 2, - collectionUpdate, - ONYX_KEYS.COLLECTION.TEST_POLICY, - ); - }) - ); - }); - - it("should return a promise when set() called with the same value and there is no change", () => { - const promiseOne = Onyx.set("test", "pizza"); - expect(promiseOne).toBeInstanceOf(Promise); - return promiseOne.then(() => { - const promiseTwo = Onyx.set("test", "pizza"); - expect(promiseTwo).toBeInstanceOf(Promise); - }); - }); - - it("should not update a subscriber if the value in the cache has not changed at all", () => { - const mockCallback = jest.fn(); - const collectionUpdate = { - testPolicy_1: { ID: 234, value: "one" }, - }; - - // Given an Onyx.connect call to a collection key - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_POLICY, - callback: mockCallback, - }); - return ( - waitForPromisesToResolve() - // When merge is called with an updated collection - .then(() => - Onyx.merge( - `${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, - collectionUpdate.testPolicy_1, - ), - ) - .then(() => { - // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update - expect(mockCallback).toHaveBeenCalledTimes(2); - - // And the value for the second call should be collectionUpdate - expect(mockCallback).toHaveBeenNthCalledWith( - 2, - collectionUpdate, - ONYX_KEYS.COLLECTION.TEST_POLICY, - ); - }) + }); - // When merge is called again with the same collection not modified - .then(() => - Onyx.merge( - `${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, - collectionUpdate.testPolicy_1, - ), - ) - .then(() => { - // Then we should not expect another invocation of the callback - expect(mockCallback).toHaveBeenCalledTimes(2); - }) + it('should only trigger collection callback once when mergeCollection is called with null values', async () => { + const mockCallback = jest.fn(); + const key1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; + const key2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`; + const key3 = `${ONYX_KEYS.COLLECTION.TEST_KEY}3`; + + // Set up initial data + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [key1]: {id: '1', name: 'Item 1'}, + [key2]: {id: '2', name: 'Item 2'}, + [key3]: {id: '3', name: 'Item 3'}, + } as GenericCollection); + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: mockCallback, + }); + + await waitForPromisesToResolve(); + expect(mockCallback).toHaveBeenCalledTimes(1); + mockCallback.mockClear(); + + // Call mergeCollection with mixed null and data values + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [key1]: null, + [key2]: {id: '2', name: 'Updated Item 2'}, + [key3]: null, + } as GenericCollection); + + // Should only be called once + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Should receive filtered collection + const receivedData = mockCallback.mock.calls.at(0)[0]; + expect(receivedData).toEqual({ + [key2]: {id: '2', name: 'Updated Item 2'}, + }); + + // Verify removed items are not present + expect(receivedData[key1]).toBeUndefined(); + expect(receivedData[key3]).toBeUndefined(); + }); - // When merge is called again with an object of equivalent value but not the same reference - .then(() => - Onyx.merge( - `${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, - lodashClone(collectionUpdate.testPolicy_1), - ), - ) - .then(() => { - // Then we should not expect another invocation of the callback - expect(mockCallback).toHaveBeenCalledTimes(2); + it('should use update data object to set/merge keys', () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + let otherTestKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.OTHER_TEST, + callback: (value) => { + otherTestKeyValue = value; + }, + }); + + return waitForPromisesToResolve() + .then(() => { + // Given the initial Onyx state: {test: true, otherTest: {test1: 'test1'}} + Onyx.set(ONYX_KEYS.TEST_KEY, true); + Onyx.set(ONYX_KEYS.OTHER_TEST, {test1: 'test1'}); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(testKeyValue).toBe(true); + expect(otherTestKeyValue).toEqual({test1: 'test1'}); + + // When we pass a data object to Onyx.update + return Onyx.update([ + { + onyxMethod: 'set', + key: ONYX_KEYS.TEST_KEY, + value: 'one', + }, + { + onyxMethod: 'merge', + key: ONYX_KEYS.OTHER_TEST, + value: {test2: 'test2'}, + }, + ]); + }) + .then(() => { + // Then the final Onyx state should be {test: 'one', otherTest: {test1: 'test1', test2: 'test2'}} + expect(testKeyValue).toBe('one'); + expect(otherTestKeyValue).toEqual({test1: 'test1', test2: 'test2'}); + }); + }); + + it('should use update data object to merge a collection of keys', () => { + const mockCallback = jest.fn(); + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: mockCallback, + }); + + return waitForPromisesToResolve() + .then(() => { + mockCallback.mockReset(); + + // Given the initial Onyx state: {test_1: {existingData: 'test',}, test_2: {existingData: 'test',}} + Onyx.multiSet({ + test_1: { + existingData: 'test', + }, + test_2: { + existingData: 'test', + }, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + // The collection callback receives the whole collection object. + expect(mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]).toEqual({ + test_1: {existingData: 'test'}, + test_2: {existingData: 'test'}, + }); + mockCallback.mockReset(); + + // When we pass a mergeCollection data object to Onyx.update + return Onyx.update([ + { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: { + test_1: { + ID: 123, + value: 'one', + }, + test_2: { + ID: 234, + value: 'two', + }, + test_3: { + ID: 345, + value: 'three', + }, + }, + }, + ]); + }) + .then(() => { + // mergeCollection fires the collection object once with all 3 merged members. + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback.mock.calls[0][0]).toEqual({ + test_1: {ID: 123, value: 'one', existingData: 'test'}, + test_2: {ID: 234, value: 'two', existingData: 'test'}, + test_3: {ID: 345, value: 'three'}, + }); + expect(mockCallback.mock.calls[0][1]).toBe(ONYX_KEYS.COLLECTION.TEST_KEY); + }); + }); + + it('should properly set all keys provided in a multiSet called via update', () => { + let lastCollectionObject: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => (lastCollectionObject = value), + }); + + return Onyx.multiSet({ + test_1: { + existingData: 'test', + }, + test_2: { + existingData: 'test', + }, }) - ); - }); - - it("should return a promise that completes when all update() operations are done", () => { - const connections: Connection[] = []; - - const testCallback = jest.fn(); - const otherTestCallback = jest.fn(); - const collectionCallback = jest.fn(); - const itemKey = `${ONYX_KEYS.COLLECTION.TEST_UPDATE}1`; - - connections.push( - Onyx.connect({ key: ONYX_KEYS.TEST_KEY, callback: testCallback }), - ); - connections.push( - Onyx.connect({ key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback }), - ); - connections.push( - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_UPDATE, - callback: collectionCallback, - }), - ); - return waitForPromisesToResolve().then(() => - 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" } }, - }, - ]).then(() => { - expect(collectionCallback).toHaveBeenCalledTimes(2); - expect(collectionCallback).toHaveBeenNthCalledWith( - 1, - undefined, - ONYX_KEYS.COLLECTION.TEST_UPDATE, + .then(() => + Onyx.update([ + { + onyxMethod: 'multiset', + value: { + test_1: { + ID: 123, + value: 'one', + }, + test_2: { + ID: 234, + value: 'two', + }, + }, + }, + ] as unknown as Array>), + ) + .then(() => { + expect(lastCollectionObject).toEqual({ + test_1: { + ID: 123, + value: 'one', + }, + test_2: { + ID: 234, + value: 'two', + }, + }); + }); + }); + + it('should return all collection keys as a single object', () => { + const mockCallback = jest.fn(); + + // Given some initial collection data + const initialCollectionData = { + testConnectCollection_1: { + ID: 123, + value: 'one', + }, + testConnectCollection_2: { + ID: 234, + value: 'two', + }, + testConnectCollection_3: { + ID: 345, + value: 'three', + }, + }; + + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, initialCollectionData as GenericCollection) + .then(() => { + // When we connect to that collection + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, + callback: mockCallback, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + // Then we expect the callback to be called only once and the initial stored value to be initialCollectionData + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(initialCollectionData, ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION); + }); + }); + + it('should return all collection keys as a single object when updating a collection key', () => { + const mockCallback = jest.fn(); + const collectionUpdate = { + testPolicy_1: {ID: 234, value: 'one'}, + testPolicy_2: {ID: 123, value: 'two'}, + }; + + // Given an Onyx.connect call to a collection key + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_POLICY, + callback: mockCallback, + }); + return ( + waitForPromisesToResolve() + // When mergeCollection is called with an updated collection + .then(() => Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_POLICY, collectionUpdate as GenericCollection)) + .then(() => { + // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update + expect(mockCallback).toHaveBeenCalledTimes(2); + + // AND the value for the first call should be null since the collection was not initialized at that point + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_POLICY); + + // AND the value for the second call should be collectionUpdate since the collection was updated + expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY); + }) + ); + }); + + it('should send a value to Onyx.connect() when subscribing to a specific collection member key and keysChanged() is called', () => { + const mockCallback = jest.fn(); + const collectionUpdate = { + testPolicy_1: {ID: 234, value: 'one'}, + testPolicy_2: {ID: 123, value: 'two'}, + }; + + // Given an Onyx.connect call subscribing to a single collection member key + connection = Onyx.connect({ + key: `${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, + callback: mockCallback, + }); + return ( + waitForPromisesToResolve() + // When mergeCollection is called with an updated collection + .then(() => Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_POLICY, collectionUpdate as GenericCollection)) + .then(() => { + // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update + expect(mockCallback).toHaveBeenCalledTimes(2); + + // AND the value for the first call should be null since the collection was not initialized at that point + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined); + + // AND the value for the second call should be collectionUpdate since the collection was updated + expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate.testPolicy_1, 'testPolicy_1'); + }) ); - expect(collectionCallback).toHaveBeenNthCalledWith( - 2, - { [itemKey]: { a: "a" } }, - ONYX_KEYS.COLLECTION.TEST_UPDATE, + }); + + it('should return all collection keys as a single object when a single collection member key is updated', () => { + const mockCallback = jest.fn(); + const collectionUpdate = { + testPolicy_1: {ID: 234, value: 'one'}, + }; + + // Given an Onyx.connect call to a collection key + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_POLICY, + callback: mockCallback, + }); + return ( + waitForPromisesToResolve() + // When mergeCollection is called with an updated collection + .then(() => Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, collectionUpdate.testPolicy_1)) + .then(() => { + // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update + expect(mockCallback).toHaveBeenCalledTimes(2); + + // AND the value for the second call should be collectionUpdate + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_POLICY); + expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY); + }) ); + }); - expect(testCallback).toHaveBeenCalledTimes(2); - expect(testCallback).toHaveBeenNthCalledWith(1, undefined, undefined); - expect(testCallback).toHaveBeenNthCalledWith( - 2, - "taco", - ONYX_KEYS.TEST_KEY, + it('should return a promise when set() called with the same value and there is no change', () => { + const promiseOne = Onyx.set('test', 'pizza'); + expect(promiseOne).toBeInstanceOf(Promise); + return promiseOne.then(() => { + const promiseTwo = Onyx.set('test', 'pizza'); + expect(promiseTwo).toBeInstanceOf(Promise); + }); + }); + + it('should not update a subscriber if the value in the cache has not changed at all', () => { + const mockCallback = jest.fn(); + const collectionUpdate = { + testPolicy_1: {ID: 234, value: 'one'}, + }; + + // Given an Onyx.connect call to a collection key + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_POLICY, + callback: mockCallback, + }); + return ( + waitForPromisesToResolve() + // When merge is called with an updated collection + .then(() => Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, collectionUpdate.testPolicy_1)) + .then(() => { + // Then we expect the callback to have called twice, once for the initial connect call + once for the collection update + expect(mockCallback).toHaveBeenCalledTimes(2); + + // And the value for the second call should be collectionUpdate + expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY); + }) + + // When merge is called again with the same collection not modified + .then(() => Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, collectionUpdate.testPolicy_1)) + .then(() => { + // Then we should not expect another invocation of the callback + expect(mockCallback).toHaveBeenCalledTimes(2); + }) + + // When merge is called again with an object of equivalent value but not the same reference + .then(() => Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, lodashClone(collectionUpdate.testPolicy_1))) + .then(() => { + // Then we should not expect another invocation of the callback + expect(mockCallback).toHaveBeenCalledTimes(2); + }) ); + }); - expect(otherTestCallback).toHaveBeenCalledTimes(2); - // We set an initial value of 42 for ONYX_KEYS.OTHER_TEST in Onyx.init() - expect(otherTestCallback).toHaveBeenNthCalledWith( - 1, - 42, - ONYX_KEYS.OTHER_TEST, + it('should return a promise that completes when all update() operations are done', () => { + const connections: Connection[] = []; + + const testCallback = jest.fn(); + const otherTestCallback = jest.fn(); + const collectionCallback = jest.fn(); + const itemKey = `${ONYX_KEYS.COLLECTION.TEST_UPDATE}1`; + + connections.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); + connections.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback})); + connections.push( + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_UPDATE, + callback: collectionCallback, + }), ); - expect(otherTestCallback).toHaveBeenNthCalledWith( - 2, - "pizza", - ONYX_KEYS.OTHER_TEST, + return waitForPromisesToResolve().then(() => + 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'}}, + }, + ]).then(() => { + expect(collectionCallback).toHaveBeenCalledTimes(2); + expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, ONYX_KEYS.COLLECTION.TEST_UPDATE); + expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}}, ONYX_KEYS.COLLECTION.TEST_UPDATE); + + expect(testCallback).toHaveBeenCalledTimes(2); + expect(testCallback).toHaveBeenNthCalledWith(1, undefined, undefined); + expect(testCallback).toHaveBeenNthCalledWith(2, 'taco', ONYX_KEYS.TEST_KEY); + + expect(otherTestCallback).toHaveBeenCalledTimes(2); + // We set an initial value of 42 for ONYX_KEYS.OTHER_TEST in Onyx.init() + expect(otherTestCallback).toHaveBeenNthCalledWith(1, 42, ONYX_KEYS.OTHER_TEST); + expect(otherTestCallback).toHaveBeenNthCalledWith(2, 'pizza', ONYX_KEYS.OTHER_TEST); + for (const id of connections) Onyx.disconnect(id); + }), ); - for (const id of connections) Onyx.disconnect(id); - }), - ); - }); - - it("should merge an object with a batch of objects and undefined", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { test1: "test1" }) - .then(() => { - expect(testKeyValue).toEqual({ test1: "test1" }); - Onyx.merge(ONYX_KEYS.TEST_KEY, { test2: "test2" }); - Onyx.merge(ONYX_KEYS.TEST_KEY, { test3: "test3" }); - Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); - Onyx.merge(ONYX_KEYS.TEST_KEY, { test4: "test4" }); - Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(testKeyValue).toEqual({ - test1: "test1", - test2: "test2", - test3: "test3", - test4: "test4", - }); - }); - }); - - it("should merge an object with null and overwrite the value", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, { test1: "test1" }) - .then(() => { - expect(testKeyValue).toEqual({ test1: "test1" }); - Onyx.merge(ONYX_KEYS.TEST_KEY, null); - Onyx.merge(ONYX_KEYS.TEST_KEY, { test2: "test2" }); - Onyx.merge(ONYX_KEYS.TEST_KEY, { test3: "test3" }); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(testKeyValue).toEqual({ - test2: "test2", - test3: "test3", - }); - }); - }); - - it("should merge a key with null and allow subsequent updates", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, 1) - .then(() => { - expect(testKeyValue).toEqual(1); - Onyx.merge(ONYX_KEYS.TEST_KEY, null); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(testKeyValue).toEqual(undefined); - return Onyx.merge(ONYX_KEYS.TEST_KEY, 2); - }) - .then(() => { - expect(testKeyValue).toEqual(2); - }); - }); - - it("should merge a key after an invalid change is merged", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, {}) - .then(() => { - expect(testKeyValue).toEqual({}); - Onyx.merge(ONYX_KEYS.TEST_KEY, []); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(testKeyValue).toEqual({}); - return Onyx.merge(ONYX_KEYS.TEST_KEY, { test1: "test1" }); - }) - .then(() => { - expect(testKeyValue).toEqual({ test1: "test1" }); - }); - }); - - it("should not set null values in Onyx.merge, when the key doesn't exist yet", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.merge(ONYX_KEYS.TEST_KEY, { - waypoints: { - 1: "Home", - 2: "Work", - 3: null, - }, - }).then(() => { - expect(testKeyValue).toEqual({ - waypoints: { - 1: "Home", - 2: "Work", - }, - }); - }); - }); - it("should apply updates in order with Onyx.update", () => { - let testKeyValue: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, {}) - .then(() => { - expect(testKeyValue).toEqual({}); - Onyx.update([ - { - onyxMethod: "merge", + }); + + it('should merge an object with a batch of objects and undefined', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, - value: { test1: "test1" }, - }, - { - onyxMethod: "set", + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: 'test1'}) + .then(() => { + expect(testKeyValue).toEqual({test1: 'test1'}); + Onyx.merge(ONYX_KEYS.TEST_KEY, {test2: 'test2'}); + Onyx.merge(ONYX_KEYS.TEST_KEY, {test3: 'test3'}); + Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); + Onyx.merge(ONYX_KEYS.TEST_KEY, {test4: 'test4'}); + Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: 'test1', + test2: 'test2', + test3: 'test3', + test4: 'test4', + }); + }); + }); + + it('should merge an object with null and overwrite the value', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, - value: null, - }, - ]); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(testKeyValue).toBeUndefined(); - }); - }); - - it("mergeCollection should omit nested null values", () => { - let result: OnyxCollection; - - const routineRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}routine`; - const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => (result = value), - }); - - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [routineRoute]: { - waypoints: { - 1: "Home", - 2: "Work", - 3: "Gym", - }, - }, - [holidayRoute]: { - waypoints: { - 1: "Home", - 2: "Beach", - 3: null, - }, - }, - }).then(() => { - expect(result).toEqual({ - [routineRoute]: { - waypoints: { - 1: "Home", - 2: "Work", - 3: "Gym", - }, - }, - [holidayRoute]: { - waypoints: { - 1: "Home", - 2: "Beach", - }, - }, - }); - }); - }); - - it("should not call a collection item subscriber if the value did not change", () => { - const connections: Connection[] = []; - - const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; - const dog = `${ONYX_KEYS.COLLECTION.ANIMALS}dog`; - - const collectionCallback = jest.fn(); - const catCallback = jest.fn(); - const dogCallback = jest.fn(); - - connections.push( - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ANIMALS, - callback: collectionCallback, - }), - ); - connections.push(Onyx.connect({ key: cat, callback: catCallback })); - connections.push(Onyx.connect({ key: dog, callback: dogCallback })); - - const initialValue = { name: "Fluffy" }; - - const collectionDiff: GenericCollection = { - [cat]: initialValue, - [dog]: { name: "Rex" }, - }; - - return Onyx.set(cat, initialValue) - .then(() => { - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ANIMALS, collectionDiff); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(collectionCallback).toHaveBeenCalledTimes(2); - expect(collectionCallback).toHaveBeenNthCalledWith( - 1, - { [cat]: initialValue }, - ONYX_KEYS.COLLECTION.ANIMALS, - ); - expect(collectionCallback).toHaveBeenNthCalledWith( - 2, - collectionDiff, - ONYX_KEYS.COLLECTION.ANIMALS, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: 'test1'}) + .then(() => { + expect(testKeyValue).toEqual({test1: 'test1'}); + Onyx.merge(ONYX_KEYS.TEST_KEY, null); + Onyx.merge(ONYX_KEYS.TEST_KEY, {test2: 'test2'}); + Onyx.merge(ONYX_KEYS.TEST_KEY, {test3: 'test3'}); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test2: 'test2', + test3: 'test3', + }); + }); + }); + + it('should merge a key with null and allow subsequent updates', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, 1) + .then(() => { + expect(testKeyValue).toEqual(1); + Onyx.merge(ONYX_KEYS.TEST_KEY, null); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(testKeyValue).toEqual(undefined); + return Onyx.merge(ONYX_KEYS.TEST_KEY, 2); + }) + .then(() => { + expect(testKeyValue).toEqual(2); + }); + }); + + it('should merge a key after an invalid change is merged', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {}) + .then(() => { + expect(testKeyValue).toEqual({}); + Onyx.merge(ONYX_KEYS.TEST_KEY, []); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(testKeyValue).toEqual({}); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {test1: 'test1'}); + }) + .then(() => { + expect(testKeyValue).toEqual({test1: 'test1'}); + }); + }); + + it("should not set null values in Onyx.merge, when the key doesn't exist yet", () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.merge(ONYX_KEYS.TEST_KEY, { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: null, + }, + }).then(() => { + expect(testKeyValue).toEqual({ + waypoints: { + 1: 'Home', + 2: 'Work', + }, + }); + }); + }); + it('should apply updates in order with Onyx.update', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {}) + .then(() => { + expect(testKeyValue).toEqual({}); + Onyx.update([ + { + onyxMethod: 'merge', + key: ONYX_KEYS.TEST_KEY, + value: {test1: 'test1'}, + }, + { + onyxMethod: 'set', + key: ONYX_KEYS.TEST_KEY, + value: null, + }, + ]); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(testKeyValue).toBeUndefined(); + }); + }); + + it('mergeCollection should omit nested null values', () => { + let result: OnyxCollection; + + const routineRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}routine`; + const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`; + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => (result = value), + }); + + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [routineRoute]: { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: 'Gym', + }, + }, + [holidayRoute]: { + waypoints: { + 1: 'Home', + 2: 'Beach', + 3: null, + }, + }, + }).then(() => { + expect(result).toEqual({ + [routineRoute]: { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: 'Gym', + }, + }, + [holidayRoute]: { + waypoints: { + 1: 'Home', + 2: 'Beach', + }, + }, + }); + }); + }); + + it('should not call a collection item subscriber if the value did not change', () => { + const connections: Connection[] = []; + + const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; + const dog = `${ONYX_KEYS.COLLECTION.ANIMALS}dog`; + + const collectionCallback = jest.fn(); + const catCallback = jest.fn(); + const dogCallback = jest.fn(); + + connections.push( + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ANIMALS, + callback: collectionCallback, + }), ); + connections.push(Onyx.connect({key: cat, callback: catCallback})); + connections.push(Onyx.connect({key: dog, callback: dogCallback})); + + const initialValue = {name: 'Fluffy'}; + + const collectionDiff: GenericCollection = { + [cat]: initialValue, + [dog]: {name: 'Rex'}, + }; + + return Onyx.set(cat, initialValue) + .then(() => { + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ANIMALS, collectionDiff); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(collectionCallback).toHaveBeenCalledTimes(2); + expect(collectionCallback).toHaveBeenNthCalledWith(1, {[cat]: initialValue}, ONYX_KEYS.COLLECTION.ANIMALS); + expect(collectionCallback).toHaveBeenNthCalledWith(2, collectionDiff, ONYX_KEYS.COLLECTION.ANIMALS); + + // Cat hasn't changed from its original value, expect only the initial connect callback + expect(catCallback).toHaveBeenCalledTimes(1); + + // Dog was modified, expect the initial connect callback and the mergeCollection callback + expect(dogCallback).toHaveBeenCalledTimes(2); + + connections.map((id) => Onyx.disconnect(id)); + }); + }); + + it('should update Snapshot when its data changed', async () => { + const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; + const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`; + + const initialValue = {name: 'Fluffy'}; + const finalValue = {name: 'Kitty'}; + + await Onyx.set(cat, initialValue); + await Onyx.set(snapshot1, {data: {[cat]: initialValue}}); + + const callback = jest.fn(); + + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.SNAPSHOT, + callback, + }); + + await waitForPromisesToResolve(); + + await Onyx.update([{key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE}]); + + // The SNAPSHOT collection-root subscriber receives the whole collection. + expect(callback).toHaveBeenCalledTimes(2); + expect(callback.mock.calls[0][0]).toEqual({ + [snapshot1]: {data: {[cat]: initialValue}}, + }); + expect(callback.mock.calls[0][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); + expect(callback.mock.calls[1][0]).toEqual({ + [snapshot1]: {data: {[cat]: finalValue}}, + }); + expect(callback.mock.calls[1][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); + }); + + it('should merge allowlisted keys into Snapshot even if they were missing', async () => { + const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; + const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`; + + const initialValue = {name: 'Fluffy'}; + const finalValue = { + name: 'Kitty', + pendingAction: 'delete', + pendingFields: {preview: 'delete'}, + other: 'ignored', + }; + + await Onyx.set(cat, initialValue); + await Onyx.set(snapshot1, {data: {[cat]: initialValue}}); + + const callback = jest.fn(); + + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.SNAPSHOT, + callback, + }); + + await waitForPromisesToResolve(); + + await Onyx.update([{key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE}]); + + // The SNAPSHOT collection-root subscriber receives the whole collection. + expect(callback).toHaveBeenCalledTimes(2); + expect(callback.mock.calls[0][0]).toEqual({ + [snapshot1]: {data: {[cat]: initialValue}}, + }); + expect(callback.mock.calls[0][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); + expect(callback.mock.calls[1][0]).toEqual({ + [snapshot1]: { + data: { + [cat]: { + name: 'Kitty', + pendingAction: 'delete', + pendingFields: {preview: 'delete'}, + }, + }, + }, + }); + expect(callback.mock.calls[1][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); + }); + + describe('update', () => { + let logInfoFn = jest.fn(); + + beforeEach(() => { + logInfoFn = jest.fn(); + jest.spyOn(Logger, 'logInfo').mockImplementation(logInfoFn); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should squash all updates of collection-related keys into a single mergeCollection call', () => { + const connections: Connection[] = []; + + const routineRoute = `${ONYX_KEYS.COLLECTION.ROUTES}routine`; + const holidayRoute = `${ONYX_KEYS.COLLECTION.ROUTES}holiday`; - // Cat hasn't changed from its original value, expect only the initial connect callback - expect(catCallback).toHaveBeenCalledTimes(1); + const routesCollectionCallback = jest.fn(); + connections.push( + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ROUTES, + callback: routesCollectionCallback, + }), + ); + + return Onyx.update([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: routineRoute, + value: { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: 'Gym', + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: holidayRoute, + value: { + waypoints: { + 1: 'Home', + 2: 'Beach', + 3: 'Restaurant', + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYX_KEYS.COLLECTION.ROUTES, + value: { + [holidayRoute]: { + waypoints: { + 0: 'Bed', + }, + }, + [routineRoute]: { + waypoints: { + 0: 'Bed', + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: holidayRoute, + value: { + waypoints: { + 4: 'Home', + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: routineRoute, + value: { + waypoints: { + 3: 'Gym', + }, + }, + }, + ]).then(() => { + expect(routesCollectionCallback).toHaveBeenNthCalledWith( + 1, + { + [holidayRoute]: { + waypoints: { + 0: 'Bed', + 1: 'Home', + 2: 'Beach', + 3: 'Restaurant', + 4: 'Home', + }, + }, + [routineRoute]: { + waypoints: { + 0: 'Bed', + 1: 'Home', + 2: 'Work', + 3: 'Gym', + }, + }, + }, + ONYX_KEYS.COLLECTION.ROUTES, + ); + + connections.map((id) => Onyx.disconnect(id)); + }); + }); + + it('should return a promise that completes when all update() operations are done', () => { + const connections: Connection[] = []; + + const bob = `${ONYX_KEYS.COLLECTION.PEOPLE}bob`; + const lisa = `${ONYX_KEYS.COLLECTION.PEOPLE}lisa`; + + const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; + const dog = `${ONYX_KEYS.COLLECTION.ANIMALS}dog`; + + const testCallback = jest.fn(); + const otherTestCallback = jest.fn(); + const peopleCollectionCallback = jest.fn(); + const animalsCollectionCallback = jest.fn(); + const catCallback = jest.fn(); + + connections.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); + connections.push( + Onyx.connect({ + key: ONYX_KEYS.OTHER_TEST, + callback: otherTestCallback, + }), + ); + connections.push( + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ANIMALS, + callback: animalsCollectionCallback, + }), + ); + connections.push( + Onyx.connect({ + key: ONYX_KEYS.COLLECTION.PEOPLE, + callback: peopleCollectionCallback, + }), + ); + connections.push(Onyx.connect({key: cat, callback: catCallback})); + + return Onyx.update([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.TEST_KEY, + value: 'none', + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYX_KEYS.TEST_KEY, + value: {food: 'taco'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.TEST_KEY, + value: {drink: 'wine'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.OTHER_TEST, + value: {food: 'pizza'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYX_KEYS.OTHER_TEST, + value: {drink: 'water'}, + }, + {onyxMethod: Onyx.METHOD.MERGE, key: dog, value: {sound: 'woof'}}, + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYX_KEYS.COLLECTION.ANIMALS, + value: { + [cat]: {age: 5, size: 'S'}, + [dog]: {size: 'M'}, + }, + }, + {onyxMethod: Onyx.METHOD.SET, key: cat, value: {age: 3}}, + {onyxMethod: Onyx.METHOD.MERGE, key: cat, value: {sound: 'meow'}}, + {onyxMethod: Onyx.METHOD.MERGE, key: bob, value: {car: 'sedan'}}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: lisa, + value: {car: 'SUV', age: 21}, + }, + {onyxMethod: Onyx.METHOD.MERGE, key: bob, value: {age: 25}}, + ]).then(() => { + expect(testCallback).toHaveBeenNthCalledWith(1, {food: 'taco', drink: 'wine'}, ONYX_KEYS.TEST_KEY); + + expect(otherTestCallback).toHaveBeenNthCalledWith(1, {food: 'pizza', drink: 'water'}, ONYX_KEYS.OTHER_TEST); + + expect(animalsCollectionCallback).toHaveBeenNthCalledWith( + 1, + { + [cat]: {age: 3, sound: 'meow'}, + }, + ONYX_KEYS.COLLECTION.ANIMALS, + ); + expect(animalsCollectionCallback).toHaveBeenNthCalledWith( + 2, + { + [cat]: {age: 3, sound: 'meow'}, + [dog]: {size: 'M', sound: 'woof'}, + }, + ONYX_KEYS.COLLECTION.ANIMALS, + ); + + expect(catCallback).toHaveBeenNthCalledWith(1, {age: 3, sound: 'meow'}, cat); + + expect(peopleCollectionCallback).toHaveBeenNthCalledWith( + 1, + { + [bob]: {age: 25, car: 'sedan'}, + [lisa]: {age: 21, car: 'SUV'}, + }, + ONYX_KEYS.COLLECTION.PEOPLE, + ); + + connections.map((id) => Onyx.disconnect(id)); + }); + }); + + it('should apply updates in the correct order with Onyx.update', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {}) + .then(() => { + expect(testKeyValue).toEqual({}); + Onyx.update([ + { + onyxMethod: 'merge', + key: ONYX_KEYS.TEST_KEY, + value: {test1: 'test1'}, + }, + { + onyxMethod: 'set', + key: ONYX_KEYS.TEST_KEY, + value: null, + }, + ]); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(testKeyValue).toBeUndefined(); + }); + }); + + it('should replace the old value after a null merge in the top-level object when batching updates', async () => { + let result: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_UPDATE, + callback: (value) => { + result = value; + }, + }); + + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { + id: 'entry1', + someKey: 'someValue', + }, + }); + + const queuedUpdates: Array> = [ + { + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + // Removing the entire object in this update. + // Any subsequent changes to this key should completely replace the old value. + value: null, + }, + { + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + // This change should completely replace `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1` old value. + value: { + someKey: 'someValueChanged', + }, + }, + ]; + + await Onyx.update(queuedUpdates); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { + someKey: 'someValueChanged', + }, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual({someKey: 'someValueChanged'}); + }); + + describe('should replace the old value after a null merge in a nested property when batching updates', () => { + let result: unknown; + + beforeEach(() => { + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_UPDATE, + callback: (value) => { + result = value; + }, + }); + }); + + it('replacing old object after null merge', async () => { + const entry1: GenericDeepRecord = { + sub_entry1: { + id: 'sub_entry1', + someKey: 'someValue', + }, + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + + const entry1ExpectedResult = lodashCloneDeep(entry1); + const queuedUpdates: Array> = []; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + // Removing the "sub_entry1" object in this update. + // Any subsequent changes to this object should completely replace the existing object in store. + sub_entry1: null, + }, + }); + delete entry1ExpectedResult.sub_entry1; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + // This change should completely replace "sub_entry1" existing object in store. + sub_entry1: { + newKey: 'newValue', + }, + }, + }); + entry1ExpectedResult.sub_entry1 = {newKey: 'newValue'}; + + await Onyx.update(queuedUpdates); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); + }); + + it('setting new object after null merge', async () => { + const entry1: GenericDeepRecord = { + sub_entry1: { + id: 'sub_entry1', + someKey: 'someValue', + someNestedObject: { + someNestedKey: 'someNestedValue', + anotherNestedObject: { + anotherNestedKey: 'anotherNestedValue', + }, + }, + }, + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + + const entry1ExpectedResult = lodashCloneDeep(entry1); + const queuedUpdates: Array> = []; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + sub_entry1: { + someNestedObject: { + // Introducing a new "anotherNestedObject2" object in this update. + anotherNestedObject2: { + anotherNestedKey2: 'anotherNestedValue2', + }, + }, + }, + }, + }); + entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = {anotherNestedKey2: 'anotherNestedValue2'}; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + sub_entry1: { + someNestedObject: { + // Removing the "anotherNestedObject2" object in this update. + // This property was only introduced in a previous update, so we don't need to care + // about an old existing value because there isn't one. + anotherNestedObject2: null, + }, + }, + }, + }); + delete entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + sub_entry1: { + someNestedObject: { + // Introducing the "anotherNestedObject2" object again with this update. + anotherNestedObject2: { + newNestedKey2: 'newNestedValue2', + }, + }, + }, + }, + }); + entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = {newNestedKey2: 'newNestedValue2'}; + + await Onyx.update(queuedUpdates); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); + }); + + it('setting new object after null merge of a primitive property', async () => { + const entry1: GenericDeepRecord = { + sub_entry1: { + id: 'sub_entry1', + someKey: 'someValue', + someNestedObject: { + someNestedKey: 'someNestedValue', + anotherNestedObject: { + anotherNestedKey: 'anotherNestedValue', + }, + }, + }, + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + + const entry1ExpectedResult = lodashCloneDeep(entry1); + const queuedUpdates: Array> = []; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + sub_entry1: { + someNestedObject: { + anotherNestedObject: { + // Removing the "anotherNestedKey" property in this update. + // This property's existing value in store is a primitive value, so we don't need to care + // about it when merging new values in any next updates. + anotherNestedKey: null, + }, + }, + }, + }, + }); + delete entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject.anotherNestedKey; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + sub_entry1: { + someNestedObject: { + anotherNestedObject: { + // Setting a new object to the "anotherNestedKey" property. + anotherNestedKey: { + newNestedKey: 'newNestedValue', + }, + }, + }, + }, + }, + }); + entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject.anotherNestedKey = {newNestedKey: 'newNestedValue'}; + + await Onyx.update(queuedUpdates); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); + }); + + it('replacing nested object during updates', async () => { + const entry1: GenericDeepRecord | undefined = { + id: 'entry1', + someKey: 'someValue', + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { + id: 'entry1', + someKey: 'someValue', + }, + }); + + let entry1ExpectedResult = lodashCloneDeep(entry1) as GenericDeepRecord | undefined; + const queuedUpdates: Array> = []; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + // Removing the entire object in this update. + // Any subsequent changes to this key should completely replace the old value. + value: null, + }); + entry1ExpectedResult = undefined; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + // This change should completely replace `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1` old value. + value: { + someKey: 'someValueChanged', + someNestedObject: { + someNestedKey: 'someNestedValue', + }, + }, + }); + entry1ExpectedResult = { + someKey: 'someValueChanged', + someNestedObject: {someNestedKey: 'someNestedValue'}, + }; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + // Removing the "sub_entry1" object in this update. + // Any subsequent changes to this key should completely replace the old update's value. + someNestedObject: null, + }, + }); + delete entry1ExpectedResult.someNestedObject; + + queuedUpdates.push({ + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + // This change should completely replace `someNestedObject` old update's value. + value: { + someNestedObject: { + someNestedKeyChanged: 'someNestedValueChange', + }, + }, + }); + entry1ExpectedResult.someNestedObject = { + someNestedKeyChanged: 'someNestedValueChange', + }; + + await Onyx.update(queuedUpdates); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); + }); + + describe('mergeCollection', () => { + it('replacing old object after null merge', async () => { + const entry1: GenericDeepRecord = { + sub_entry1: { + id: 'sub_entry1', + someKey: 'someValue', + }, + }; + + const entry2: GenericDeepRecord = { + sub_entry2: { + id: 'sub_entry2', + someKey: 'someValue', + }, + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`]: entry2, + }); + + const entry1ExpectedResult = lodashCloneDeep(entry1); + const entry2ExpectedResult = lodashCloneDeep(entry2); + const queuedUpdates: Array> = []; + + queuedUpdates.push( + { + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + // Removing the "sub_entry1" object in this update. + // Any subsequent changes to this object should completely replace the existing object in store. + sub_entry1: null, + }, + }, + { + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`, + onyxMethod: 'merge', + value: { + // Removing the "sub_entry2" object in this update. + // Any subsequent changes to this object should completely replace the existing object in store. + sub_entry2: null, + }, + }, + ); + delete entry1ExpectedResult.sub_entry1; + delete entry2ExpectedResult.sub_entry2; + + queuedUpdates.push( + { + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, + onyxMethod: 'merge', + value: { + // This change should completely replace "sub_entry1" existing object in store. + sub_entry1: { + newKey: 'newValue', + }, + }, + }, + { + key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`, + onyxMethod: 'merge', + value: { + // This change should completely replace "sub_entry2" existing object in store. + sub_entry2: { + newKey: 'newValue', + }, + }, + }, + ); + entry1ExpectedResult.sub_entry1 = {newKey: 'newValue'}; + entry2ExpectedResult.sub_entry2 = {newKey: 'newValue'}; + + await Onyx.update(queuedUpdates); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`]: entry2ExpectedResult, + }); + expect(await StorageMock.multiGet([`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`])).toEqual([ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, entry1ExpectedResult], + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`, entry2ExpectedResult], + ]); + }); + + it('should not save a RAM-only collection to storage', async () => { + const key1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const key2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; + + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, { + [key1]: 'value 1', + [key2]: 'value 2', + }); + + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, { + [key1]: 'updated value 1', + [key2]: 'updated value 2', + }); + + expect(await cache.get(key1)).toEqual('updated value 1'); + expect(await cache.get(key2)).toEqual('updated value 2'); + expect(await StorageMock.getItem(key1)).toBeNull(); + expect(await StorageMock.getItem(key2)).toBeNull(); + }); + }); + }); + + it('should properly handle setCollection operations in update()', () => { + const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; + const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; + const routeC = `${ONYX_KEYS.COLLECTION.ROUTES}C`; + + let routesCollection: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ROUTES, + callback: (value) => { + routesCollection = value; + }, + }); + + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + [routeB]: {name: 'Route B'}, + [routeC]: {name: 'Route C'}, + }) + .then(() => + Onyx.update([ + { + onyxMethod: Onyx.METHOD.SET_COLLECTION, + key: ONYX_KEYS.COLLECTION.ROUTES, + value: { + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + }, + }, + ]), + ) + .then(() => { + expect(routesCollection).toEqual({ + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + }); + }); + }); + + it('should handle mixed operations with setCollection in update()', () => { + const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; + const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; + const testKey = ONYX_KEYS.TEST_KEY; + let routesCollection: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ROUTES, + callback: (value) => { + routesCollection = value; + }, + }); + + let testKeyValue: unknown; + Onyx.connect({ + key: testKey, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + [routeB]: {name: 'Route B'}, + }) + .then(() => + Onyx.update([ + { + onyxMethod: Onyx.METHOD.SET, + key: testKey, + value: 'test value', + }, + { + onyxMethod: Onyx.METHOD.SET_COLLECTION, + key: ONYX_KEYS.COLLECTION.ROUTES, + value: { + [routeA]: {name: 'Final Route A'}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: testKey, + value: 'merged value', + }, + ]), + ) + .then(() => { + expect(routesCollection).toEqual({ + [routeA]: {name: 'Final Route A'}, + }); + + expect(testKeyValue).toBe('merged value'); + }); + }); + + it('should trigger individual callbacks for each key when update is called with mergeCollection', async () => { + const collectionCallback = jest.fn(); + const individualCallback1 = jest.fn(); + const individualCallback2 = jest.fn(); + const key1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; + const key2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`; + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: collectionCallback, + }); + + const connection1 = Onyx.connect({ + key: key1, + callback: individualCallback1, + }); + + const connection2 = Onyx.connect({ + key: key2, + callback: individualCallback2, + }); + + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + individualCallback1.mockClear(); + individualCallback2.mockClear(); + + // Perform update with mergeCollection + await Onyx.update([ + { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: { + [key1]: {id: '1', name: 'Updated Item 1'}, + [key2]: {id: '2', name: 'Updated Item 2'}, + }, + }, + ]); - // Dog was modified, expect the initial connect callback and the mergeCollection callback - expect(dogCallback).toHaveBeenCalledTimes(2); + // Collection callback should be called + expect(collectionCallback).toHaveBeenCalled(); - connections.map((id) => Onyx.disconnect(id)); - }); - }); + // Individual callbacks should still work + expect(individualCallback1).toHaveBeenCalledWith({id: '1', name: 'Updated Item 1'}, key1); + expect(individualCallback2).toHaveBeenCalledWith({id: '2', name: 'Updated Item 2'}, key2); - it("should update Snapshot when its data changed", async () => { - const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; - const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`; + Onyx.disconnect(connection1); + Onyx.disconnect(connection2); + }); - const initialValue = { name: "Fluffy" }; - const finalValue = { name: "Kitty" }; + it('should not save a RAM-only collection to storage', async () => { + const queuedUpdates: Array> = []; - await Onyx.set(cat, initialValue); - await Onyx.set(snapshot1, { data: { [cat]: initialValue } }); + queuedUpdates.push( + { + key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, + onyxMethod: 'merge', + value: null, + }, + { + key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`, + onyxMethod: 'merge', + value: null, + }, + ); - const callback = jest.fn(); + queuedUpdates.push( + { + key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, + onyxMethod: 'merge', + value: 'updated test 1', + }, + { + key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`, + onyxMethod: 'merge', + value: 'updated test 2', + }, + ); + + await Onyx.update(queuedUpdates); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toEqual('updated test 1'); + expect(cache.get(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`)).toEqual('updated test 2'); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBeNull(); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`)).toBeNull(); + }); + + describe('should log and skip invalid operations', () => { + it('invalid method', async () => { + await act(async () => + Onyx.update([ + {onyxMethod: 'set', key: ONYX_KEYS.TEST_KEY, value: 'test1'}, + // @ts-expect-error invalid method + { + onyxMethod: 'invalidMethod', + key: ONYX_KEYS.OTHER_TEST, + value: 'test2', + }, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid onyxMethod invalidMethod in Onyx update. Skipping this operation.'); + }); + + it('non-object value passed to multiSet', async () => { + await act(async () => + Onyx.update([ + // @ts-expect-error non-object value + {onyxMethod: 'multiset', key: ONYX_KEYS.TEST_KEY, value: []}, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid value provided in Onyx multiSet. Value must be of type object. Skipping this operation.'); + }); + + it('non-string value passed to key', async () => { + await act(async () => + Onyx.update([ + // @ts-expect-error invalid key + {onyxMethod: 'set', key: 1000, value: 'test'}, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid number key provided in Onyx update. Key must be of type string. Skipping this operation.'); + }); + + it('invalid or empty value passed to mergeCollection', async () => { + await act(async () => + Onyx.update([ + // @ts-expect-error invalid value + { + onyxMethod: 'mergecollection', + key: ONYX_KEYS.COLLECTION.TEST_KEY, + value: 'test1', + }, + ]), + ); + + expect(logInfoFn).toHaveBeenNthCalledWith(1, 'Invalid or empty value provided in Onyx mergeCollection. Skipping this operation.'); + }); + }); + }); + + describe('merge', () => { + it('should replace the old value after a null merge in the top-level object when batching merges', async () => { + let result: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_UPDATE, + callback: (value) => { + result = value; + }, + }); - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.SNAPSHOT, - callback, - }); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { + id: 'entry1', + someKey: 'someValue', + }, + }); - await waitForPromisesToResolve(); + // Removing the entire object in this merge. + // Any subsequent changes to this key should completely replace the old value. + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, null); - await Onyx.update([ - { key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE }, - ]); + // This change should completely replace `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1` old value. + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + someKey: 'someValueChanged', + }); - // The SNAPSHOT collection-root subscriber receives the whole collection. - expect(callback).toHaveBeenCalledTimes(2); - expect(callback.mock.calls[0][0]).toEqual({ - [snapshot1]: { data: { [cat]: initialValue } }, - }); - expect(callback.mock.calls[0][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); - expect(callback.mock.calls[1][0]).toEqual({ - [snapshot1]: { data: { [cat]: finalValue } }, - }); - expect(callback.mock.calls[1][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); - }); + await waitForPromisesToResolve(); - it("should merge allowlisted keys into Snapshot even if they were missing", async () => { - const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; - const snapshot1 = `${ONYX_KEYS.COLLECTION.SNAPSHOT}1`; + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { + someKey: 'someValueChanged', + }, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual({someKey: 'someValueChanged'}); + }); + + describe('should replace the old value after a null merge in a nested property when batching merges', () => { + let result: unknown; + + beforeEach(() => { + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_UPDATE, + callback: (value) => { + result = value; + }, + }); + }); + + it('replacing old object after null merge', async () => { + const entry1: GenericDeepRecord = { + sub_entry1: { + id: 'sub_entry1', + someKey: 'someValue', + }, + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + + const entry1ExpectedResult = lodashCloneDeep(entry1); + + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + // Removing the "sub_entry1" object in this merge. + // Any subsequent changes to this object should completely replace the existing object in store. + sub_entry1: null, + }); + delete entry1ExpectedResult.sub_entry1; + + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + // This change should completely replace "sub_entry1" existing object in store. + sub_entry1: { + newKey: 'newValue', + }, + }); + entry1ExpectedResult.sub_entry1 = {newKey: 'newValue'}; + + await waitForPromisesToResolve(); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); + }); + + it('setting new object after null merge', async () => { + const entry1: GenericDeepRecord = { + sub_entry1: { + id: 'sub_entry1', + someKey: 'someValue', + someNestedObject: { + someNestedKey: 'someNestedValue', + anotherNestedObject: { + anotherNestedKey: 'anotherNestedValue', + }, + }, + }, + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + + const entry1ExpectedResult = lodashCloneDeep(entry1); + + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + sub_entry1: { + someNestedObject: { + // Introducing a new "anotherNestedObject2" object in this merge. + anotherNestedObject2: { + anotherNestedKey2: 'anotherNestedValue2', + }, + }, + }, + }); + entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = {anotherNestedKey2: 'anotherNestedValue2'}; + + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + sub_entry1: { + someNestedObject: { + // Removing the "anotherNestedObject2" object in this merge. + // This property was only introduced in a previous merge, so we don't need to care + // about an old existing value because there isn't one. + anotherNestedObject2: null, + }, + }, + }); + delete entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2; + + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + sub_entry1: { + someNestedObject: { + // Introducing the "anotherNestedObject2" object again with this update. + anotherNestedObject2: { + newNestedKey2: 'newNestedValue2', + }, + }, + }, + }); + entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = {newNestedKey2: 'newNestedValue2'}; + + await waitForPromisesToResolve(); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); + }); + + it('setting new object after null merge of a primitive property', async () => { + const entry1: GenericDeepRecord = { + sub_entry1: { + id: 'sub_entry1', + someKey: 'someValue', + someNestedObject: { + someNestedKey: 'someNestedValue', + anotherNestedObject: { + anotherNestedKey: 'anotherNestedValue', + }, + }, + }, + }; + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + }); + + const entry1ExpectedResult = lodashCloneDeep(entry1); + + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + sub_entry1: { + someNestedObject: { + anotherNestedObject: { + // Removing the "anotherNestedKey" property in this merge. + // This property's existing value in store is a primitive value, so we don't need to care + // about it when merging new values in any next merges. + anotherNestedKey: null, + }, + }, + }, + }); + delete entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject.anotherNestedKey; + + Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { + sub_entry1: { + someNestedObject: { + anotherNestedObject: { + // Setting a new object to the "anotherNestedKey" property. + anotherNestedKey: { + newNestedKey: 'newNestedValue', + }, + }, + }, + }, + }); + entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject.anotherNestedKey = {newNestedKey: 'newNestedValue'}; + + await waitForPromisesToResolve(); + + expect(result).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + }); + expect(await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`)).toEqual(entry1ExpectedResult); + }); + }); + + it('should remove a deeply nested null when merging an existing key', () => { + let result: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => (result = value), + }); + + const initialValue = { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: 'Gym', + }, + }; + + return Onyx.set(ONYX_KEYS.TEST_KEY, initialValue) + .then(() => { + expect(result).toEqual(initialValue); + Onyx.merge(ONYX_KEYS.TEST_KEY, { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: null, + }, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(result).toEqual({ + waypoints: { + 1: 'Home', + 2: 'Work', + }, + }); + }); + }); + + it('should not save a RAM-only key to storage when using merge', async () => { + await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, {someProperty: 'value'}); + + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({ + someProperty: 'value', + }); + expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); + }); + + it('should not save a RAM-only collection member to storage when using merge', async () => { + const collectionMemberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + await Onyx.merge(collectionMemberKey, {data: 'test'}); + + expect(cache.get(collectionMemberKey)).toEqual({data: 'test'}); + expect(await StorageMock.getItem(collectionMemberKey)).toBeNull(); + }); + }); + + describe('set', () => { + it('should work with skipCacheCheck option', () => { + let testKeyValue: unknown; + + connection = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); - const initialValue = { name: "Fluffy" }; - const finalValue = { - name: "Kitty", - pendingAction: "delete", - pendingFields: { preview: "delete" }, - other: "ignored", - }; + const testData = {id: 1, name: 'test'}; - await Onyx.set(cat, initialValue); - await Onyx.set(snapshot1, { data: { [cat]: initialValue } }); + return Onyx.set(ONYX_KEYS.TEST_KEY, testData) + .then(() => { + expect(testKeyValue).toEqual(testData); - const callback = jest.fn(); + return Onyx.set(ONYX_KEYS.TEST_KEY, testData, { + skipCacheCheck: true, + }); + }) + .then(() => { + expect(testKeyValue).toEqual(testData); + }); + }); - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.SNAPSHOT, - callback, - }); + it('should not save a RAM-only key to storage', async () => { + await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'test'); - await waitForPromisesToResolve(); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual('test'); + expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); + }); - await Onyx.update([ - { key: cat, value: finalValue, onyxMethod: Onyx.METHOD.MERGE }, - ]); + it('should not save a member of a RAM-only collection to storage', async () => { + const collectionMemberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + await Onyx.set(collectionMemberKey, 'test'); - // The SNAPSHOT collection-root subscriber receives the whole collection. - expect(callback).toHaveBeenCalledTimes(2); - expect(callback.mock.calls[0][0]).toEqual({ - [snapshot1]: { data: { [cat]: initialValue } }, - }); - expect(callback.mock.calls[0][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); - expect(callback.mock.calls[1][0]).toEqual({ - [snapshot1]: { - data: { - [cat]: { - name: "Kitty", - pendingAction: "delete", - pendingFields: { preview: "delete" }, - }, - }, - }, + expect(cache.get(collectionMemberKey)).toEqual('test'); + expect(await StorageMock.getItem(collectionMemberKey)).toBeNull(); + }); }); - expect(callback.mock.calls[1][1]).toBe(ONYX_KEYS.COLLECTION.SNAPSHOT); - }); - describe("update", () => { - let logInfoFn = jest.fn(); + describe('multiSet', () => { + it('should only save non RAM-only keys to storage', async () => { + const otherTestValue = 'non ram only value'; + const collectionMemberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; - beforeEach(() => { - logInfoFn = jest.fn(); - jest.spyOn(Logger, "logInfo").mockImplementation(logInfoFn); - }); + await Onyx.multiSet({ + [ONYX_KEYS.OTHER_TEST]: otherTestValue, + [ONYX_KEYS.RAM_ONLY_TEST_KEY]: 'test value 1', + [collectionMemberKey]: 'test value 2', + }); - afterEach(() => { - jest.restoreAllMocks(); + expect(await StorageMock.getItem(ONYX_KEYS.OTHER_TEST)).toEqual(otherTestValue); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual('test value 1'); + expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); + expect(cache.get(collectionMemberKey)).toEqual('test value 2'); + expect(await StorageMock.getItem(collectionMemberKey)).toBeNull(); + }); }); - it("should squash all updates of collection-related keys into a single mergeCollection call", () => { - const connections: Connection[] = []; + describe('setCollection', () => { + it('should replace all existing collection members with new values and remove old ones', async () => { + let result: OnyxCollection; + const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; + const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; + const routeB1 = `${ONYX_KEYS.COLLECTION.ROUTES}B1`; + const routeC = `${ONYX_KEYS.COLLECTION.ROUTES}C`; - const routineRoute = `${ONYX_KEYS.COLLECTION.ROUTES}routine`; - const holidayRoute = `${ONYX_KEYS.COLLECTION.ROUTES}holiday`; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ROUTES, + callback: (value) => (result = value), + }); - const routesCollectionCallback = jest.fn(); - connections.push( - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ROUTES, - callback: routesCollectionCallback, - }), - ); - - return Onyx.update([ - { - onyxMethod: Onyx.METHOD.MERGE, - key: routineRoute, - value: { - waypoints: { - 1: "Home", - 2: "Work", - 3: "Gym", - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: holidayRoute, - value: { - waypoints: { - 1: "Home", - 2: "Beach", - 3: "Restaurant", - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE_COLLECTION, - key: ONYX_KEYS.COLLECTION.ROUTES, - value: { - [holidayRoute]: { - waypoints: { - 0: "Bed", - }, - }, - [routineRoute]: { - waypoints: { - 0: "Bed", - }, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: holidayRoute, - value: { - waypoints: { - 4: "Home", - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: routineRoute, - value: { - waypoints: { - 3: "Gym", - }, - }, - }, - ]).then(() => { - expect(routesCollectionCallback).toHaveBeenNthCalledWith( - 1, - { - [holidayRoute]: { - waypoints: { - 0: "Bed", - 1: "Home", - 2: "Beach", - 3: "Restaurant", - 4: "Home", - }, - }, - [routineRoute]: { - waypoints: { - 0: "Bed", - 1: "Home", - 2: "Work", - 3: "Gym", - }, - }, - }, - ONYX_KEYS.COLLECTION.ROUTES, - ); + // Set initial collection state + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + [routeB1]: {name: 'Route B1'}, + [routeC]: {name: 'Route C'}, + }); - connections.map((id) => Onyx.disconnect(id)); - }); - }); + // Replace with new collection data + await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + [routeC]: {name: 'New Route C'}, + }); - it("should return a promise that completes when all update() operations are done", () => { - const connections: Connection[] = []; + expect(result).toEqual({ + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + [routeC]: {name: 'New Route C'}, + }); + }); - const bob = `${ONYX_KEYS.COLLECTION.PEOPLE}bob`; - const lisa = `${ONYX_KEYS.COLLECTION.PEOPLE}lisa`; + it('should replace the collection with empty values', async () => { + let result: OnyxCollection; + const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; + const callback = jest.fn(); - const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; - const dog = `${ONYX_KEYS.COLLECTION.ANIMALS}dog`; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ROUTES, + callback, + }); + await waitForPromisesToResolve(); - const testCallback = jest.fn(); - const otherTestCallback = jest.fn(); - const peopleCollectionCallback = jest.fn(); - const animalsCollectionCallback = jest.fn(); - const catCallback = jest.fn(); + callback.mockReset(); + callback.mockImplementation((value) => { + result = value; + }); - connections.push( - Onyx.connect({ key: ONYX_KEYS.TEST_KEY, callback: testCallback }), - ); - connections.push( - Onyx.connect({ - key: ONYX_KEYS.OTHER_TEST, - callback: otherTestCallback, - }), - ); - connections.push( - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ANIMALS, - callback: animalsCollectionCallback, - }), - ); - connections.push( - Onyx.connect({ - key: ONYX_KEYS.COLLECTION.PEOPLE, - callback: peopleCollectionCallback, - }), - ); - connections.push(Onyx.connect({ key: cat, callback: catCallback })); - - return Onyx.update([ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYX_KEYS.TEST_KEY, - value: "none", - }, - { - onyxMethod: Onyx.METHOD.SET, - key: ONYX_KEYS.TEST_KEY, - value: { food: "taco" }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYX_KEYS.TEST_KEY, - value: { drink: "wine" }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYX_KEYS.OTHER_TEST, - value: { food: "pizza" }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYX_KEYS.OTHER_TEST, - value: { drink: "water" }, - }, - { onyxMethod: Onyx.METHOD.MERGE, key: dog, value: { sound: "woof" } }, - { - onyxMethod: Onyx.METHOD.MERGE_COLLECTION, - key: ONYX_KEYS.COLLECTION.ANIMALS, - value: { - [cat]: { age: 5, size: "S" }, - [dog]: { size: "M" }, - }, - }, - { onyxMethod: Onyx.METHOD.SET, key: cat, value: { age: 3 } }, - { onyxMethod: Onyx.METHOD.MERGE, key: cat, value: { sound: "meow" } }, - { onyxMethod: Onyx.METHOD.MERGE, key: bob, value: { car: "sedan" } }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: lisa, - value: { car: "SUV", age: 21 }, - }, - { onyxMethod: Onyx.METHOD.MERGE, key: bob, value: { age: 25 } }, - ]).then(() => { - expect(testCallback).toHaveBeenNthCalledWith( - 1, - { food: "taco", drink: "wine" }, - ONYX_KEYS.TEST_KEY, - ); + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + }); - expect(otherTestCallback).toHaveBeenNthCalledWith( - 1, - { food: "pizza", drink: "water" }, - ONYX_KEYS.OTHER_TEST, - ); + await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, {}); - expect(animalsCollectionCallback).toHaveBeenNthCalledWith( - 1, - { - [cat]: { age: 3, sound: "meow" }, - }, - ONYX_KEYS.COLLECTION.ANIMALS, - ); - expect(animalsCollectionCallback).toHaveBeenNthCalledWith( - 2, - { - [cat]: { age: 3, sound: "meow" }, - [dog]: { size: "M", sound: "woof" }, - }, - ONYX_KEYS.COLLECTION.ANIMALS, - ); + expect(result).toEqual({}); + }); - expect(catCallback).toHaveBeenNthCalledWith( - 1, - { age: 3, sound: "meow" }, - cat, - ); + it('should reject collection items with invalid keys', async () => { + let result: OnyxCollection; + const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; + const invalidRoute = 'invalid_route'; - expect(peopleCollectionCallback).toHaveBeenNthCalledWith( - 1, - { - [bob]: { age: 25, car: "sedan" }, - [lisa]: { age: 21, car: "SUV" }, - }, - ONYX_KEYS.COLLECTION.PEOPLE, - ); + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ROUTES, + callback: (value) => (result = value), + }); - connections.map((id) => Onyx.disconnect(id)); - }); - }); + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + }); + + await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [invalidRoute]: {name: 'Invalid Route'}, + }); + + expect(result).toEqual({ + [routeA]: {name: 'Route A'}, + }); + }); + + it('should only trigger collection callback once when setCollection is called with null values', async () => { + const mockCallback = jest.fn(); + const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; + const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; + const routeC = `${ONYX_KEYS.COLLECTION.ROUTES}C`; + + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.ROUTES, + callback: mockCallback, + }); + + await waitForPromisesToResolve(); + expect(mockCallback).toHaveBeenCalledTimes(1); + mockCallback.mockClear(); + + // Call setCollection with mixed null and data values + await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { + [routeA]: null, + [routeB]: {name: 'Route B'}, + [routeC]: null, + }); + + // Should only be called once + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Should receive filtered collection (only non-null values) + const receivedData = mockCallback.mock.calls.at(0)[0]; + expect(receivedData).toEqual({ + [routeB]: {name: 'Route B'}, + }); + }); + + it('should not save a RAM-only collection to storage', async () => { + const key1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const key2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; + + await Onyx.setCollection(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, { + [key1]: 'test1', + [key2]: 'test2', + }); - it("should apply updates in the correct order with Onyx.update", () => { - let testKeyValue: unknown; + expect(cache.get(key1)).toEqual('test1'); + expect(cache.get(key2)).toEqual('test2'); + expect(await StorageMock.getItem(key1)).toBeNull(); + expect(await StorageMock.getItem(key2)).toBeNull(); + }); + }); - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); + describe('skippable collection member ids', () => { + it('should skip the collection member id value when using Onyx.set()', async () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, { + id: 'entry1_id', + name: 'entry2_name', + }); + await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, { + id: 'skippable-id_id', + name: 'skippable-id_name', + }); + + expect(testKeyValue).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry2_name', + }, + }); + }); - return Onyx.set(ONYX_KEYS.TEST_KEY, {}) - .then(() => { - expect(testKeyValue).toEqual({}); - Onyx.update([ - { - onyxMethod: "merge", - key: ONYX_KEYS.TEST_KEY, - value: { test1: "test1" }, - }, - { - onyxMethod: "set", - key: ONYX_KEYS.TEST_KEY, - value: null, - }, - ]); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(testKeyValue).toBeUndefined(); - }); - }); - - it("should replace the old value after a null merge in the top-level object when batching updates", async () => { - let result: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_UPDATE, - callback: (value) => { - result = value; - }, - }); - - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { - id: "entry1", - someKey: "someValue", - }, - }); - - const queuedUpdates: Array> = [ - { - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - // Removing the entire object in this update. - // Any subsequent changes to this key should completely replace the old value. - value: null, - }, - { - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - // This change should completely replace `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1` old value. - value: { - someKey: "someValueChanged", - }, - }, - ]; - - await Onyx.update(queuedUpdates); - - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { - someKey: "someValueChanged", - }, - }); - expect( - await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`), - ).toEqual({ someKey: "someValueChanged" }); - }); - - describe("should replace the old value after a null merge in a nested property when batching updates", () => { - let result: unknown; - - beforeEach(() => { - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_UPDATE, - callback: (value) => { - result = value; - }, - }); - }); - - it("replacing old object after null merge", async () => { - const entry1: GenericDeepRecord = { - sub_entry1: { - id: "sub_entry1", - someKey: "someValue", - }, - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, - }); - - const entry1ExpectedResult = lodashCloneDeep(entry1); - const queuedUpdates: Array> = []; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - // Removing the "sub_entry1" object in this update. - // Any subsequent changes to this object should completely replace the existing object in store. - sub_entry1: null, - }, - }); - delete entry1ExpectedResult.sub_entry1; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - // This change should completely replace "sub_entry1" existing object in store. - sub_entry1: { - newKey: "newValue", - }, - }, - }); - entry1ExpectedResult.sub_entry1 = { newKey: "newValue" }; - - await Onyx.update(queuedUpdates); - - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, - }); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - ), - ).toEqual(entry1ExpectedResult); - }); - - it("setting new object after null merge", async () => { - const entry1: GenericDeepRecord = { - sub_entry1: { - id: "sub_entry1", - someKey: "someValue", - someNestedObject: { - someNestedKey: "someNestedValue", - anotherNestedObject: { - anotherNestedKey: "anotherNestedValue", - }, - }, - }, - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + it('should skip the collection member id value when using Onyx.merge()', async () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); + + await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, { + id: 'entry1_id', + name: 'entry2_name', + }); + await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, { + id: 'skippable-id_id', + name: 'skippable-id_name', + }); + + expect(testKeyValue).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry2_name', + }, + }); }); - const entry1ExpectedResult = lodashCloneDeep(entry1); - const queuedUpdates: Array> = []; + it('should skip the collection member id value when using Onyx.mergeCollection()', async () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - sub_entry1: { - someNestedObject: { - // Introducing a new "anotherNestedObject2" object in this update. - anotherNestedObject2: { - anotherNestedKey2: "anotherNestedValue2", + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', }, - }, - }, - }, - }); - entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = - { anotherNestedKey2: "anotherNestedValue2" }; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - sub_entry1: { - someNestedObject: { - // Removing the "anotherNestedObject2" object in this update. - // This property was only introduced in a previous update, so we don't need to care - // about an old existing value because there isn't one. - anotherNestedObject2: null, - }, - }, - }, - }); - delete entry1ExpectedResult.sub_entry1.someNestedObject - .anotherNestedObject2; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - sub_entry1: { - someNestedObject: { - // Introducing the "anotherNestedObject2" object again with this update. - anotherNestedObject2: { - newNestedKey2: "newNestedValue2", - }, - }, - }, - }, - }); - entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = - { newNestedKey2: "newNestedValue2" }; - - await Onyx.update(queuedUpdates); - - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, - }); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - ), - ).toEqual(entry1ExpectedResult); - }); - - it("setting new object after null merge of a primitive property", async () => { - const entry1: GenericDeepRecord = { - sub_entry1: { - id: "sub_entry1", - someKey: "someValue", - someNestedObject: { - someNestedKey: "someNestedValue", - anotherNestedObject: { - anotherNestedKey: "anotherNestedValue", - }, - }, - }, - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, - }); - - const entry1ExpectedResult = lodashCloneDeep(entry1); - const queuedUpdates: Array> = []; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - sub_entry1: { - someNestedObject: { - anotherNestedObject: { - // Removing the "anotherNestedKey" property in this update. - // This property's existing value in store is a primitive value, so we don't need to care - // about it when merging new values in any next updates. - anotherNestedKey: null, - }, - }, - }, - }, - }); - delete entry1ExpectedResult.sub_entry1.someNestedObject - .anotherNestedObject.anotherNestedKey; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - sub_entry1: { - someNestedObject: { - anotherNestedObject: { - // Setting a new object to the "anotherNestedKey" property. - anotherNestedKey: { - newNestedKey: "newNestedValue", - }, - }, - }, - }, - }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name', + }, + }); + + expect(testKeyValue).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + }); }); - entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject.anotherNestedKey = - { newNestedKey: "newNestedValue" }; - await Onyx.update(queuedUpdates); + it('should skip the collection member id value when using Onyx.setCollection()', async () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, - }); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - ), - ).toEqual(entry1ExpectedResult); - }); + await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name', + }, + }); - it("replacing nested object during updates", async () => { - const entry1: GenericDeepRecord | undefined = { - id: "entry1", - someKey: "someValue", - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { - id: "entry1", - someKey: "someValue", - }, - }); - - let entry1ExpectedResult = lodashCloneDeep(entry1) as - | GenericDeepRecord - | undefined; - const queuedUpdates: Array> = []; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - // Removing the entire object in this update. - // Any subsequent changes to this key should completely replace the old value. - value: null, - }); - entry1ExpectedResult = undefined; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - // This change should completely replace `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1` old value. - value: { - someKey: "someValueChanged", - someNestedObject: { - someNestedKey: "someNestedValue", - }, - }, + expect(testKeyValue).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + }); }); - entry1ExpectedResult = { - someKey: "someValueChanged", - someNestedObject: { someNestedKey: "someNestedValue" }, - }; - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - // Removing the "sub_entry1" object in this update. - // Any subsequent changes to this key should completely replace the old update's value. - someNestedObject: null, - }, - }); - delete entry1ExpectedResult.someNestedObject; - - queuedUpdates.push({ - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - // This change should completely replace `someNestedObject` old update's value. - value: { - someNestedObject: { - someNestedKeyChanged: "someNestedValueChange", - }, - }, - }); - entry1ExpectedResult.someNestedObject = { - someNestedKeyChanged: "someNestedValueChange", - }; + it('should skip the collection member id value when using Onyx.multiSet()', async () => { + let testKeyValue: unknown; + connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + callback: (value) => { + testKeyValue = value; + }, + }); - await Onyx.update(queuedUpdates); + await Onyx.multiSet({ + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { + id: 'skippable-id_id', + name: 'skippable-id_name', + }, + }); - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, + expect(testKeyValue).toEqual({ + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { + id: 'entry1_id', + name: 'entry1_name', + }, + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { + id: 'entry2_id', + name: 'entry2_name', + }, + }); }); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - ), - ).toEqual(entry1ExpectedResult); - }); + it('should clear pending merge for a key during multiSet()', async () => { + const testKey = `${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`; - describe("mergeCollection", () => { - it("replacing old object after null merge", async () => { - const entry1: GenericDeepRecord = { - sub_entry1: { - id: "sub_entry1", - someKey: "someValue", - }, - }; + // Mock the merge queue with the correct type + const mockMergeQueue: Record = { + [testKey]: [{some: 'mergeData'}], + }; - const entry2: GenericDeepRecord = { - sub_entry2: { - id: "sub_entry2", - someKey: "someValue", - }, - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, - }); - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`]: entry2, - }); - - const entry1ExpectedResult = lodashCloneDeep(entry1); - const entry2ExpectedResult = lodashCloneDeep(entry2); - const queuedUpdates: Array> = []; - - queuedUpdates.push( - { - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - // Removing the "sub_entry1" object in this update. - // Any subsequent changes to this object should completely replace the existing object in store. - sub_entry1: null, - }, - }, - { - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`, - onyxMethod: "merge", - value: { - // Removing the "sub_entry2" object in this update. - // Any subsequent changes to this object should completely replace the existing object in store. - sub_entry2: null, - }, - }, - ); - delete entry1ExpectedResult.sub_entry1; - delete entry2ExpectedResult.sub_entry2; + // Mock the utility functions + jest.spyOn(OnyxUtils, 'hasPendingMergeForKey').mockImplementation((key) => key === testKey); + jest.spyOn(OnyxUtils, 'getMergeQueue').mockImplementation(() => mockMergeQueue); - queuedUpdates.push( - { - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - onyxMethod: "merge", - value: { - // This change should completely replace "sub_entry1" existing object in store. - sub_entry1: { - newKey: "newValue", - }, - }, - }, - { - key: `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`, - onyxMethod: "merge", - value: { - // This change should completely replace "sub_entry2" existing object in store. - sub_entry2: { - newKey: "newValue", - }, - }, - }, - ); - entry1ExpectedResult.sub_entry1 = { newKey: "newValue" }; - entry2ExpectedResult.sub_entry2 = { newKey: "newValue" }; - - await Onyx.update(queuedUpdates); - - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`]: entry2ExpectedResult, - }); - expect( - await StorageMock.multiGet([ - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`, - ]), - ).toEqual([ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, entry1ExpectedResult], - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry2`, entry2ExpectedResult], - ]); - }); - - it("should not save a RAM-only collection to storage", async () => { - const key1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - const key2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; - - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, { - [key1]: "value 1", - [key2]: "value 2", - }); - - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, { - [key1]: "updated value 1", - [key2]: "updated value 2", - }); - - expect(await cache.get(key1)).toEqual("updated value 1"); - expect(await cache.get(key2)).toEqual("updated value 2"); - expect(await StorageMock.getItem(key1)).toBeNull(); - expect(await StorageMock.getItem(key2)).toBeNull(); - }); - }); - }); - - it("should properly handle setCollection operations in update()", () => { - const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; - const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; - const routeC = `${ONYX_KEYS.COLLECTION.ROUTES}C`; - - let routesCollection: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ROUTES, - callback: (value) => { - routesCollection = value; - }, - }); - - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - [routeB]: { name: "Route B" }, - [routeC]: { name: "Route C" }, - }) - .then(() => - Onyx.update([ - { - onyxMethod: Onyx.METHOD.SET_COLLECTION, - key: ONYX_KEYS.COLLECTION.ROUTES, - value: { - [routeA]: { name: "New Route A" }, - [routeB]: { name: "New Route B" }, - }, - }, - ]), - ) - .then(() => { - expect(routesCollection).toEqual({ - [routeA]: { name: "New Route A" }, - [routeB]: { name: "New Route B" }, - }); - }); - }); - - it("should handle mixed operations with setCollection in update()", () => { - const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; - const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; - const testKey = ONYX_KEYS.TEST_KEY; - let routesCollection: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ROUTES, - callback: (value) => { - routesCollection = value; - }, - }); - - let testKeyValue: unknown; - Onyx.connect({ - key: testKey, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - [routeB]: { name: "Route B" }, - }) - .then(() => - Onyx.update([ - { - onyxMethod: Onyx.METHOD.SET, - key: testKey, - value: "test value", - }, - { - onyxMethod: Onyx.METHOD.SET_COLLECTION, - key: ONYX_KEYS.COLLECTION.ROUTES, - value: { - [routeA]: { name: "Final Route A" }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: testKey, - value: "merged value", - }, - ]), - ) - .then(() => { - expect(routesCollection).toEqual({ - [routeA]: { name: "Final Route A" }, - }); - - expect(testKeyValue).toBe("merged value"); - }); - }); - - it("should trigger individual callbacks for each key when update is called with mergeCollection", async () => { - const collectionCallback = jest.fn(); - const individualCallback1 = jest.fn(); - const individualCallback2 = jest.fn(); - const key1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; - const key2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: collectionCallback, - }); - - const connection1 = Onyx.connect({ - key: key1, - callback: individualCallback1, - }); - - const connection2 = Onyx.connect({ - key: key2, - callback: individualCallback2, - }); - - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - individualCallback1.mockClear(); - individualCallback2.mockClear(); - - // Perform update with mergeCollection - await Onyx.update([ - { - onyxMethod: "mergecollection", - key: ONYX_KEYS.COLLECTION.TEST_KEY, - value: { - [key1]: { id: "1", name: "Updated Item 1" }, - [key2]: { id: "2", name: "Updated Item 2" }, - }, - }, - ]); - - // Collection callback should be called - expect(collectionCallback).toHaveBeenCalled(); - - // Individual callbacks should still work - expect(individualCallback1).toHaveBeenCalledWith( - { id: "1", name: "Updated Item 1" }, - key1, - ); - expect(individualCallback2).toHaveBeenCalledWith( - { id: "2", name: "Updated Item 2" }, - key2, - ); - - Onyx.disconnect(connection1); - Onyx.disconnect(connection2); - }); - - it("should not save a RAM-only collection to storage", async () => { - const queuedUpdates: Array> = []; - - queuedUpdates.push( - { - key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, - onyxMethod: "merge", - value: null, - }, - { - key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`, - onyxMethod: "merge", - value: null, - }, - ); - - queuedUpdates.push( - { - key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, - onyxMethod: "merge", - value: "updated test 1", - }, - { - key: `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`, - onyxMethod: "merge", - value: "updated test 2", - }, - ); - - await Onyx.update(queuedUpdates); - - expect(cache.get(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toEqual( - "updated test 1", - ); - expect(cache.get(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`)).toEqual( - "updated test 2", - ); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, - ), - ).toBeNull(); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`, - ), - ).toBeNull(); - }); - - describe("should log and skip invalid operations", () => { - it("invalid method", async () => { - await act(async () => - Onyx.update([ - { onyxMethod: "set", key: ONYX_KEYS.TEST_KEY, value: "test1" }, - // @ts-expect-error invalid method - { - onyxMethod: "invalidMethod", - key: ONYX_KEYS.OTHER_TEST, - value: "test2", - }, - ]), - ); + await Onyx.multiSet({ + [testKey]: {id: 'entry1_id', name: 'entry1_name'}, + }); - expect(logInfoFn).toHaveBeenNthCalledWith( - 1, - "Invalid onyxMethod invalidMethod in Onyx update. Skipping this operation.", - ); - }); - - it("non-object value passed to multiSet", async () => { - await act(async () => - Onyx.update([ - // @ts-expect-error non-object value - { onyxMethod: "multiset", key: ONYX_KEYS.TEST_KEY, value: [] }, - ]), - ); + expect(mockMergeQueue[testKey]).toBeUndefined(); - expect(logInfoFn).toHaveBeenNthCalledWith( - 1, - "Invalid value provided in Onyx multiSet. Value must be of type object. Skipping this operation.", - ); - }); - - it("non-string value passed to key", async () => { - await act(async () => - Onyx.update([ - // @ts-expect-error invalid key - { onyxMethod: "set", key: 1000, value: "test" }, - ]), - ); + jest.restoreAllMocks(); + }); + }); - expect(logInfoFn).toHaveBeenNthCalledWith( - 1, - "Invalid number key provided in Onyx update. Key must be of type string. Skipping this operation.", - ); - }); + describe('clear', () => { + it('should handle RAM-only keys with defaults correctly during clear', async () => { + // Set a value for RAM-only key + await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'some value'); + await Onyx.set(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, 'some other value'); - it("invalid or empty value passed to mergeCollection", async () => { - await act(async () => - Onyx.update([ - // @ts-expect-error invalid value - { - onyxMethod: "mergecollection", - key: ONYX_KEYS.COLLECTION.TEST_KEY, - value: "test1", - }, - ]), - ); + await Onyx.clear(); - expect(logInfoFn).toHaveBeenNthCalledWith( - 1, - "Invalid or empty value provided in Onyx mergeCollection. Skipping this operation.", - ); - }); - }); - }); - - describe("merge", () => { - it("should replace the old value after a null merge in the top-level object when batching merges", async () => { - let result: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_UPDATE, - callback: (value) => { - result = value; - }, - }); - - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { - id: "entry1", - someKey: "someValue", - }, - }); - - // Removing the entire object in this merge. - // Any subsequent changes to this key should completely replace the old value. - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, null); - - // This change should completely replace `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1` old value. - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - someKey: "someValueChanged", - }); - - await waitForPromisesToResolve(); - - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: { - someKey: "someValueChanged", - }, - }); - expect( - await StorageMock.getItem(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`), - ).toEqual({ someKey: "someValueChanged" }); - }); - - describe("should replace the old value after a null merge in a nested property when batching merges", () => { - let result: unknown; - - beforeEach(() => { - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_UPDATE, - callback: (value) => { - result = value; - }, - }); - }); - - it("replacing old object after null merge", async () => { - const entry1: GenericDeepRecord = { - sub_entry1: { - id: "sub_entry1", - someKey: "someValue", - }, - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + // Verify it's not in storage + expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); + expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toBeNull(); + // Verify cache state based on whether there's a default + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); + expect(cache.get(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual('default'); }); + }); +}); - const entry1ExpectedResult = lodashCloneDeep(entry1); +// Separate describe block for Onyx.init to control initialization during each test. +describe('Onyx.init', () => { + let cache: typeof OnyxCache; - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - // Removing the "sub_entry1" object in this merge. - // Any subsequent changes to this object should completely replace the existing object in store. - sub_entry1: null, - }); - delete entry1ExpectedResult.sub_entry1; + beforeEach(() => { + // Resets the deferred init task before each test. + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + cache = require('../../lib/OnyxCache').default; + }); - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - // This change should completely replace "sub_entry1" existing object in store. - sub_entry1: { - newKey: "newValue", - }, - }); - entry1ExpectedResult.sub_entry1 = { newKey: "newValue" }; + afterEach(() => { + jest.restoreAllMocks(); + return Onyx.clear(); + }); - await waitForPromisesToResolve(); + describe('should only execute Onyx methods after initialization', () => { + it('set', async () => { + Onyx.set(ONYX_KEYS.TEST_KEY, 'test'); + await act(async () => waitForPromisesToResolve()); - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, - }); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - ), - ).toEqual(entry1ExpectedResult); - }); - - it("setting new object after null merge", async () => { - const entry1: GenericDeepRecord = { - sub_entry1: { - id: "sub_entry1", - someKey: "someValue", - someNestedObject: { - someNestedKey: "someNestedValue", - anotherNestedObject: { - anotherNestedKey: "anotherNestedValue", - }, - }, - }, - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual('test'); }); - const entry1ExpectedResult = lodashCloneDeep(entry1); + it('multiSet', async () => { + Onyx.multiSet({[`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1'}); + await act(async () => waitForPromisesToResolve()); - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - sub_entry1: { - someNestedObject: { - // Introducing a new "anotherNestedObject2" object in this merge. - anotherNestedObject2: { - anotherNestedKey2: "anotherNestedValue2", - }, - }, - }, - }); - entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = - { anotherNestedKey2: "anotherNestedValue2" }; - - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - sub_entry1: { - someNestedObject: { - // Removing the "anotherNestedObject2" object in this merge. - // This property was only introduced in a previous merge, so we don't need to care - // about an old existing value because there isn't one. - anotherNestedObject2: null, - }, - }, - }); - delete entry1ExpectedResult.sub_entry1.someNestedObject - .anotherNestedObject2; - - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - sub_entry1: { - someNestedObject: { - // Introducing the "anotherNestedObject2" object again with this update. - anotherNestedObject2: { - newNestedKey2: "newNestedValue2", - }, - }, - }, + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual('test_1'); }); - entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject2 = - { newNestedKey2: "newNestedValue2" }; - await waitForPromisesToResolve(); + it('merge', async () => { + Onyx.merge(ONYX_KEYS.TEST_KEY, 'test'); + await act(async () => waitForPromisesToResolve()); - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, - }); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - ), - ).toEqual(entry1ExpectedResult); - }); - - it("setting new object after null merge of a primitive property", async () => { - const entry1: GenericDeepRecord = { - sub_entry1: { - id: "sub_entry1", - someKey: "someValue", - someNestedObject: { - someNestedKey: "someNestedValue", - anotherNestedObject: { - anotherNestedKey: "anotherNestedValue", - }, - }, - }, - }; - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1, + expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual('test'); }); - const entry1ExpectedResult = lodashCloneDeep(entry1); + it('mergeCollection', async () => { + Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1', + }); + await act(async () => waitForPromisesToResolve()); - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - sub_entry1: { - someNestedObject: { - anotherNestedObject: { - // Removing the "anotherNestedKey" property in this merge. - // This property's existing value in store is a primitive value, so we don't need to care - // about it when merging new values in any next merges. - anotherNestedKey: null, - }, - }, - }, + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual('test_1'); }); - delete entry1ExpectedResult.sub_entry1.someNestedObject - .anotherNestedObject.anotherNestedKey; - Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, { - sub_entry1: { - someNestedObject: { - anotherNestedObject: { - // Setting a new object to the "anotherNestedKey" property. - anotherNestedKey: { - newNestedKey: "newNestedValue", - }, - }, - }, - }, + it('clear', async () => { + // Spies on a function that is exclusively called during Onyx.clear(). + const spyClearNullishStorageKeys = jest.spyOn(cache, 'clearNullishStorageKeys'); + + Onyx.clear(); + await act(async () => waitForPromisesToResolve()); + + expect(spyClearNullishStorageKeys).not.toHaveBeenCalled(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(spyClearNullishStorageKeys).toHaveBeenCalled(); }); - entry1ExpectedResult.sub_entry1.someNestedObject.anotherNestedObject.anotherNestedKey = - { newNestedKey: "newNestedValue" }; - await waitForPromisesToResolve(); + it('update', async () => { + Onyx.update([{onyxMethod: 'set', key: ONYX_KEYS.TEST_KEY, value: 'test'}]); + await act(async () => waitForPromisesToResolve()); - expect(result).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`]: entry1ExpectedResult, - }); - expect( - await StorageMock.getItem( - `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1`, - ), - ).toEqual(entry1ExpectedResult); - }); - }); - - it("should remove a deeply nested null when merging an existing key", () => { - let result: unknown; - - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => (result = value), - }); - - const initialValue = { - waypoints: { - 1: "Home", - 2: "Work", - 3: "Gym", - }, - }; - - return Onyx.set(ONYX_KEYS.TEST_KEY, initialValue) - .then(() => { - expect(result).toEqual(initialValue); - Onyx.merge(ONYX_KEYS.TEST_KEY, { - waypoints: { - 1: "Home", - 2: "Work", - 3: null, - }, - }); - return waitForPromisesToResolve(); - }) - .then(() => { - expect(result).toEqual({ - waypoints: { - 1: "Home", - 2: "Work", - }, - }); + expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual('test'); }); - }); - it("should not save a RAM-only key to storage when using merge", async () => { - await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, { someProperty: "value" }); + it('setCollection', async () => { + Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: 'test_1', + }); + await act(async () => waitForPromisesToResolve()); - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({ - someProperty: "value", - }); - expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); - }); + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toBeUndefined(); - it("should not save a RAM-only collection member to storage when using merge", async () => { - const collectionMemberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - await Onyx.merge(collectionMemberKey, { data: "test" }); + Onyx.init({keys: ONYX_KEYS}); + await act(async () => waitForPromisesToResolve()); - expect(cache.get(collectionMemberKey)).toEqual({ data: "test" }); - expect(await StorageMock.getItem(collectionMemberKey)).toBeNull(); + expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual('test_1'); + }); }); - }); +}); - describe("set", () => { - it("should work with skipCacheCheck option", () => { - let testKeyValue: unknown; +// Separate describe block to control Onyx.init() per-test so we can pre-seed storage before init. +describe('RAM-only keys should not read from storage', () => { + let cache: typeof OnyxCache; - connection = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); + beforeEach(() => { + // Resets the deferred init task before each test. + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + cache = require('../../lib/OnyxCache').default; + }); - const testData = { id: 1, name: "test" }; + afterEach(() => { + jest.restoreAllMocks(); + return Onyx.clear(); + }); - return Onyx.set(ONYX_KEYS.TEST_KEY, testData) - .then(() => { - expect(testKeyValue).toEqual(testData); + it('should not return stale storage data for a RAM-only key via get', async () => { + // Simulate stale data left in storage from before the key was RAM-only + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value'); - return Onyx.set(ONYX_KEYS.TEST_KEY, testData, { - skipCacheCheck: true, - }); - }) - .then(() => { - expect(testKeyValue).toEqual(testData); + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], }); - }); + await act(async () => waitForPromisesToResolve()); + + let receivedValue: unknown; + const connection = Onyx.connect({ + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, + callback: (value) => { + receivedValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); - it("should not save a RAM-only key to storage", async () => { - await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, "test"); + expect(receivedValue).toBeUndefined(); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual("test"); - expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); + Onyx.disconnect(connection); }); - it("should not save a member of a RAM-only collection to storage", async () => { - const collectionMemberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - await Onyx.set(collectionMemberKey, "test"); + it('should not return stale storage data for RAM-only collection members via multiGet', async () => { + const collectionMember1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const collectionMember2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; - expect(cache.get(collectionMemberKey)).toEqual("test"); - expect(await StorageMock.getItem(collectionMemberKey)).toBeNull(); - }); - }); + // Simulate stale collection members in storage + await StorageMock.setItem(collectionMember1, {name: 'stale_1'}); + await StorageMock.setItem(collectionMember2, {name: 'stale_2'}); + + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); - describe("multiSet", () => { - it("should only save non RAM-only keys to storage", async () => { - const otherTestValue = "non ram only value"; - const collectionMemberKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; + let receivedCollection: OnyxCollection; + const connection = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, + callback: (value) => { + receivedCollection = value; + }, + }); + await act(async () => waitForPromisesToResolve()); - await Onyx.multiSet({ - [ONYX_KEYS.OTHER_TEST]: otherTestValue, - [ONYX_KEYS.RAM_ONLY_TEST_KEY]: "test value 1", - [collectionMemberKey]: "test value 2", - }); + expect(receivedCollection).toBeUndefined(); + expect(cache.get(collectionMember1)).toBeUndefined(); + expect(cache.get(collectionMember2)).toBeUndefined(); - expect(await StorageMock.getItem(ONYX_KEYS.OTHER_TEST)).toEqual( - otherTestValue, - ); - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual("test value 1"); - expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); - expect(cache.get(collectionMemberKey)).toEqual("test value 2"); - expect(await StorageMock.getItem(collectionMemberKey)).toBeNull(); + Onyx.disconnect(connection); }); - }); - describe("setCollection", () => { - it("should replace all existing collection members with new values and remove old ones", async () => { - let result: OnyxCollection; - const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; - const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; - const routeB1 = `${ONYX_KEYS.COLLECTION.ROUTES}B1`; - const routeC = `${ONYX_KEYS.COLLECTION.ROUTES}C`; + it('should not include stale RAM-only keys in getAllKeys results', async () => { + // Simulate stale data in storage + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value'); + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, { + stale: 'member', + }); + await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, 'normal_value'); - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ROUTES, - callback: (value) => (result = value), - }); + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); - // Set initial collection state - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - [routeB1]: { name: "Route B1" }, - [routeC]: { name: "Route C" }, - }); + const keys = await OnyxUtils.getAllKeys(); - // Replace with new collection data - await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [routeA]: { name: "New Route A" }, - [routeB]: { name: "New Route B" }, - [routeC]: { name: "New Route C" }, - }); - - expect(result).toEqual({ - [routeA]: { name: "New Route A" }, - [routeB]: { name: "New Route B" }, - [routeC]: { name: "New Route C" }, - }); + expect(keys.has(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBe(false); + expect(keys.has(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBe(false); + // Normal keys should still be present + expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(true); }); - it("should replace the collection with empty values", async () => { - let result: OnyxCollection; - const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; - const callback = jest.fn(); - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ROUTES, - callback, - }); - await waitForPromisesToResolve(); - - callback.mockReset(); - callback.mockImplementation((value) => { - result = value; - }); - - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - }); - - await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, {}); - - expect(result).toEqual({}); - }); - - it("should reject collection items with invalid keys", async () => { - let result: OnyxCollection; - const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; - const invalidRoute = "invalid_route"; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ROUTES, - callback: (value) => (result = value), - }); - - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - }); - - await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [invalidRoute]: { name: "Invalid Route" }, - }); - - expect(result).toEqual({ - [routeA]: { name: "Route A" }, - }); - }); - - it("should only trigger collection callback once when setCollection is called with null values", async () => { - const mockCallback = jest.fn(); - const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`; - const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`; - const routeC = `${ONYX_KEYS.COLLECTION.ROUTES}C`; - - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.ROUTES, - callback: mockCallback, - }); - - await waitForPromisesToResolve(); - expect(mockCallback).toHaveBeenCalledTimes(1); - mockCallback.mockClear(); - - // Call setCollection with mixed null and data values - await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, { - [routeA]: null, - [routeB]: { name: "Route B" }, - [routeC]: null, - }); - - // Should only be called once - expect(mockCallback).toHaveBeenCalledTimes(1); - - // Should receive filtered collection (only non-null values) - const receivedData = mockCallback.mock.calls.at(0)[0]; - expect(receivedData).toEqual({ - [routeB]: { name: "Route B" }, - }); - }); - - it("should not save a RAM-only collection to storage", async () => { - const key1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - const key2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; - - await Onyx.setCollection(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, { - [key1]: "test1", - [key2]: "test2", - }); - - expect(cache.get(key1)).toEqual("test1"); - expect(cache.get(key2)).toEqual("test2"); - expect(await StorageMock.getItem(key1)).toBeNull(); - expect(await StorageMock.getItem(key2)).toBeNull(); - }); - }); - - describe("skippable collection member ids", () => { - it("should skip the collection member id value when using Onyx.set()", async () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, { - id: "entry1_id", - name: "entry2_name", - }); - await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, { - id: "skippable-id_id", - name: "skippable-id_name", - }); - - expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry2_name", - }, - }); - }); - - it("should skip the collection member id value when using Onyx.merge()", async () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`, { - id: "entry1_id", - name: "entry2_name", - }); - await Onyx.merge(`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`, { - id: "skippable-id_id", - name: "skippable-id_name", - }); - - expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry2_name", - }, - }); - }); - - it("should skip the collection member id value when using Onyx.mergeCollection()", async () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry1_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { - id: "entry2_id", - name: "entry2_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { - id: "skippable-id_id", - name: "skippable-id_name", - }, - }); - - expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry1_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { - id: "entry2_id", - name: "entry2_name", - }, - }); - }); - - it("should skip the collection member id value when using Onyx.setCollection()", async () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry1_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { - id: "entry2_id", - name: "entry2_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { - id: "skippable-id_id", - name: "skippable-id_name", - }, - }); - - expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry1_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { - id: "entry2_id", - name: "entry2_name", - }, - }); - }); - - it("should skip the collection member id value when using Onyx.multiSet()", async () => { - let testKeyValue: unknown; - connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.TEST_KEY, - callback: (value) => { - testKeyValue = value; - }, - }); - - await Onyx.multiSet({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry1_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { - id: "entry2_id", - name: "entry2_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}skippable-id`]: { - id: "skippable-id_id", - name: "skippable-id_name", - }, - }); - - expect(testKeyValue).toEqual({ - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: { - id: "entry1_id", - name: "entry1_name", - }, - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry2`]: { - id: "entry2_id", - name: "entry2_name", - }, - }); - }); - it("should clear pending merge for a key during multiSet()", async () => { - const testKey = `${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`; - - // Mock the merge queue with the correct type - const mockMergeQueue: Record = { - [testKey]: [{ some: "mergeData" }], - }; - - // Mock the utility functions - jest - .spyOn(OnyxUtils, "hasPendingMergeForKey") - .mockImplementation((key) => key === testKey); - jest - .spyOn(OnyxUtils, "getMergeQueue") - .mockImplementation(() => mockMergeQueue); - - await Onyx.multiSet({ - [testKey]: { id: "entry1_id", name: "entry1_name" }, - }); - - expect(mockMergeQueue[testKey]).toBeUndefined(); - - jest.restoreAllMocks(); - }); - }); - - describe("clear", () => { - it("should handle RAM-only keys with defaults correctly during clear", async () => { - // Set a value for RAM-only key - await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, "some value"); - await Onyx.set(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, "some other value"); - - await Onyx.clear(); - - // Verify it's not in storage - expect(await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeNull(); - expect( - await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE), - ).toBeNull(); - // Verify cache state based on whether there's a default - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); - expect(cache.get(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual( - "default", - ); - }); - }); -}); - -// Separate describe block for Onyx.init to control initialization during each test. -describe("Onyx.init", () => { - let cache: typeof OnyxCache; + it('should not read stale storage data for RAM-only keys during initializeWithDefaultKeyStates', async () => { + // Simulate stale data for a RAM-only key that also has a default key state + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, 'stale_value'); - beforeEach(() => { - // Resets the deferred init task before each test. - Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); - cache = require("../../lib/OnyxCache").default; - }); + Onyx.init({ + keys: ONYX_KEYS, + initialKeyStates: { + [ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: 'default_value', + }, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); - afterEach(() => { - jest.restoreAllMocks(); - return Onyx.clear(); - }); + // The cache should have the default value, not the stale storage value + expect(cache.get(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual('default_value'); + }); - describe("should only execute Onyx methods after initialization", () => { - it("set", async () => { - Onyx.set(ONYX_KEYS.TEST_KEY, "test"); - await act(async () => waitForPromisesToResolve()); + it('should not use stale storage data as merge base for RAM-only keys', async () => { + // Simulate stale data in storage + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, { + name: 'stale', + token: 'old_token', + }); - expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); - Onyx.init({ keys: ONYX_KEYS }); - await act(async () => waitForPromisesToResolve()); + // Merge new data — should NOT merge with stale storage value + await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, {name: 'new'}); - expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual("test"); + // The result should only contain the merged value, not the stale token + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({name: 'new'}); }); - it("multiSet", async () => { - Onyx.multiSet({ [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: "test_1" }); - await act(async () => waitForPromisesToResolve()); + it('should not read stale storage data when subscribing to individual RAM-only collection members', async () => { + const collectionMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - expect( - cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`), - ).toBeUndefined(); + // Simulate stale data in storage + await StorageMock.setItem(collectionMember, {data: 'stale'}); - Onyx.init({ keys: ONYX_KEYS }); - await act(async () => waitForPromisesToResolve()); + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + const receivedValues: unknown[] = []; + const connection = Onyx.connect({ + key: collectionMember, + callback: (value) => { + receivedValues.push(value); + }, + }); + await act(async () => waitForPromisesToResolve()); - expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual( - "test_1", - ); + // Should never receive the stale value + expect(receivedValues.every((v) => v === undefined || v === null)).toBe(true); + + Onyx.disconnect(connection); }); - it("merge", async () => { - Onyx.merge(ONYX_KEYS.TEST_KEY, "test"); - await act(async () => waitForPromisesToResolve()); + it('should still work correctly for normal keys when RAM-only keys have stale storage data', async () => { + // Simulate both normal and RAM-only stale data in storage + await StorageMock.setItem(ONYX_KEYS.TEST_KEY, 'normal_value'); + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_ram_value'); + + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + let normalValue: unknown; + let ramOnlyValue: unknown; - expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + const connection1 = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + callback: (value) => { + normalValue = value; + }, + }); + const connection2 = Onyx.connect({ + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, + callback: (value) => { + ramOnlyValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); - Onyx.init({ keys: ONYX_KEYS }); - await act(async () => waitForPromisesToResolve()); + // Normal key should read from storage as expected + expect(normalValue).toEqual('normal_value'); + // RAM-only key should NOT read stale value from storage + expect(ramOnlyValue).toBeUndefined(); - expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual("test"); + Onyx.disconnect(connection1); + Onyx.disconnect(connection2); }); - it("mergeCollection", async () => { - Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: "test_1", - }); - await act(async () => waitForPromisesToResolve()); + it('should not sync RAM-only keys from other instances via keepInstancesSync', async () => { + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + shouldSyncMultipleInstances: true, + }); + await act(async () => waitForPromisesToResolve()); - expect( - cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`), - ).toBeUndefined(); + // Get the callback that was passed to keepInstancesSync + const syncCallback = (StorageMock.keepInstancesSync as jest.Mock).mock.calls.at(0)?.[0]; + expect(syncCallback).toBeDefined(); - Onyx.init({ keys: ONYX_KEYS }); - await act(async () => waitForPromisesToResolve()); + let receivedValue: unknown; + const connection = Onyx.connect({ + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, + callback: (value) => { + receivedValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); - expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual( - "test_1", - ); - }); + // Simulate another tab syncing a stale RAM-only key value + syncCallback(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'synced_stale_value'); + await act(async () => waitForPromisesToResolve()); - it("clear", async () => { - // Spies on a function that is exclusively called during Onyx.clear(). - const spyClearNullishStorageKeys = jest.spyOn( - cache, - "clearNullishStorageKeys", - ); + // The RAM-only key should NOT have been updated from the sync + expect(receivedValue).toBeUndefined(); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); - Onyx.clear(); - await act(async () => waitForPromisesToResolve()); + // Verify that normal keys still sync correctly + let normalValue: unknown; + const connection2 = Onyx.connect({ + key: ONYX_KEYS.OTHER_TEST, + callback: (value) => { + normalValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); - expect(spyClearNullishStorageKeys).not.toHaveBeenCalled(); + syncCallback(ONYX_KEYS.OTHER_TEST, 'synced_normal_value'); + await act(async () => waitForPromisesToResolve()); - Onyx.init({ keys: ONYX_KEYS }); - await act(async () => waitForPromisesToResolve()); + expect(normalValue).toEqual('synced_normal_value'); - expect(spyClearNullishStorageKeys).toHaveBeenCalled(); + Onyx.disconnect(connection); + Onyx.disconnect(connection2); }); - it("update", async () => { - Onyx.update([ - { onyxMethod: "set", key: ONYX_KEYS.TEST_KEY, value: "test" }, - ]); - await act(async () => waitForPromisesToResolve()); + it('should serve RAM-only keys from cache and normal keys from storage in multiGet', async () => { + const ramOnlyMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; + const normalMember = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; + + // Pre-seed storage with stale data for both normal and RAM-only keys + await StorageMock.setItem(normalMember, 'normal_from_storage'); + await StorageMock.setItem(ramOnlyMember, { + data: 'stale_collection_member', + }); + + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); - expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + // Set a RAM-only collection member via Onyx (goes to cache only) + await Onyx.set(ramOnlyMember, {data: 'fresh_from_cache'}); - Onyx.init({ keys: ONYX_KEYS }); - await act(async () => waitForPromisesToResolve()); + // multiGet receives individual keys (e.g. collection members), not collection base keys + const result = await OnyxUtils.multiGet([normalMember, ramOnlyMember]); - expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual("test"); + // Normal key should come from storage + expect(result.get(normalMember)).toEqual('normal_from_storage'); + // RAM-only collection member should come from cache, not stale storage + expect(result.get(ramOnlyMember)).toEqual({data: 'fresh_from_cache'}); }); - it("setCollection", async () => { - Onyx.setCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { - [`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`]: "test_1", - }); - await act(async () => waitForPromisesToResolve()); + it('should return cached value for RAM-only key after set then connect', async () => { + await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'stale_value'); - expect( - cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`), - ).toBeUndefined(); + Onyx.init({ + keys: ONYX_KEYS, + ramOnlyKeys: [ONYX_KEYS.RAM_ONLY_TEST_KEY, ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE], + }); + await act(async () => waitForPromisesToResolve()); + + // Write a fresh value to the RAM-only key + await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, 'fresh_value'); + + let receivedValue: unknown; + const connection = Onyx.connect({ + key: ONYX_KEYS.RAM_ONLY_TEST_KEY, + callback: (value) => { + receivedValue = value; + }, + }); + await act(async () => waitForPromisesToResolve()); + + // Should get the fresh cached value, not the stale storage value + expect(receivedValue).toEqual('fresh_value'); + expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual('fresh_value'); - Onyx.init({ keys: ONYX_KEYS }); - await act(async () => waitForPromisesToResolve()); + // Verify storage was NOT written to + const storageValue = await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY); + expect(storageValue).toEqual('stale_value'); - expect(cache.get(`${ONYX_KEYS.COLLECTION.TEST_KEY}entry1`)).toEqual( - "test_1", - ); + Onyx.disconnect(connection); }); - }); }); -// Separate describe block to control Onyx.init() per-test so we can pre-seed storage before init. -describe("RAM-only keys should not read from storage", () => { - let cache: typeof OnyxCache; - - beforeEach(() => { - // Resets the deferred init task before each test. - Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); - cache = require("../../lib/OnyxCache").default; - }); - - afterEach(() => { - jest.restoreAllMocks(); - return Onyx.clear(); - }); - - it("should not return stale storage data for a RAM-only key via get", async () => { - // Simulate stale data left in storage from before the key was RAM-only - await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, "stale_value"); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - let receivedValue: unknown; - const connection = Onyx.connect({ - key: ONYX_KEYS.RAM_ONLY_TEST_KEY, - callback: (value) => { - receivedValue = value; - }, - }); - await act(async () => waitForPromisesToResolve()); - - expect(receivedValue).toBeUndefined(); - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); - - Onyx.disconnect(connection); - }); - - it("should not return stale storage data for RAM-only collection members via multiGet", async () => { - const collectionMember1 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - const collectionMember2 = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}2`; - - // Simulate stale collection members in storage - await StorageMock.setItem(collectionMember1, { name: "stale_1" }); - await StorageMock.setItem(collectionMember2, { name: "stale_2" }); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - let receivedCollection: OnyxCollection; - const connection = Onyx.connect({ - key: ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - callback: (value) => { - receivedCollection = value; - }, - }); - await act(async () => waitForPromisesToResolve()); - - expect(receivedCollection).toBeUndefined(); - expect(cache.get(collectionMember1)).toBeUndefined(); - expect(cache.get(collectionMember2)).toBeUndefined(); - - Onyx.disconnect(connection); - }); - - it("should not include stale RAM-only keys in getAllKeys results", async () => { - // Simulate stale data in storage - await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, "stale_value"); - await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`, { - stale: "member", - }); - await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, "normal_value"); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - const keys = await OnyxUtils.getAllKeys(); - - expect(keys.has(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBe(false); - expect(keys.has(`${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBe( - false, - ); - // Normal keys should still be present - expect(keys.has(ONYX_KEYS.OTHER_TEST)).toBe(true); - }); - - it("should not read stale storage data for RAM-only keys during initializeWithDefaultKeyStates", async () => { - // Simulate stale data for a RAM-only key that also has a default key state - await StorageMock.setItem( - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - "stale_value", - ); - - Onyx.init({ - keys: ONYX_KEYS, - initialKeyStates: { - [ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE]: "default_value", - }, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - // The cache should have the default value, not the stale storage value - expect(cache.get(ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE)).toEqual( - "default_value", - ); - }); - - it("should not use stale storage data as merge base for RAM-only keys", async () => { - // Simulate stale data in storage - await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, { - name: "stale", - token: "old_token", - }); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - // Merge new data — should NOT merge with stale storage value - await Onyx.merge(ONYX_KEYS.RAM_ONLY_TEST_KEY, { name: "new" }); - - // The result should only contain the merged value, not the stale token - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual({ name: "new" }); - }); - - it("should not read stale storage data when subscribing to individual RAM-only collection members", async () => { - const collectionMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - - // Simulate stale data in storage - await StorageMock.setItem(collectionMember, { data: "stale" }); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - const receivedValues: unknown[] = []; - const connection = Onyx.connect({ - key: collectionMember, - callback: (value) => { - receivedValues.push(value); - }, - }); - await act(async () => waitForPromisesToResolve()); - - // Should never receive the stale value - expect(receivedValues.every((v) => v === undefined || v === null)).toBe( - true, - ); - - Onyx.disconnect(connection); - }); - - it("should still work correctly for normal keys when RAM-only keys have stale storage data", async () => { - // Simulate both normal and RAM-only stale data in storage - await StorageMock.setItem(ONYX_KEYS.TEST_KEY, "normal_value"); - await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, "stale_ram_value"); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - let normalValue: unknown; - let ramOnlyValue: unknown; - - const connection1 = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - callback: (value) => { - normalValue = value; - }, - }); - const connection2 = Onyx.connect({ - key: ONYX_KEYS.RAM_ONLY_TEST_KEY, - callback: (value) => { - ramOnlyValue = value; - }, - }); - await act(async () => waitForPromisesToResolve()); - - // Normal key should read from storage as expected - expect(normalValue).toEqual("normal_value"); - // RAM-only key should NOT read stale value from storage - expect(ramOnlyValue).toBeUndefined(); - - Onyx.disconnect(connection1); - Onyx.disconnect(connection2); - }); - - it("should not sync RAM-only keys from other instances via keepInstancesSync", async () => { - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - shouldSyncMultipleInstances: true, - }); - await act(async () => waitForPromisesToResolve()); - - // Get the callback that was passed to keepInstancesSync - const syncCallback = ( - StorageMock.keepInstancesSync as jest.Mock - ).mock.calls.at(0)?.[0]; - expect(syncCallback).toBeDefined(); - - let receivedValue: unknown; - const connection = Onyx.connect({ - key: ONYX_KEYS.RAM_ONLY_TEST_KEY, - callback: (value) => { - receivedValue = value; - }, - }); - await act(async () => waitForPromisesToResolve()); - - // Simulate another tab syncing a stale RAM-only key value - syncCallback(ONYX_KEYS.RAM_ONLY_TEST_KEY, "synced_stale_value"); - await act(async () => waitForPromisesToResolve()); - - // The RAM-only key should NOT have been updated from the sync - expect(receivedValue).toBeUndefined(); - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toBeUndefined(); - - // Verify that normal keys still sync correctly - let normalValue: unknown; - const connection2 = Onyx.connect({ - key: ONYX_KEYS.OTHER_TEST, - callback: (value) => { - normalValue = value; - }, - }); - await act(async () => waitForPromisesToResolve()); - - syncCallback(ONYX_KEYS.OTHER_TEST, "synced_normal_value"); - await act(async () => waitForPromisesToResolve()); - - expect(normalValue).toEqual("synced_normal_value"); - - Onyx.disconnect(connection); - Onyx.disconnect(connection2); - }); - - it("should serve RAM-only keys from cache and normal keys from storage in multiGet", async () => { - const ramOnlyMember = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}1`; - const normalMember = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; - - // Pre-seed storage with stale data for both normal and RAM-only keys - await StorageMock.setItem(normalMember, "normal_from_storage"); - await StorageMock.setItem(ramOnlyMember, { - data: "stale_collection_member", - }); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - // Set a RAM-only collection member via Onyx (goes to cache only) - await Onyx.set(ramOnlyMember, { data: "fresh_from_cache" }); - - // multiGet receives individual keys (e.g. collection members), not collection base keys - const result = await OnyxUtils.multiGet([normalMember, ramOnlyMember]); - - // Normal key should come from storage - expect(result.get(normalMember)).toEqual("normal_from_storage"); - // RAM-only collection member should come from cache, not stale storage - expect(result.get(ramOnlyMember)).toEqual({ data: "fresh_from_cache" }); - }); - - it("should return cached value for RAM-only key after set then connect", async () => { - await StorageMock.setItem(ONYX_KEYS.RAM_ONLY_TEST_KEY, "stale_value"); - - Onyx.init({ - keys: ONYX_KEYS, - ramOnlyKeys: [ - ONYX_KEYS.RAM_ONLY_TEST_KEY, - ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, - ONYX_KEYS.RAM_ONLY_WITH_INITIAL_VALUE, - ], - }); - await act(async () => waitForPromisesToResolve()); - - // Write a fresh value to the RAM-only key - await Onyx.set(ONYX_KEYS.RAM_ONLY_TEST_KEY, "fresh_value"); - - let receivedValue: unknown; - const connection = Onyx.connect({ - key: ONYX_KEYS.RAM_ONLY_TEST_KEY, - callback: (value) => { - receivedValue = value; - }, +describe('get() should prefer cache over stale storage', () => { + let cache: typeof OnyxCache; + + beforeEach(() => { + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + cache = require('../../lib/OnyxCache').default; + Onyx.init({keys: ONYX_KEYS}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + return Onyx.clear(); }); - await act(async () => waitForPromisesToResolve()); - // Should get the fresh cached value, not the stale storage value - expect(receivedValue).toEqual("fresh_value"); - expect(cache.get(ONYX_KEYS.RAM_ONLY_TEST_KEY)).toEqual("fresh_value"); + it('should preserve data from Onyx.update when a concurrent Onyx.merge fires before cache is written', async () => { + const member1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; + const member2 = `${ONYX_KEYS.COLLECTION.TEST_KEY}2`; - // Verify storage was NOT written to - const storageValue = await StorageMock.getItem(ONYX_KEYS.RAM_ONLY_TEST_KEY); - expect(storageValue).toEqual("stale_value"); + // 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((resolve) => { + setTimeout(() => resolve(undefined), 50); + }); + } + return originalGetItem(key); + }); - Onyx.disconnect(connection); - }); -}); + // 2+ collection keys get batched into mergeCollectionWithPatches (deferred cache write) + const updatePromise = Onyx.update([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: member1, + value: {isOptimistic: true, name: 'first'}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: member2, + value: {isOptimistic: true, name: 'second'}, + }, + ]); -describe("get() should prefer cache over stale storage", () => { - let cache: typeof OnyxCache; - - beforeEach(() => { - Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); - cache = require("../../lib/OnyxCache").default; - Onyx.init({ keys: ONYX_KEYS }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - return Onyx.clear(); - }); - - it("should preserve data from Onyx.update when a concurrent Onyx.merge fires before cache is written", async () => { - const member1 = `${ONYX_KEYS.COLLECTION.TEST_KEY}1`; - 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((resolve) => { - setTimeout(() => resolve(undefined), 50); - }); - } - return originalGetItem(key); - }); - - // 2+ collection keys get batched into mergeCollectionWithPatches (deferred cache write) - const updatePromise = Onyx.update([ - { - onyxMethod: Onyx.METHOD.MERGE, - key: member1, - value: { isOptimistic: true, name: "first" }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: member2, - value: { isOptimistic: true, name: "second" }, - }, - ]); - - // Concurrent merge fires before cache write — its get() hits the delayed storage mock - const mergePromise = Onyx.merge(member1, { lastVisitTime: "2025-01-01" }); - - await act(async () => { - await updatePromise; - await mergePromise; - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - }); - - const value = cache.get(member1); - expect(value).toHaveProperty("isOptimistic", true); - expect(value).toHaveProperty("lastVisitTime", "2025-01-01"); - }); + // Concurrent merge fires before cache write — its get() hits the delayed storage mock + const mergePromise = Onyx.merge(member1, {lastVisitTime: '2025-01-01'}); + + await act(async () => { + await updatePromise; + await mergePromise; + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + }); + + const value = cache.get(member1); + expect(value).toHaveProperty('isOptimistic', true); + expect(value).toHaveProperty('lastVisitTime', '2025-01-01'); + }); }); diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 20931e045..046ab72d2 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -1,1890 +1,1769 @@ -import { act } from "@testing-library/react-native"; -import Onyx from "../../lib"; -import OnyxUtils from "../../lib/OnyxUtils"; -import type { GenericDeepRecord } from "../types"; -import utils from "../../lib/utils"; -import type { Collection, OnyxCollection } from "../../lib/types"; -import OnyxCache from "../../lib/OnyxCache"; -import * as Logger from "../../lib/Logger"; -import StorageMock from "../../lib/storage"; -import StorageCircuitBreaker from "../../lib/StorageCircuitBreaker"; -import createDeferredTask from "../../lib/createDeferredTask"; -import waitForPromisesToResolve from "../utils/waitForPromisesToResolve"; +import {act} from '@testing-library/react-native'; +import Onyx from '../../lib'; +import OnyxUtils from '../../lib/OnyxUtils'; +import type {GenericDeepRecord} from '../types'; +import utils from '../../lib/utils'; +import type {Collection, OnyxCollection} from '../../lib/types'; +import OnyxCache from '../../lib/OnyxCache'; +import * as Logger from '../../lib/Logger'; +import StorageMock from '../../lib/storage'; +import StorageCircuitBreaker from '../../lib/StorageCircuitBreaker'; +import createDeferredTask from '../../lib/createDeferredTask'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; const testObject: GenericDeepRecord = { - a: "a", - b: { - c: "c", - d: { - e: "e", - f: "f", + a: 'a', + b: { + c: 'c', + d: { + e: 'e', + f: 'f', + }, + g: 'g', }, - g: "g", - }, }; const testMergeChanges: GenericDeepRecord[] = [ - { - b: { - d: { - h: "h", - }, + { + b: { + d: { + h: 'h', + }, + }, }, - }, - { - b: { - // Removing "d" object. - d: null, - h: "h", + { + b: { + // Removing "d" object. + d: null, + h: 'h', + }, }, - }, - { - b: { - // Adding back "d" property with a object. - // The "ONYX_INTERNALS__REPLACE_OBJECT_MARK" marker property should be added here when batching merge changes. - d: { - i: "i", - }, + { + b: { + // Adding back "d" property with a object. + // The "ONYX_INTERNALS__REPLACE_OBJECT_MARK" marker property should be added here when batching merge changes. + d: { + i: 'i', + }, + }, }, - }, - { - b: { - // Removing "d" object again. - d: null, - // Removing "g" object. - g: null, + { + b: { + // Removing "d" object again. + d: null, + // Removing "g" object. + g: null, + }, }, - }, - { - b: { - // Adding back "d" property with a object. - // The "ONYX_INTERNALS__REPLACE_OBJECT_MARK" marker property should be added here when batching merge changes. - d: { - i: "i", - j: "j", - }, - // Adding back "g" property with a object. - // The "ONYX_INTERNALS__REPLACE_OBJECT_MARK" marker property should be added here when batching merge changes. - g: { - k: "k", - }, + { + b: { + // Adding back "d" property with a object. + // The "ONYX_INTERNALS__REPLACE_OBJECT_MARK" marker property should be added here when batching merge changes. + d: { + i: 'i', + j: 'j', + }, + // Adding back "g" property with a object. + // The "ONYX_INTERNALS__REPLACE_OBJECT_MARK" marker property should be added here when batching merge changes. + g: { + k: 'k', + }, + }, }, - }, ]; const ONYXKEYS = { - TEST_KEY: "test", - TEST_KEY_2: "test2", - COLLECTION: { - TEST_KEY: "test_", - TEST_LEVEL_KEY: "test_level_", - TEST_LEVEL_LAST_KEY: "test_level_last_", - ROUTES: "routes_", - RAM_ONLY_COLLECTION: "ramOnlyCollection_", - }, - RAM_ONLY_KEY: "ramOnlyKey", + TEST_KEY: 'test', + TEST_KEY_2: 'test2', + COLLECTION: { + TEST_KEY: 'test_', + TEST_LEVEL_KEY: 'test_level_', + TEST_LEVEL_LAST_KEY: 'test_level_last_', + ROUTES: 'routes_', + RAM_ONLY_COLLECTION: 'ramOnlyCollection_', + }, + RAM_ONLY_KEY: 'ramOnlyKey', }; -describe("OnyxUtils", () => { - beforeAll(() => - Onyx.init({ - keys: ONYXKEYS, - ramOnlyKeys: [ - ONYXKEYS.RAM_ONLY_KEY, - ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION, - ], - }), - ); - - beforeEach(() => Onyx.clear()); - - afterEach(() => jest.clearAllMocks()); - - describe("partialSetCollection", () => { - beforeEach(() => { - Onyx.clear(); - }); - - afterEach(() => { - Onyx.clear(); - }); - it("should replace all existing collection members with new values and keep old ones intact", async () => { - let result: OnyxCollection; - const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; - const routeB = `${ONYXKEYS.COLLECTION.ROUTES}B`; - const routeB1 = `${ONYXKEYS.COLLECTION.ROUTES}B1`; - const routeC = `${ONYXKEYS.COLLECTION.ROUTES}C`; - - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.ROUTES, - callback: (value) => (result = value), - }); - - // Set initial collection state - await Onyx.setCollection(ONYXKEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - [routeB1]: { name: "Route B1" }, - [routeC]: { name: "Route C" }, - }); - - // Replace with new collection data - await OnyxUtils.partialSetCollection({ - collectionKey: ONYXKEYS.COLLECTION.ROUTES, - collection: { - [routeA]: { name: "New Route A" }, - [routeB]: { name: "New Route B" }, - [routeC]: { name: "New Route C" }, - }, - }); - - expect(result).toEqual({ - [routeA]: { name: "New Route A" }, - [routeB]: { name: "New Route B" }, - [routeB1]: { name: "Route B1" }, - [routeC]: { name: "New Route C" }, - }); - await Onyx.disconnect(connection); - }); - - it("should not replace anything in the collection with empty values", async () => { - let result: OnyxCollection; - const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; - - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.ROUTES, - callback: (value) => (result = value), - }); - - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - }); - - await OnyxUtils.partialSetCollection({ - collectionKey: ONYXKEYS.COLLECTION.ROUTES, - collection: {}, - }); - - expect(result).toEqual({ - [routeA]: { name: "Route A" }, - }); - await Onyx.disconnect(connection); - }); - - it("should reject collection items with invalid keys", async () => { - let result: OnyxCollection; - const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; - const invalidRoute = "invalid_route"; - - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.ROUTES, - callback: (value) => (result = value), - }); - - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { - [routeA]: { name: "Route A" }, - }); - - await OnyxUtils.partialSetCollection({ - collectionKey: ONYXKEYS.COLLECTION.ROUTES, - collection: { - [invalidRoute]: { name: "Invalid Route" }, - }, - }); - - expect(result).toEqual({ - [routeA]: { name: "Route A" }, - }); - - await Onyx.disconnect(connection); - }); - }); - - describe("multiSetWithRetry", () => { - it("should fire collection-level callback only once per collection even with multiple members", async () => { - const collectionCallback = jest.fn(); - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: collectionCallback, - }); - - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - // multiSet with 3 members of the same collection - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: { id: 1 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: { id: 2 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: { id: 3 }, - }); - - // Should be called only ONCE with the batched collection (not 3 times) - expect(collectionCallback).toHaveBeenCalledTimes(1); - const [collection] = collectionCallback.mock.calls.at(0); - expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]).toEqual({ id: 1 }); - expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]).toEqual({ id: 2 }); - expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}3`]).toEqual({ id: 3 }); - - Onyx.disconnect(connection); - }); - - it("should fire individual member-key subscribers once per key", async () => { - const spy1 = jest.fn(); - const spy2 = jest.fn(); - const spy3 = jest.fn(); - - const conn1 = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, - callback: spy1, - }); - const conn2 = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, - callback: spy2, - }); - const conn3 = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TEST_KEY}3`, - callback: spy3, - }); - await waitForPromisesToResolve(); - spy1.mockClear(); - spy2.mockClear(); - spy3.mockClear(); - - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: { id: 1 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: { id: 2 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: { id: 3 }, - }); - - expect(spy1).toHaveBeenCalledTimes(1); - expect(spy1).toHaveBeenCalledWith( - { id: 1 }, - `${ONYXKEYS.COLLECTION.TEST_KEY}1`, - ); - expect(spy2).toHaveBeenCalledTimes(1); - expect(spy2).toHaveBeenCalledWith( - { id: 2 }, - `${ONYXKEYS.COLLECTION.TEST_KEY}2`, - ); - expect(spy3).toHaveBeenCalledTimes(1); - expect(spy3).toHaveBeenCalledWith( - { id: 3 }, - `${ONYXKEYS.COLLECTION.TEST_KEY}3`, - ); - - Onyx.disconnect(conn1); - Onyx.disconnect(conn2); - Onyx.disconnect(conn3); - }); - - it("should notify non-collection keys individually alongside batched collection updates", async () => { - const collectionCallback = jest.fn(); - const singleKeyCallback = jest.fn(); - - const connCollection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: collectionCallback, - }); - const connSingle = Onyx.connect({ - key: ONYXKEYS.TEST_KEY, - callback: singleKeyCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - singleKeyCallback.mockClear(); - - // Mix of collection members and a non-collection key - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: { id: 1 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: { id: 2 }, - [ONYXKEYS.TEST_KEY]: "standalone", - }); - - // Collection callback fires once (batched) - expect(collectionCallback).toHaveBeenCalledTimes(1); - // Non-collection key callback fires once - expect(singleKeyCallback).toHaveBeenCalledTimes(1); - expect(singleKeyCallback).toHaveBeenCalledWith( - "standalone", - ONYXKEYS.TEST_KEY, - ); - - Onyx.disconnect(connCollection); - Onyx.disconnect(connSingle); - }); - - it("should batch notifications per-collection when members span multiple collections", async () => { - const testCallback = jest.fn(); - const routesCallback = jest.fn(); - - const connTest = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: testCallback, - }); - const connRoutes = Onyx.connect({ - key: ONYXKEYS.COLLECTION.ROUTES, - callback: routesCallback, - }); - await waitForPromisesToResolve(); - testCallback.mockClear(); - routesCallback.mockClear(); - - // multiSet with members of two different collections - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: { id: 1 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: { id: 2 }, - [`${ONYXKEYS.COLLECTION.ROUTES}A`]: { name: "A" }, - [`${ONYXKEYS.COLLECTION.ROUTES}B`]: { name: "B" }, - }); - - // Each collection callback fires once - expect(testCallback).toHaveBeenCalledTimes(1); - expect(routesCallback).toHaveBeenCalledTimes(1); - - Onyx.disconnect(connTest); - Onyx.disconnect(connRoutes); - }); - - it("should pass previous values to keysChanged so unchanged members skip notification", async () => { - // Set initial data - const initial1 = { id: 1, name: "A" }; - const initial2 = { id: 2, name: "B" }; - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: initial1, - [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: initial2, - }); - - const spy1 = jest.fn(); - const spy2 = jest.fn(); - const conn1 = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, - callback: spy1, - }); - const conn2 = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, - callback: spy2, - }); - await waitForPromisesToResolve(); - spy1.mockClear(); - spy2.mockClear(); - - // multiSet: change key 1, keep key 2 with same content (but new reference) - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: { id: 1, name: "A-updated" }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: initial2, - }); - - // Key 1 subscriber fires (value changed) - expect(spy1).toHaveBeenCalledTimes(1); - expect(spy1).toHaveBeenCalledWith( - { id: 1, name: "A-updated" }, - `${ONYXKEYS.COLLECTION.TEST_KEY}1`, - ); - - // Key 2 keeps the same reference (passed as-is in multiSet) — subscriber should not fire - // because keysChanged sees the same reference as previousCollection[key] - expect(spy2).not.toHaveBeenCalled(); - - Onyx.disconnect(conn1); - Onyx.disconnect(conn2); - }); - - it("should not fire again for a collection subscriber that disconnects itself in its callback", async () => { - // A collection-root subscriber disconnects itself when it receives a - // collection object. A subsequent collection change must NOT trigger another callback. - const callback = jest.fn(); - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback, - }); - await waitForPromisesToResolve(); - callback.mockReset(); - callback.mockImplementation(() => { - Onyx.disconnect(connection); - }); - - // First batch fires the collection callback once, which disconnects the subscriber. - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: { id: 1 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: { id: 2 }, - [`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: { id: 3 }, - }); - - expect(callback).toHaveBeenCalledTimes(1); - - // A subsequent change must not fire the now-disconnected subscriber again. - await Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, { id: 11 }); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it("should keep cache and subscriber state consistent when a non-collection callback writes to another payload key", async () => { - // A subscriber for keyA synchronously calls Onyx.set() on keyB during its callback. - // After multiSet completes, the cache must reflect the multiSet's value for keyB - // (multiSet wins), and the keyB subscriber's last seen value must equal the cache. - await Onyx.multiSet({ - [ONYXKEYS.TEST_KEY]: "initialA", - [ONYXKEYS.TEST_KEY_2]: "initialB", - }); - - const callbackA = jest.fn((value: unknown) => { - if (value !== "newA") { - return; - } - - // While processing the new value of keyA, write to keyB. - // keyB is later in the same multiSet payload — multiSet should win. - Onyx.set(ONYXKEYS.TEST_KEY_2, "callbackB"); - }); - const callbackB = jest.fn(); - - const connA = Onyx.connect({ - key: ONYXKEYS.TEST_KEY, - callback: callbackA, - }); - const connB = Onyx.connect({ - key: ONYXKEYS.TEST_KEY_2, - callback: callbackB, - }); - await waitForPromisesToResolve(); - callbackA.mockClear(); - callbackB.mockClear(); - - await Onyx.multiSet({ - [ONYXKEYS.TEST_KEY]: "newA", - [ONYXKEYS.TEST_KEY_2]: "multiSetB", - }); - - // Cache reflects multiSet's payload value for keyB (the multiSet's later cache.set wins) - expect(OnyxCache.get(ONYXKEYS.TEST_KEY_2)).toBe("multiSetB"); - - expect(callbackB.mock.calls.length).toBe(2); - expect(callbackB.mock.calls.at(0)?.[0]).toBe("callbackB"); - // keyB subscriber's last received value matches the cache (no stale callback) - expect(callbackB.mock.calls.at(1)?.[0]).toBe("multiSetB"); - - Onyx.disconnect(connA); - Onyx.disconnect(connB); - }); - }); - - describe("keysChanged", () => { - beforeEach(() => { - Onyx.clear(); - }); - - afterEach(() => { - Onyx.clear(); - }); - - it("should call callback when data actually changes for collection member key subscribers", async () => { - const callbackSpy = jest.fn(); - const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}123`; - const connection = Onyx.connect({ - key: entryKey, - callback: callbackSpy, - }); - - const entryData = { value: "updated_data" }; - - // Create partial collection data that includes our member key - const collection = { - [entryKey]: entryData, - } as Collection; - - // Clear the callback spy to focus on the keysChanged behavior - callbackSpy.mockClear(); - - await Onyx.setCollection(ONYXKEYS.COLLECTION.TEST_KEY, collection); - - // Verify the subscriber callback was called - expect(callbackSpy).toHaveBeenCalledTimes(1); - expect(callbackSpy).toHaveBeenCalledWith(entryData, entryKey); - - await Onyx.disconnect(connection); - }); - - it("should set lastConnectionCallbackData for collection member key subscribers", async () => { - const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}456`; - const initialEntryData = { value: "initial_data" }; - const updatedEntryData = { value: "updated_data" }; - const newEntryData = { value: "new_data" }; - const callbackSpy = jest.fn(); - - const connection = await Onyx.connect({ - key: entryKey, - callback: callbackSpy, - }); - - // Create partial collection data that includes our member key - const initialCollection = { - [entryKey]: initialEntryData, - } as Collection; - - // Clear the callback spy to focus on the keysChanged behavior - callbackSpy.mockClear(); - - OnyxUtils.keysChanged( - ONYXKEYS.COLLECTION.TEST_KEY, - { [entryKey]: updatedEntryData }, // new collection - initialCollection, // previous collection - ); - - // Should be called again because data changed - expect(callbackSpy).toHaveBeenCalledTimes(1); - expect(callbackSpy).toHaveBeenCalledWith(undefined, entryKey); - - // Clear the callback spy to focus on the keyChanged behavior - callbackSpy.mockClear(); - - OnyxUtils.keyChanged( - entryKey, - newEntryData, // Second update with different data - () => true, // notify connect subscribers - ); - - // Should be called again because data changed - expect(callbackSpy).toHaveBeenCalledTimes(1); - expect(callbackSpy).toHaveBeenCalledWith(newEntryData, entryKey); - - await Onyx.disconnect(connection); - }); - - it("should notify collection-level subscribers with the whole collection object", async () => { - const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}789`; - const entryData = { value: "data" }; - - const collectionCallback = jest.fn(); - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TEST_KEY, - callback: collectionCallback, - }); - - await Onyx.set(entryKey, entryData); - collectionCallback.mockClear(); - - // Trigger keysChanged directly with a partial collection - OnyxUtils.keysChanged( - ONYXKEYS.COLLECTION.TEST_KEY, - { [entryKey]: entryData }, - {}, - ); - - expect(collectionCallback).toHaveBeenCalledTimes(1); - // Collection subscriber receives the full cached collection and subscriber.key - const [receivedCollection, receivedKey] = - collectionCallback.mock.calls.at(0); - expect(receivedKey).toBe(ONYXKEYS.COLLECTION.TEST_KEY); - expect(receivedCollection[entryKey]).toEqual(entryData); - - Onyx.disconnect(connection); - }); - - it("should skip notification when member value has same reference in previous and current collection", async () => { - const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}same`; - const sameValue = { value: "unchanged" }; - - await Onyx.set(entryKey, sameValue); - - const callbackSpy = jest.fn(); - const connection = Onyx.connect({ - key: entryKey, - callback: callbackSpy, - }); - await waitForPromisesToResolve(); - callbackSpy.mockClear(); - - // Simulate keysChanged where the previous and current value are the SAME reference - // (which happens with frozen snapshots when nothing changed). === should skip notification. - OnyxUtils.keysChanged( - ONYXKEYS.COLLECTION.TEST_KEY, - { [entryKey]: sameValue }, - { [entryKey]: sameValue }, - ); - - expect(callbackSpy).not.toHaveBeenCalled(); - - Onyx.disconnect(connection); - }); - - it("should notify member subscribers only for changed keys in a batched update", async () => { - const keyA = `${ONYXKEYS.COLLECTION.TEST_KEY}A`; - const keyB = `${ONYXKEYS.COLLECTION.TEST_KEY}B`; - const keyC = `${ONYXKEYS.COLLECTION.TEST_KEY}C`; - - const dataA = { value: "A" }; - const dataB = { value: "B" }; - const dataC = { value: "C" }; - - await Onyx.multiSet({ [keyA]: dataA, [keyB]: dataB, [keyC]: dataC }); - - const spyA = jest.fn(); - const spyB = jest.fn(); - const spyC = jest.fn(); - const connA = Onyx.connect({ key: keyA, callback: spyA }); - const connB = Onyx.connect({ key: keyB, callback: spyB }); - const connC = Onyx.connect({ key: keyC, callback: spyC }); - await waitForPromisesToResolve(); - spyA.mockClear(); - spyB.mockClear(); - spyC.mockClear(); - - // Update cache so keysChanged reads the new values via getCachedCollection - const newA = { value: "A-updated" }; - const newC = { value: "C-updated" }; - OnyxCache.set(keyA, newA); - OnyxCache.set(keyC, newC); - // keyB stays the same reference - - OnyxUtils.keysChanged( - ONYXKEYS.COLLECTION.TEST_KEY, - { [keyA]: newA, [keyB]: dataB, [keyC]: newC }, - { [keyA]: dataA, [keyB]: dataB, [keyC]: dataC }, - ); - - expect(spyA).toHaveBeenCalledTimes(1); - expect(spyB).not.toHaveBeenCalled(); - expect(spyC).toHaveBeenCalledTimes(1); - - Onyx.disconnect(connA); - Onyx.disconnect(connB); - Onyx.disconnect(connC); - }); - - it("should catch errors thrown by subscriber callbacks and continue notifying others", async () => { - const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}errorTest`; - const entryData = { value: "data" }; - - await Onyx.set(entryKey, entryData); - - const failingCallback = jest.fn(); - const workingCallback = jest.fn(); - - const connFailing = Onyx.connect({ - key: entryKey, - callback: failingCallback, - reuseConnection: false, - }); - const connWorking = Onyx.connect({ - key: entryKey, - callback: workingCallback, - reuseConnection: false, - }); - await waitForPromisesToResolve(); - failingCallback.mockReset(); - failingCallback.mockImplementation(() => { - throw new Error("subscriber failure"); - }); - workingCallback.mockClear(); - - // Spy on Logger to verify the error is logged - const logSpy = jest - .spyOn(Logger, "logAlert") - .mockImplementation(() => undefined); - - const newData = { value: "new" }; - // Update the cache so keysChanged sees the new value as different from previous - OnyxCache.set(entryKey, newData); - OnyxUtils.keysChanged( - ONYXKEYS.COLLECTION.TEST_KEY, - { [entryKey]: newData }, - { [entryKey]: entryData }, - ); - - // Both callbacks should have been attempted; error should be logged - expect(failingCallback).toHaveBeenCalled(); - expect(workingCallback).toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalled(); - - logSpy.mockRestore(); - Onyx.disconnect(connFailing); - Onyx.disconnect(connWorking); - }); - }); - - describe("mergeChanges", () => { - it("should return the last change if it's an array", () => { - const { result } = OnyxUtils.mergeChanges( - [...testMergeChanges, [0, 1, 2]], - testObject, - ); - - expect(result).toEqual([0, 1, 2]); - }); - - it("should return the last change if the changes aren't objects", () => { - const { result } = OnyxUtils.mergeChanges(["a", 0, "b", 1], testObject); - - expect(result).toEqual(1); - }); - - it("should merge data correctly when applying batched changes", () => { - const batchedChanges: GenericDeepRecord = { - b: { - d: { - i: "i", - j: "j", - [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, - }, - h: "h", - g: { - [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, - k: "k", - }, - }, - }; - - const { result } = OnyxUtils.mergeChanges([batchedChanges], testObject); - - expect(result).toEqual({ - a: "a", - b: { - c: "c", - d: { - i: "i", - j: "j", - }, - h: "h", - g: { - k: "k", - }, - }, - }); - }); - }); - - describe("mergeAndMarkChanges", () => { - it("should apply the replacement markers if we have properties with objects being removed and added back during the changes", () => { - const { result, replaceNullPatches } = - OnyxUtils.mergeAndMarkChanges(testMergeChanges); - - expect(result).toEqual({ - b: { - d: { - i: "i", - j: "j", - [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, - }, - h: "h", - g: { - [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, - k: "k", - }, - }, - }); - expect(replaceNullPatches).toEqual([ - [["b", "d"], { i: "i" }], - [["b", "d"], { i: "i", j: "j" }], - [["b", "g"], { k: "k" }], - ]); - }); - }); - - describe("retryOperation", () => { - const retryOperationSpy = jest.spyOn(OnyxUtils, "retryOperation"); - /** Mirrors StorageCircuitBreaker rolling-window trip threshold. */ - const STORAGE_FAILURE_THRESHOLD = 50; - const genericError = new Error("Generic storage error"); - const invalidDataError = new Error( - "Failed to execute 'put' on 'IDBObjectStore': invalid data", +describe('OnyxUtils', () => { + beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + ramOnlyKeys: [ONYXKEYS.RAM_ONLY_KEY, ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION], + }), ); - const diskFullError = new Error("database or disk is full"); - const nonRetriableIdbError = Object.assign( - new Error("Internal error opening backing store for indexedDB.open."), - { name: "UnknownError" }, - ); - - // The circuit breaker is process-scoped, so reset it between tests to avoid state leaking. - beforeEach(() => StorageCircuitBreaker.reset()); - - it("should retry only one time if the operation is firstly failed and then passed", async () => { - StorageMock.setItem = jest - .fn(StorageMock.setItem) - .mockRejectedValueOnce(genericError) - .mockImplementation(StorageMock.setItem); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - // Should be called once, since Storage.setItem if failed only once - expect(retryOperationSpy).toHaveBeenCalledTimes(1); - }); - - it("should stop retrying after MAX_STORAGE_OPERATION_RETRY_ATTEMPTS retries for failing operation", async () => { - StorageMock.setItem = jest.fn().mockRejectedValue(genericError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - // Should be called 6 times: initial attempt + 5 retries (MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) - expect(retryOperationSpy).toHaveBeenCalledTimes(6); - }); - - it("should log the full shape of an unclassified (UNKNOWN) error once per operation", async () => { - const logAlertSpy = jest.spyOn(Logger, "logAlert"); - StorageMock.setItem = jest.fn().mockRejectedValue(genericError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - // UNKNOWN is instrumented so we can see what to promote into a real class. The shape (provider - // + name + message) is logged exactly once even though the operation retries 6 times. - const unclassifiedCalls = logAlertSpy.mock.calls.filter( - (call) => - typeof call[0] === "string" && - call[0].startsWith("Unclassified storage error."), - ); - expect(unclassifiedCalls).toHaveLength(1); - expect(unclassifiedCalls.at(0)[0]).toBe( - `Unclassified storage error. provider: MemoryOnlyProvider. name: ${genericError.name}. message: ${genericError.message}. onyxMethod: setWithRetry.`, - ); - }); - - it("should throw error for if operation failed with \"Failed to execute 'put' on 'IDBObjectStore': invalid data\" error", async () => { - StorageMock.setItem = jest.fn().mockRejectedValueOnce(invalidDataError); - - await expect( - Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }), - ).rejects.toThrow(invalidDataError); - }); - - it("should not retry in case of storage capacity error and no keys to evict", async () => { - StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - // Should only be called once since there are no evictable keys - expect(retryOperationSpy).toHaveBeenCalledTimes(1); - }); - - it("should not retry for non-retriable IndexedDB backing-store errors", async () => { - StorageMock.setItem = jest.fn().mockRejectedValue(nonRetriableIdbError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - // Called once (initial attempt only) -- no recursion, unlike the 6 calls for generic errors - expect(retryOperationSpy).toHaveBeenCalledTimes(1); - }); - - it("should skip retry quietly (info, not alert) for fatal connection-layer errors", async () => { - const logAlertSpy = jest.spyOn(Logger, "logAlert"); - const logInfoSpy = jest.spyOn(Logger, "logInfo"); - StorageMock.setItem = jest.fn().mockRejectedValue(nonRetriableIdbError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - // The connection layer (createStore) owns and alerts on fatal errors; the operation layer - // just skips the retry at info level. No alert here, and no "5 retries exhausted" alert. - expect(logInfoSpy).toHaveBeenCalledWith( - `Storage operation skipped retry; fatal errors are handled by the connection layer. Error: ${nonRetriableIdbError}. onyxMethod: setWithRetry.`, - ); - expect(logAlertSpy).not.toHaveBeenCalled(); - }); - - it("should include the error in logAlert for IDBObjectStore invalid data errors", async () => { - const logAlertSpy = jest.spyOn(Logger, "logAlert"); - StorageMock.setItem = jest.fn().mockRejectedValueOnce(invalidDataError); - - await expect( - Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }), - ).rejects.toThrow(invalidDataError); - - expect(logAlertSpy).toHaveBeenCalledWith( - `Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${invalidDataError}`, - ); - }); - - it("should include the error in logs when out of storage with no evictable keys", async () => { - const logAlertSpy = jest.spyOn(Logger, "logAlert"); - const logInfoSpy = jest.spyOn(Logger, "logInfo"); - StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - expect(logAlertSpy).toHaveBeenCalledWith( - `Out of storage. But found no acceptable keys to remove. Error: ${diskFullError}`, - ); - expect(logInfoSpy).toHaveBeenCalledWith( - `Storage Quota Check -- bytesUsed: 0 originWideBytesRemaining (estimate, not per-DB headroom): Infinity. Original error: ${diskFullError}`, - ); - }); - - it("should include usageDetails in the storage quota log when available", async () => { - const logInfoSpy = jest.spyOn(Logger, "logInfo"); - const usageDetails = { - caches: 1500160, - fileSystem: 1369398, - indexedDB: 10419711, - }; - StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); - StorageMock.getDatabaseSize = jest.fn().mockResolvedValue({ - bytesUsed: 13289269, - bytesRemaining: 5000000, - usageDetails, - }); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - expect(logInfoSpy).toHaveBeenCalledWith( - `Storage Quota Check -- bytesUsed: 13289269 originWideBytesRemaining (estimate, not per-DB headroom): 5000000 usageDetails: ${JSON.stringify( - usageDetails, - )}. Original error: ${diskFullError}`, - ); - }); - - it("should include the error in logAlert when out of storage and getDatabaseSize fails", async () => { - const dbSizeError = new Error("Failed to estimate storage"); - const logAlertSpy = jest.spyOn(Logger, "logAlert"); - StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); - StorageMock.getDatabaseSize = jest.fn().mockRejectedValue(dbSizeError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - expect(logAlertSpy).toHaveBeenCalledWith( - `Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${diskFullError}`, - ); - }); - - it("should trip the circuit breaker and alert once after sustained capacity failures", async () => { - const logAlertSpy = jest.spyOn(Logger, "logAlert"); - StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); - - // No evictable keys are configured, so each failing write records exactly one capacity - // failure with the breaker (it cannot evict). Enough of them within one window trips it. - for (let i = 0; i <= STORAGE_FAILURE_THRESHOLD; i++) { - await Onyx.set(ONYXKEYS.TEST_KEY, { test: i }); - } - await waitForPromisesToResolve(); - - expect(StorageCircuitBreaker.isAllowed()).toBe(false); - expect(logAlertSpy).toHaveBeenCalledWith( - expect.stringContaining("Storage circuit breaker tripped"), - ); - }); - - it("should drop capacity writes silently while the circuit breaker is open", async () => { - // Trip the breaker deterministically so every capacity failure below is observed while open. - for (let i = 0; i <= STORAGE_FAILURE_THRESHOLD; i++) { - StorageCircuitBreaker.recordCapacityFailure(); - } - expect(StorageCircuitBreaker.isAllowed()).toBe(false); - - // Clear so we only observe logging caused by the write below, not the trip alert above. - const logInfoSpy = jest.spyOn(Logger, "logInfo").mockClear(); - const logAlertSpy = jest.spyOn(Logger, "logAlert").mockClear(); - StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); - - await Onyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - await waitForPromisesToResolve(); - - // The write and any cascading derived writes are dropped without per-write log spam, and - // without re-alerting — the single trip alert is the only signal while open. - expect(StorageCircuitBreaker.isAllowed()).toBe(false); - expect(logInfoSpy).not.toHaveBeenCalledWith( - expect.stringContaining("Failed to save to storage"), - ); - expect(logAlertSpy).not.toHaveBeenCalled(); - }); - - it("should not re-add an evicted key to recentlyAccessedKeys after removal", async () => { - // Re-init with evictable keys so getKeyForEviction() has something to return - Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); - Onyx.init({ - keys: ONYXKEYS, - evictableKeys: [ONYXKEYS.COLLECTION.TEST_KEY], - }); - await waitForPromisesToResolve(); - - const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - - await Onyx.set(evictableKey, { id: 1 }); - expect(OnyxCache.getKeyForEviction()).toBe(evictableKey); - - await OnyxUtils.remove(evictableKey); - expect(OnyxCache.getKeyForEviction()).toBeUndefined(); - }); - }); - - describe("mergeCollection cache-first ordering", () => { - // Save originals so we can restore them after each test. The tests below replace - // StorageMock.multiMerge / StorageMock.multiSet with rejecting mocks; without - // restoring, the mock leaks into later describe blocks (e.g. eviction tests) whose - // setup relies on these storage methods working normally. - const originalMultiMerge = StorageMock.multiMerge; - const originalMultiSet = StorageMock.multiSet; - - afterEach(() => { - StorageMock.multiMerge = originalMultiMerge; - StorageMock.multiSet = originalMultiSet; - }); - - it("updates cache and notifies subscribers even when Storage.multiMerge rejects", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const existingMemberKey = `${collectionKey}1`; - const newMemberKey = `${collectionKey}2`; - - // Seed an existing member so the merge path exercises multiMerge (existing) + multiSet (new) - await Onyx.set(existingMemberKey, { value: "initial" }); - - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - // Force Storage.multiMerge to reject with a non-retriable IDB error so the failure - // path is taken without burning the full retry budget and without rejecting the - // outer Onyx.mergeCollection promise. - const nonRetriableIdbError = Object.assign( - new Error("Internal error opening backing store for indexedDB.open."), - { name: "UnknownError" }, - ); - StorageMock.multiMerge = jest - .fn() - .mockRejectedValue(nonRetriableIdbError); - - await Onyx.mergeCollection(collectionKey, { - [existingMemberKey]: { value: "merged" }, - [newMemberKey]: { value: "new" }, - }); - - // Cache must reflect the merge regardless of the multiMerge rejection. This is the - // cache-first / storage-second invariant that mergeCollectionWithPatches must honor. - const cachedCollection = OnyxCache.getCollectionData(collectionKey); - expect(cachedCollection?.[existingMemberKey]).toEqual({ - value: "merged", - }); - expect(cachedCollection?.[newMemberKey]).toEqual({ value: "new" }); - - // Subscribers must have been notified with the merged values. - expect(collectionCallback).toHaveBeenCalled(); - const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as - | Record - | undefined; - expect(lastBroadcast?.[existingMemberKey]).toEqual({ value: "merged" }); - expect(lastBroadcast?.[newMemberKey]).toEqual({ value: "new" }); - }); - - it("updates cache and notifies subscribers even when Storage.multiSet rejects", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const newMemberKey1 = `${collectionKey}1`; - const newMemberKey2 = `${collectionKey}2`; - - // No keys are seeded, so every merged key is a "new" key. This forces the merge path - // to use Storage.multiSet (existing keys would go through Storage.multiMerge). - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - // Force Storage.multiSet to reject with a non-retriable IDB error so the failure - // path is taken without burning the full retry budget and without rejecting the - // outer Onyx.mergeCollection promise. - const nonRetriableIdbError = Object.assign( - new Error("Internal error opening backing store for indexedDB.open."), - { name: "UnknownError" }, - ); - StorageMock.multiSet = jest.fn().mockRejectedValue(nonRetriableIdbError); - - await Onyx.mergeCollection(collectionKey, { - [newMemberKey1]: { value: "first" }, - [newMemberKey2]: { value: "second" }, - }); - - // Cache must reflect the merge regardless of the multiSet rejection. This is the - // cache-first / storage-second invariant that mergeCollectionWithPatches must honor. - const cachedCollection = OnyxCache.getCollectionData(collectionKey); - expect(cachedCollection?.[newMemberKey1]).toEqual({ value: "first" }); - expect(cachedCollection?.[newMemberKey2]).toEqual({ value: "second" }); - - // Subscribers must have been notified with the merged values. - expect(collectionCallback).toHaveBeenCalled(); - const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as - | Record - | undefined; - expect(lastBroadcast?.[newMemberKey1]).toEqual({ value: "first" }); - expect(lastBroadcast?.[newMemberKey2]).toEqual({ value: "second" }); - }); - }); - - describe("retry side-effect idempotency", () => { - // Save originals so each test can replace StorageMock.multiMerge / StorageMock.multiSet - // with a one-shot rejecting mock that triggers retryOperation's transient-error path. - // Restoring keeps mocks from leaking into the storage-eviction describe block below. - const originalMultiMerge = StorageMock.multiMerge; - const originalMultiSet = StorageMock.multiSet; - - afterEach(() => { - StorageMock.multiMerge = originalMultiMerge; - StorageMock.multiSet = originalMultiSet; - }); - - // A retriable error: not in NON_RETRIABLE_ERRORS, not in STORAGE_ERRORS, so retryOperation - // re-enters the failing method on the next attempt. - const transientError = new Error("Transient storage error"); - - it("mergeCollection — collection-root subscriber fires once across retries", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const existingMemberKey = `${collectionKey}1`; - const newMemberKey = `${collectionKey}2`; - - await Onyx.set(existingMemberKey, { value: "initial" }); - - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - StorageMock.multiMerge = jest - .fn(originalMultiMerge) - .mockRejectedValueOnce(transientError); - - await Onyx.mergeCollection(collectionKey, { - [existingMemberKey]: { value: "merged" }, - [newMemberKey]: { value: "new" }, - }); - - // Before this fix, every retry attempt re-fired keysChanged() — and - // Collection-root subscribers fire on every keysChanged() call by contract. - // After the fix, retries skip the keysChanged re-fire, so subscribers are notified - // exactly once per logical operation. - expect(collectionCallback).toHaveBeenCalledTimes(1); - }); - - it("Onyx.multiSet — collection subscriber fires once across retries", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const memberKey1 = `${collectionKey}1`; - const memberKey2 = `${collectionKey}2`; - - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - StorageMock.multiSet = jest - .fn(originalMultiSet) - .mockRejectedValueOnce(transientError); - - await Onyx.multiSet({ - [memberKey1]: { value: "first" }, - [memberKey2]: { value: "second" }, - }); - - expect(collectionCallback).toHaveBeenCalledTimes(1); - }); - - it("Onyx.setCollection — collection subscriber fires once across retries", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const memberKey1 = `${collectionKey}1`; - const memberKey2 = `${collectionKey}2`; - - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - StorageMock.multiSet = jest - .fn(originalMultiSet) - .mockRejectedValueOnce(transientError); - - await Onyx.setCollection(collectionKey, { - [memberKey1]: { value: "first" }, - [memberKey2]: { value: "second" }, - }); - - expect(collectionCallback).toHaveBeenCalledTimes(1); - }); - - it("OnyxUtils.partialSetCollection — collection subscriber fires once across retries", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const memberKey1 = `${collectionKey}1`; - const memberKey2 = `${collectionKey}2`; - - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - StorageMock.multiSet = jest - .fn(originalMultiSet) - .mockRejectedValueOnce(transientError); - - await OnyxUtils.partialSetCollection({ - collectionKey, - collection: { - [memberKey1]: { value: "first" }, - [memberKey2]: { value: "second" }, - }, - }); - - expect(collectionCallback).toHaveBeenCalledTimes(1); - }); - }); - - describe("mergeCollection pre-warm", () => { - // retryOperation tests above replace StorageMock methods without restoring them, leaving - // rejecting mocks behind. Capture pristine refs at file-load time and restore in beforeEach - // so our Onyx.set seeding actually reaches the in-memory storage provider. - const pristineSetItem = StorageMock.setItem; - const pristineMultiSet = StorageMock.multiSet; - const pristineMultiGet = StorageMock.multiGet; - const pristineGetItem = StorageMock.getItem; - const pristineMultiMerge = StorageMock.multiMerge; - - beforeEach(() => { - StorageMock.setItem = pristineSetItem; - StorageMock.multiSet = pristineMultiSet; - StorageMock.multiGet = pristineMultiGet; - StorageMock.getItem = pristineGetItem; - StorageMock.multiMerge = pristineMultiMerge; - }); - - // Make a key "cold" — value evicted from cache but still tracked as persisted. OnyxCache.drop - // also removes the key from `storageKeys`, so we re-register it afterwards to reliably hit - // the cold-but-persisted state regardless of getAllKeys()'s fallback path. - const evictFromCache = (...keys: string[]) => { - for (const key of keys) { - OnyxCache.drop(key); - OnyxCache.addKey(key); - } - }; - - it("fast path: skips storage reads entirely when every existing key is warm in cache", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const existingKey1 = `${collectionKey}1`; - const existingKey2 = `${collectionKey}2`; - - // Seed both members so they are both warm in cache and present in storage. - await Onyx.set(existingKey1, { value: "initial-1" }); - await Onyx.set(existingKey2, { value: "initial-2" }); - - const multiGetSpy = jest.spyOn(StorageMock, "multiGet"); - const getItemSpy = jest.spyOn(StorageMock, "getItem"); - - await Onyx.mergeCollection(collectionKey, { - [existingKey1]: { value: "merged-1" }, - [existingKey2]: { value: "merged-2" }, - }); - - // With every existingKey warm, the diff swaps Promise.all(get) for Promise.resolve(), - // so no storage reads should happen during the pre-warm. - expect(multiGetSpy).not.toHaveBeenCalled(); - expect(getItemSpy).not.toHaveBeenCalled(); - - // Cache still reflects the merge. - const cached = OnyxCache.getCollectionData(collectionKey); - expect(cached?.[existingKey1]).toEqual({ value: "merged-1" }); - expect(cached?.[existingKey2]).toEqual({ value: "merged-2" }); - }); - - it("slow path: batches cold existing keys into a single Storage.multiGet, with no individual getItem calls", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const coldKey1 = `${collectionKey}1`; - const coldKey2 = `${collectionKey}2`; - const warmKey = `${collectionKey}3`; - - // Seed all three in storage, then evict two from cache so they are cold-but-persisted. - await Onyx.set(coldKey1, { value: "persisted-1" }); - await Onyx.set(coldKey2, { value: "persisted-2" }); - await Onyx.set(warmKey, { value: "persisted-3" }); - evictFromCache(coldKey1, coldKey2); - - // Reset spies AFTER seeding so we only count calls made during mergeCollection itself. - const multiGetSpy = jest.spyOn(StorageMock, "multiGet").mockClear(); - const getItemSpy = jest.spyOn(StorageMock, "getItem").mockClear(); - - await Onyx.mergeCollection(collectionKey, { - [coldKey1]: { value: "merged-1" }, - [coldKey2]: { value: "merged-2" }, - [warmKey]: { value: "merged-3" }, - }); - - // OnyxUtils.multiGet filters to cache-missing keys before issuing Storage.multiGet, so we - // expect exactly one batched read containing only the cold keys (the warm key is skipped). - expect(multiGetSpy).toHaveBeenCalledTimes(1); - const requestedKeys = multiGetSpy.mock.calls.at(0)[0]; - expect(requestedKeys.sort()).toEqual([coldKey1, coldKey2].sort()); - - // No individual Storage.getItem calls during pre-warm. Old code path would have fired one - // get() per existing key, each potentially landing in Storage.getItem on cache miss. - expect(getItemSpy).not.toHaveBeenCalled(); - }); - - it("slow path: cold-cache merge layers the new delta on top of existing storage data (no field drops)", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const coldKey = `${collectionKey}1`; - - // Seed an object with multiple fields in storage, then evict from cache so the merge base - // must come from a storage read — not from `undefined`. - await Onyx.set(coldKey, { a: 1, b: 2 }); - evictFromCache(coldKey); - - await Onyx.mergeCollection(collectionKey, { - [coldKey]: { c: 3 }, - }); - - // If the pre-warm did NOT populate the cache from storage, fastMerge would treat the - // previous value as undefined and the result would drop {a:1, b:2}. With the pre-warm - // running multiGet on the cold key, the merge layers {c:3} on top of {a:1, b:2}. - const cached = OnyxCache.getCollectionData(collectionKey); - expect(cached?.[coldKey]).toEqual({ a: 1, b: 2, c: 3 }); - }); - - it("warm cache: subscriber receives a single merged broadcast for an Onyx.update batch (no transient undefined)", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const existingKey = `${collectionKey}1`; - - await Onyx.set(existingKey, { value: "initial" }); - - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - await Onyx.update([ - { - onyxMethod: Onyx.METHOD.MERGE_COLLECTION, - key: collectionKey, - value: { - [existingKey]: { value: "merged" }, - }, - }, - ]); - - // The fast path resolves the pre-warm synchronously (Promise.resolve()), preserving the - // original promise-chain depth. The Onyx.update batch must therefore broadcast exactly - // one merged value — not undefined first and the merged value on a later microtask. - const broadcasts = collectionCallback.mock.calls.map((c) => c[0]); - expect(broadcasts).toHaveLength(1); - expect(broadcasts.at(0)?.[existingKey]).toEqual({ value: "merged" }); - }); - - it("equivalence: warm-path and cold-path produce the same final cache state for the same merge", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const memberKey = `${collectionKey}1`; - const delta = { value: "after" } as const; - - // Warm-path run. - await Onyx.set(memberKey, { value: "before", extra: "kept" }); - await Onyx.mergeCollection(collectionKey, { - [memberKey]: delta, - }); - const warmResult = - OnyxCache.getCollectionData(collectionKey)?.[memberKey]; - - // Reset and replay with a cold cache before the merge. - await Onyx.clear(); - await Onyx.set(memberKey, { value: "before", extra: "kept" }); - evictFromCache(memberKey); - await Onyx.mergeCollection(collectionKey, { - [memberKey]: delta, - }); - const coldResult = - OnyxCache.getCollectionData(collectionKey)?.[memberKey]; - - expect(warmResult).toEqual(coldResult); - expect(coldResult).toEqual({ value: "after", extra: "kept" }); - }); - - it("preserves cache-first invariant when Storage.multiGet rejects on the slow path", async () => { - // A Storage.multiGet rejection during pre-warm must not skip the cache.merge() + - // keysChanged() that follow. Without the .catch() at the pre-warm call site, - // subscribers would miss the merge and Onyx.mergeCollection would reject. - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const coldMemberKey = `${collectionKey}1`; - const newMemberKey = `${collectionKey}2`; - - // Seed an existing member, then evict it from cache so it's "tracked but unloaded" — - // the slow path will try to multiGet it. - await Onyx.set(coldMemberKey, { value: "persisted" }); - evictFromCache(coldMemberKey); - - // Connect and flush the subscriber's initial load BEFORE installing the rejecting mock — - // otherwise the connect's own multiGet (no .catch) consumes the mockRejectedValueOnce and - // leaks an unhandled rejection instead of exercising the merge pre-warm path. - const collectionCallback = jest.fn(); - Onyx.connect({ - key: collectionKey, - callback: collectionCallback, - }); - await waitForPromisesToResolve(); - collectionCallback.mockClear(); - - // The subscriber's connect re-populated cache, so re-evict to force the merge into - // the slow (cold-key) path. Then reject the next Storage.multiGet so the pre-warm - // read fails. - evictFromCache(coldMemberKey); - const transientError = new Error("Transient IndexedDB read error"); - StorageMock.multiGet = jest - .fn(pristineMultiGet) - .mockRejectedValueOnce(transientError); - - // Outer promise must resolve, not reject, even when the pre-warm read fails. - let outerRejected: unknown = null; - const result = await Onyx.mergeCollection(collectionKey, { - [coldMemberKey]: { merged: true }, - [newMemberKey]: { value: "new" }, - }).catch((e: unknown) => { - outerRejected = e; - }); - expect(outerRejected).toBeNull(); - expect(result).toBeUndefined(); - - // cache.merge() + keysChanged() must still fire so subscribers see the merge. Use - // toMatchObject because a concurrent read may have re-populated the persisted value; - // what matters is that the new {merged: true} delta is applied on top. - expect(collectionCallback).toHaveBeenCalled(); - const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as - | Record - | undefined; - expect(lastBroadcast?.[coldMemberKey]).toMatchObject({ merged: true }); - expect(lastBroadcast?.[newMemberKey]).toEqual({ value: "new" }); - }); - }); - - describe("multiGet cache hit consistency", () => { - // Same suite-pollution guard as the pre-warm block above — capture pristine StorageMock - // refs at file-load time and restore them in beforeEach, since retryOperation tests leak. - const pristineSetItem = StorageMock.setItem; - const pristineMultiGet = StorageMock.multiGet; - const pristineGetItem = StorageMock.getItem; - - beforeEach(() => { - StorageMock.setItem = pristineSetItem; - StorageMock.multiGet = pristineMultiGet; - StorageMock.getItem = pristineGetItem; - }); - - it("does not re-fetch a cached falsy value from storage", async () => { - const falsyKey = ONYXKEYS.TEST_KEY; - - // Seed cache with the falsy value 0 (a number, but the same logic applies to '', - // false, and null). Using `Onyx.set` ensures the value lands in cache and storage. - await Onyx.set(falsyKey, 0); - - // Spy on Storage methods to confirm multiGet does NOT round-trip to storage for - // the cached falsy value. - const multiGetSpy = jest.spyOn(StorageMock, "multiGet"); - const getItemSpy = jest.spyOn(StorageMock, "getItem"); - - const result = await OnyxUtils.multiGet([falsyKey]); - - // The cached value must be returned without any storage read. Before this fix, - // `if (cacheValue)` treated the cached 0 as a miss and triggered Storage.multiGet, - // which would then overwrite the warm value via cache.merge(). - expect(multiGetSpy).not.toHaveBeenCalled(); - expect(getItemSpy).not.toHaveBeenCalled(); - expect(result.get(falsyKey)).toBe(0); - }); - - it("prefers cache when a concurrent write lands during the storage read", async () => { - // Concurrent write during multiGet's storage read must not be overwritten by the - // stale snapshot via cache.merge. - const key = `${ONYXKEYS.COLLECTION.TEST_KEY}race`; - - OnyxCache.drop(key); - OnyxCache.addKey(key); - - // Set cache inside the mock so it lands before Storage.multiGet's promise resolves — - // multiGet's .then() then sees a populated cache and skips writing the stale value. - StorageMock.multiGet = jest.fn().mockImplementation(() => { - OnyxCache.set(key, { fresh: "data" }); - return Promise.resolve([[key, { stale: "a", alsoStale: "b" }]]); - }); - - const result = await OnyxUtils.multiGet([key]); - - expect(OnyxCache.get(key)).toEqual({ fresh: "data" }); - expect(result.get(key)).toEqual({ fresh: "data" }); - }); - }); - - describe("storage eviction", () => { - const diskFullError = new Error("database or disk is full"); - - // Use local references that get fresh instances after jest.resetModules() - let LocalOnyx: typeof Onyx; - let LocalOnyxUtils: typeof OnyxUtils; - let LocalOnyxCache: typeof OnyxCache; - let LocalStorageMock: typeof StorageMock; - let LocalLogger: typeof Logger; - - // Reset all modules to get fresh singletons (OnyxCache, OnyxUtils, etc.) - // then re-init Onyx with evictableKeys configured - beforeEach(async () => { - jest.resetModules(); - - LocalOnyx = require("../../lib").default; - LocalOnyxUtils = require("../../lib/OnyxUtils").default; - LocalOnyxCache = require("../../lib/OnyxCache").default; - LocalStorageMock = require("../../lib/storage").default; - LocalLogger = require("../../lib/Logger"); - - LocalOnyx.init({ - keys: ONYXKEYS, - evictableKeys: [ONYXKEYS.COLLECTION.TEST_KEY], - }); - await waitForPromisesToResolve(); - }); - - it("should evict the least recently accessed evictable key on storage capacity error and retry successfully", async () => { - const key1 = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - const key2 = `${ONYXKEYS.COLLECTION.TEST_KEY}2`; - - await LocalOnyx.set(key1, { id: 1 }); - await LocalOnyx.set(key2, { id: 2 }); - expect(LocalOnyxCache.hasCacheForKey(key1)).toBe(true); - expect(LocalOnyxCache.hasCacheForKey(key2)).toBe(true); - - // Fail once with capacity error, then succeed - LocalStorageMock.setItem = jest - .fn(LocalStorageMock.setItem) - .mockRejectedValueOnce(diskFullError) - .mockImplementation(LocalStorageMock.setItem); - - await LocalOnyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - // key1 was least recently accessed, so it should have been evicted - expect(LocalOnyxCache.hasCacheForKey(key1)).toBe(false); - // key2 was more recently accessed, so it should still be in cache - expect(LocalOnyxCache.hasCacheForKey(key2)).toBe(true); - // The write that triggered the error should have succeeded on retry - expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({ test: "data" }); - }); - - it("should recover via a half-open eviction+retry probe after the open window clears", async () => { - // Regression for the bug where the capacity failure that triggers the half-open probe - // re-tripped the breaker before the eviction+retry could run — permanently disabling - // eviction-based recovery after the first trip. Drive the whole probe through retryOperation. - const ROLLING_WINDOW_MS = 60_000; - const FAILURE_THRESHOLD = 50; - const LocalStorageCircuitBreaker = - require("../../lib/StorageCircuitBreaker") - .default as typeof StorageCircuitBreaker; - - let currentTime = 1_000_000; - const nowSpy = jest - .spyOn(Date, "now") - .mockImplementation(() => currentTime); - - // Seed an evictable key so the probe has something to evict, then trip the breaker open. - const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - await LocalOnyx.set(evictableKey, { id: 1 }); - for (let i = 0; i <= FAILURE_THRESHOLD; i++) { - LocalStorageCircuitBreaker.recordCapacityFailure(); - } - expect(LocalStorageCircuitBreaker.isAllowed()).toBe(false); - - // Let the open window clear so the next capacity write is admitted as the half-open probe. - currentTime += ROLLING_WINDOW_MS; - - // The probe write fails once with capacity (triggering the probe), then its post-eviction retry succeeds. - LocalStorageMock.setItem = jest - .fn(LocalStorageMock.setItem) - .mockRejectedValueOnce(diskFullError) - .mockImplementation(LocalStorageMock.setItem); - await LocalOnyx.set(ONYXKEYS.TEST_KEY, { test: "recovered" }); - await waitForPromisesToResolve(); - - // The probe ran: the evictable key was evicted, the write landed, and the successful retry closed the circuit. - expect(LocalOnyxCache.hasCacheForKey(evictableKey)).toBe(false); - expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({ - test: "recovered", - }); - expect(LocalStorageCircuitBreaker.isAllowed()).toBe(true); - expect(LocalStorageCircuitBreaker.isAllowed()).toBe(true); - - nowSpy.mockRestore(); - }); - - it("should evict the least recently accessed key first (LRU order)", async () => { - const key1 = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - const key2 = `${ONYXKEYS.COLLECTION.TEST_KEY}2`; - const key3 = `${ONYXKEYS.COLLECTION.TEST_KEY}3`; - - // Set in order: key1, key2, key3 - await LocalOnyx.set(key1, { id: 1 }); - await LocalOnyx.set(key2, { id: 2 }); - await LocalOnyx.set(key3, { id: 3 }); - - // Now access key1 again so it becomes most recent - await LocalOnyx.merge(key1, { id: 1, updated: true }); - - // LRU order should now be: key2 (least recent), key3, key1 (most recent) - expect(LocalOnyxCache.getKeyForEviction()).toBe(key2); - }); - - it("should not evict non-evictable keys", async () => { - const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - - await LocalOnyx.set(evictableKey, { id: 1 }); - await LocalOnyx.set(ONYXKEYS.TEST_KEY, { test: "not evictable" }); - - // The evictable key should be a candidate for eviction - expect(LocalOnyxCache.isEvictableKey(evictableKey)).toBe(true); - // The non-evictable key should NOT be a candidate - expect(LocalOnyxCache.isEvictableKey(ONYXKEYS.TEST_KEY)).toBe(false); - - // Evict it - await LocalOnyxUtils.remove(evictableKey); - - // No more evictable candidates - expect(LocalOnyxCache.getKeyForEviction()).toBeUndefined(); - // Non-evictable key should still be in cache - expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({ - test: "not evictable", - }); - }); - - it("should not add collection keys to eviction candidates, only their members", async () => { - const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - - await LocalOnyx.set(memberKey, { id: 1 }); - - // The member key should be evictable - expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); - - // Attempting to add the collection key directly should be filtered out - LocalOnyxCache.addLastAccessedKey(ONYXKEYS.COLLECTION.TEST_KEY, true); - - // Should still return the member key, not the collection key - expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); - }); - - it("should seed evictable keys from storage at init", async () => { - // Set up storage with pre-existing evictable keys before init - jest.resetModules(); - - LocalOnyx = require("../../lib").default; - LocalOnyxCache = require("../../lib/OnyxCache").default; - const storage = require("../../lib/storage").default; - - await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre1`, { - id: "pre1", - }); - await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre2`, { - id: "pre2", - }); - - // Init — addEvictableKeysToRecentlyAccessedList should seed them - LocalOnyx.init({ - keys: ONYXKEYS, - evictableKeys: [ONYXKEYS.COLLECTION.TEST_KEY], - }); - await waitForPromisesToResolve(); - - // Pre-existing keys should be available for eviction without being explicitly accessed - const keyForEviction = LocalOnyxCache.getKeyForEviction(); - expect(keyForEviction).toBeDefined(); - expect(keyForEviction?.startsWith(ONYXKEYS.COLLECTION.TEST_KEY)).toBe( - true, - ); - }); - - it("should include the error in logs when evicting a key", async () => { - const logInfoSpy = jest.spyOn(LocalLogger, "logInfo"); - const key1 = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - - await LocalOnyx.set(key1, { id: 1 }); - - LocalStorageMock.setItem = jest - .fn(LocalStorageMock.setItem) - .mockRejectedValueOnce(diskFullError) - .mockImplementation(LocalStorageMock.setItem); - - await LocalOnyx.set(ONYXKEYS.TEST_KEY, { test: "data" }); - - expect(logInfoSpy).toHaveBeenCalledWith( - `Out of storage. Evicting least recently accessed key (${key1}) and retrying. Error: ${diskFullError}`, - ); - expect(logInfoSpy).toHaveBeenCalledWith( - `Storage Quota Check -- bytesUsed: 0 originWideBytesRemaining (estimate, not per-DB headroom): Infinity. Original error: ${diskFullError}`, - ); - }); - - it("multiSet — eviction of an UNRELATED key still notifies its subscribers (codex regression guard)", async () => { - const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - const writeKey = `${ONYXKEYS.COLLECTION.TEST_KEY}2`; - - // Seed the evictable key first so it becomes the LRU evictable. The subsequent multiSet - // writes a DIFFERENT key, so the evicted key is unrelated to the in-flight write. - await LocalOnyx.set(evictableKey, { value: "will-be-evicted" }); - expect(LocalOnyxCache.getKeyForEviction()).toBe(evictableKey); - - const subscriberCalls: unknown[] = []; - LocalOnyx.connect({ - key: evictableKey, - callback: (value) => subscriberCalls.push(value), - }); - await waitForPromisesToResolve(); - subscriberCalls.length = 0; - - // Storage.multiSet rejects once with disk-full, then succeeds on retry. - LocalStorageMock.multiSet = jest - .fn(LocalStorageMock.multiSet) - .mockRejectedValueOnce(diskFullError) - .mockImplementation(LocalStorageMock.multiSet); - - await LocalOnyx.multiSet({ [writeKey]: { value: "new" } }); - - // evictableKey was the LRU evictable, so retryOperation evicted it. It's not in the - // in-flight write's keys, so the retry's cache.set won't restore it — subscribers MUST - // see keyChanged(undefined) so they reflect the genuine removal (not stale value). - expect(LocalOnyxCache.hasCacheForKey(evictableKey)).toBe(false); - expect(subscriberCalls.at(-1)).toBeUndefined(); - }); - - it("multiSet — eviction of an IN-FLIGHT key does not strand its subscriber", async () => { - const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; - - // Seed memberKey so it becomes the LRU evictable. The multiSet below writes to the SAME - // key, so eviction picks an in-flight key. - await LocalOnyx.set(memberKey, { value: "original" }); - expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); - - const subscriberCalls: unknown[] = []; - LocalOnyx.connect({ - key: memberKey, - callback: (value) => subscriberCalls.push(value), - }); - await waitForPromisesToResolve(); - subscriberCalls.length = 0; - - LocalStorageMock.multiSet = jest - .fn(LocalStorageMock.multiSet) - .mockRejectedValueOnce(diskFullError) - .mockImplementation(LocalStorageMock.multiSet); - - await LocalOnyx.multiSet({ [memberKey]: { value: "updated" } }); - - // The in-flight key is excluded from eviction, so its cache value (the merge base) is - // never dropped. Subscriber's last value is the new value, never a transient undefined. - expect(LocalOnyxCache.get(memberKey)).toEqual({ value: "updated" }); - expect(subscriberCalls.at(-1)).toEqual({ value: "updated" }); - // Subscriber should never have seen undefined in the middle of the eviction-retry cycle. - expect(subscriberCalls).not.toContain(undefined); - }); - - it("mergeCollection — evicts an unrelated key, not the in-flight key, so its fields survive", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const memberKey = `${collectionKey}1`; - const unrelatedKey = `${collectionKey}2`; - - // Seed the in-flight member with extra fields, plus a separate evictable key. The merge - // only touches memberKey; the unrelated key is the genuine eviction target. - await LocalOnyx.set(memberKey, { id: 1, value: "orig" }); - await LocalOnyx.set(unrelatedKey, { value: "evict-me" }); - - const memberCalls: unknown[] = []; - LocalOnyx.connect({ - key: memberKey, - callback: (value) => memberCalls.push(value), - }); - await waitForPromisesToResolve(); - memberCalls.length = 0; - - // Storage.multiMerge rejects once with disk-full, then succeeds on retry. - LocalStorageMock.multiMerge = jest - .fn(LocalStorageMock.multiMerge) - .mockRejectedValueOnce(diskFullError) - .mockImplementation(LocalStorageMock.multiMerge); - - await LocalOnyx.mergeCollection(collectionKey, { - [memberKey]: { value: "merged" }, - }); - - // The old code evicted the in-flight key and re-ran the merge against an empty cache, - // collapsing {id: 1, value: 'orig'} + {value: 'merged'} to just {value: 'merged'}. Now - // the in-flight key is protected, so its pre-existing {id: 1} survives. - expect(LocalOnyxCache.get(memberKey)).toEqual({ id: 1, value: "merged" }); - expect(memberCalls.at(-1)).toEqual({ id: 1, value: "merged" }); - expect(memberCalls).not.toContain(undefined); - // The unrelated key was the genuine eviction target. - expect(LocalOnyxCache.hasCacheForKey(unrelatedKey)).toBe(false); - }); - - it("mergeCollection — does not truncate the in-flight key when it is the only evictable key", async () => { - const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; - const memberKey = `${collectionKey}1`; - - await LocalOnyx.set(memberKey, { id: 1, value: "orig" }); - expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); - - const memberCalls: unknown[] = []; - LocalOnyx.connect({ - key: memberKey, - callback: (value) => memberCalls.push(value), - }); - await waitForPromisesToResolve(); - memberCalls.length = 0; - - // The only evictable key is the in-flight one, which is now excluded — so retryOperation - // finds no acceptable key and reports the quota instead of dropping (and truncating) it. - LocalStorageMock.multiMerge = jest - .fn(LocalStorageMock.multiMerge) - .mockRejectedValue(diskFullError); - - await LocalOnyx.mergeCollection(collectionKey, { - [memberKey]: { value: "merged" }, - }); - - expect(LocalOnyxCache.get(memberKey)).toEqual({ id: 1, value: "merged" }); - expect(memberCalls.at(-1)).toEqual({ id: 1, value: "merged" }); - expect(memberCalls).not.toContain(undefined); - }); - }); - - describe("afterInit", () => { - beforeEach(() => { - // Resets the deferred init task before each test. - Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); - }); - - afterEach(() => { - jest.restoreAllMocks(); - return Onyx.clear(); - }); - - it("should execute the callback immediately if Onyx is already initialized", async () => { - Onyx.init({ keys: ONYXKEYS }); - await act(async () => waitForPromisesToResolve()); - - const callback = jest.fn(); - OnyxUtils.afterInit(callback); - - await act(async () => waitForPromisesToResolve()); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it("should only execute the callback after Onyx initialization", async () => { - const callback = jest.fn(); - OnyxUtils.afterInit(callback); - - await act(async () => waitForPromisesToResolve()); - - expect(callback).not.toHaveBeenCalled(); - Onyx.init({ keys: ONYXKEYS }); - await act(async () => waitForPromisesToResolve()); + beforeEach(() => Onyx.clear()); + + afterEach(() => jest.clearAllMocks()); + + describe('partialSetCollection', () => { + beforeEach(() => { + Onyx.clear(); + }); + + afterEach(() => { + Onyx.clear(); + }); + it('should replace all existing collection members with new values and keep old ones intact', async () => { + let result: OnyxCollection; + const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; + const routeB = `${ONYXKEYS.COLLECTION.ROUTES}B`; + const routeB1 = `${ONYXKEYS.COLLECTION.ROUTES}B1`; + const routeC = `${ONYXKEYS.COLLECTION.ROUTES}C`; + + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.ROUTES, + callback: (value) => (result = value), + }); + + // Set initial collection state + await Onyx.setCollection(ONYXKEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + [routeB1]: {name: 'Route B1'}, + [routeC]: {name: 'Route C'}, + }); + + // Replace with new collection data + await OnyxUtils.partialSetCollection({ + collectionKey: ONYXKEYS.COLLECTION.ROUTES, + collection: { + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + [routeC]: {name: 'New Route C'}, + }, + }); + + expect(result).toEqual({ + [routeA]: {name: 'New Route A'}, + [routeB]: {name: 'New Route B'}, + [routeB1]: {name: 'Route B1'}, + [routeC]: {name: 'New Route C'}, + }); + await Onyx.disconnect(connection); + }); + + it('should not replace anything in the collection with empty values', async () => { + let result: OnyxCollection; + const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; + + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.ROUTES, + callback: (value) => (result = value), + }); + + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + }); + + await OnyxUtils.partialSetCollection({ + collectionKey: ONYXKEYS.COLLECTION.ROUTES, + collection: {}, + }); + + expect(result).toEqual({ + [routeA]: {name: 'Route A'}, + }); + await Onyx.disconnect(connection); + }); + + it('should reject collection items with invalid keys', async () => { + let result: OnyxCollection; + const routeA = `${ONYXKEYS.COLLECTION.ROUTES}A`; + const invalidRoute = 'invalid_route'; + + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.ROUTES, + callback: (value) => (result = value), + }); + + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.ROUTES, { + [routeA]: {name: 'Route A'}, + }); + + await OnyxUtils.partialSetCollection({ + collectionKey: ONYXKEYS.COLLECTION.ROUTES, + collection: { + [invalidRoute]: {name: 'Invalid Route'}, + }, + }); + + expect(result).toEqual({ + [routeA]: {name: 'Route A'}, + }); + + await Onyx.disconnect(connection); + }); + }); + + describe('multiSetWithRetry', () => { + it('should fire collection-level callback only once per collection even with multiple members', async () => { + const collectionCallback = jest.fn(); + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: collectionCallback, + }); + + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + // multiSet with 3 members of the same collection + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: {id: 3}, + }); + + // Should be called only ONCE with the batched collection (not 3 times) + expect(collectionCallback).toHaveBeenCalledTimes(1); + const [collection] = collectionCallback.mock.calls.at(0); + expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}1`]).toEqual({id: 1}); + expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}2`]).toEqual({id: 2}); + expect(collection[`${ONYXKEYS.COLLECTION.TEST_KEY}3`]).toEqual({id: 3}); + + Onyx.disconnect(connection); + }); + + it('should fire individual member-key subscribers once per key', async () => { + const spy1 = jest.fn(); + const spy2 = jest.fn(); + const spy3 = jest.fn(); + + const conn1 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + callback: spy1, + }); + const conn2 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, + callback: spy2, + }); + const conn3 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}3`, + callback: spy3, + }); + await waitForPromisesToResolve(); + spy1.mockClear(); + spy2.mockClear(); + spy3.mockClear(); + + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: {id: 3}, + }); + + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy1).toHaveBeenCalledWith({id: 1}, `${ONYXKEYS.COLLECTION.TEST_KEY}1`); + expect(spy2).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledWith({id: 2}, `${ONYXKEYS.COLLECTION.TEST_KEY}2`); + expect(spy3).toHaveBeenCalledTimes(1); + expect(spy3).toHaveBeenCalledWith({id: 3}, `${ONYXKEYS.COLLECTION.TEST_KEY}3`); + + Onyx.disconnect(conn1); + Onyx.disconnect(conn2); + Onyx.disconnect(conn3); + }); + + it('should notify non-collection keys individually alongside batched collection updates', async () => { + const collectionCallback = jest.fn(); + const singleKeyCallback = jest.fn(); + + const connCollection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: collectionCallback, + }); + const connSingle = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: singleKeyCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + singleKeyCallback.mockClear(); + + // Mix of collection members and a non-collection key + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2}, + [ONYXKEYS.TEST_KEY]: 'standalone', + }); + + // Collection callback fires once (batched) + expect(collectionCallback).toHaveBeenCalledTimes(1); + // Non-collection key callback fires once + expect(singleKeyCallback).toHaveBeenCalledTimes(1); + expect(singleKeyCallback).toHaveBeenCalledWith('standalone', ONYXKEYS.TEST_KEY); + + Onyx.disconnect(connCollection); + Onyx.disconnect(connSingle); + }); + + it('should batch notifications per-collection when members span multiple collections', async () => { + const testCallback = jest.fn(); + const routesCallback = jest.fn(); + + const connTest = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: testCallback, + }); + const connRoutes = Onyx.connect({ + key: ONYXKEYS.COLLECTION.ROUTES, + callback: routesCallback, + }); + await waitForPromisesToResolve(); + testCallback.mockClear(); + routesCallback.mockClear(); + + // multiSet with members of two different collections + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2}, + [`${ONYXKEYS.COLLECTION.ROUTES}A`]: {name: 'A'}, + [`${ONYXKEYS.COLLECTION.ROUTES}B`]: {name: 'B'}, + }); + + // Each collection callback fires once + expect(testCallback).toHaveBeenCalledTimes(1); + expect(routesCallback).toHaveBeenCalledTimes(1); + + Onyx.disconnect(connTest); + Onyx.disconnect(connRoutes); + }); + + it('should pass previous values to keysChanged so unchanged members skip notification', async () => { + // Set initial data + const initial1 = {id: 1, name: 'A'}; + const initial2 = {id: 2, name: 'B'}; + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: initial1, + [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: initial2, + }); + + const spy1 = jest.fn(); + const spy2 = jest.fn(); + const conn1 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}1`, + callback: spy1, + }); + const conn2 = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TEST_KEY}2`, + callback: spy2, + }); + await waitForPromisesToResolve(); + spy1.mockClear(); + spy2.mockClear(); + + // multiSet: change key 1, keep key 2 with same content (but new reference) + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1, name: 'A-updated'}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: initial2, + }); + + // Key 1 subscriber fires (value changed) + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy1).toHaveBeenCalledWith({id: 1, name: 'A-updated'}, `${ONYXKEYS.COLLECTION.TEST_KEY}1`); + + // Key 2 keeps the same reference (passed as-is in multiSet) — subscriber should not fire + // because keysChanged sees the same reference as previousCollection[key] + expect(spy2).not.toHaveBeenCalled(); + + Onyx.disconnect(conn1); + Onyx.disconnect(conn2); + }); + + it('should not fire again for a collection subscriber that disconnects itself in its callback', async () => { + // A collection-root subscriber disconnects itself when it receives a + // collection object. A subsequent collection change must NOT trigger another callback. + const callback = jest.fn(); + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback, + }); + await waitForPromisesToResolve(); + callback.mockReset(); + callback.mockImplementation(() => { + Onyx.disconnect(connection); + }); + + // First batch fires the collection callback once, which disconnects the subscriber. + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TEST_KEY}1`]: {id: 1}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}2`]: {id: 2}, + [`${ONYXKEYS.COLLECTION.TEST_KEY}3`]: {id: 3}, + }); + + expect(callback).toHaveBeenCalledTimes(1); + + // A subsequent change must not fire the now-disconnected subscriber again. + await Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}1`, {id: 11}); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should keep cache and subscriber state consistent when a non-collection callback writes to another payload key', async () => { + // A subscriber for keyA synchronously calls Onyx.set() on keyB during its callback. + // After multiSet completes, the cache must reflect the multiSet's value for keyB + // (multiSet wins), and the keyB subscriber's last seen value must equal the cache. + await Onyx.multiSet({ + [ONYXKEYS.TEST_KEY]: 'initialA', + [ONYXKEYS.TEST_KEY_2]: 'initialB', + }); + + const callbackA = jest.fn((value: unknown) => { + if (value !== 'newA') { + return; + } + + // While processing the new value of keyA, write to keyB. + // keyB is later in the same multiSet payload — multiSet should win. + Onyx.set(ONYXKEYS.TEST_KEY_2, 'callbackB'); + }); + const callbackB = jest.fn(); + + const connA = Onyx.connect({ + key: ONYXKEYS.TEST_KEY, + callback: callbackA, + }); + const connB = Onyx.connect({ + key: ONYXKEYS.TEST_KEY_2, + callback: callbackB, + }); + await waitForPromisesToResolve(); + callbackA.mockClear(); + callbackB.mockClear(); + + await Onyx.multiSet({ + [ONYXKEYS.TEST_KEY]: 'newA', + [ONYXKEYS.TEST_KEY_2]: 'multiSetB', + }); + + // Cache reflects multiSet's payload value for keyB (the multiSet's later cache.set wins) + expect(OnyxCache.get(ONYXKEYS.TEST_KEY_2)).toBe('multiSetB'); + + expect(callbackB.mock.calls.length).toBe(2); + expect(callbackB.mock.calls.at(0)?.[0]).toBe('callbackB'); + // keyB subscriber's last received value matches the cache (no stale callback) + expect(callbackB.mock.calls.at(1)?.[0]).toBe('multiSetB'); + + Onyx.disconnect(connA); + Onyx.disconnect(connB); + }); + }); + + describe('keysChanged', () => { + beforeEach(() => { + Onyx.clear(); + }); + + afterEach(() => { + Onyx.clear(); + }); + + it('should call callback when data actually changes for collection member key subscribers', async () => { + const callbackSpy = jest.fn(); + const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}123`; + const connection = Onyx.connect({ + key: entryKey, + callback: callbackSpy, + }); + + const entryData = {value: 'updated_data'}; + + // Create partial collection data that includes our member key + const collection = { + [entryKey]: entryData, + } as Collection; + + // Clear the callback spy to focus on the keysChanged behavior + callbackSpy.mockClear(); + + await Onyx.setCollection(ONYXKEYS.COLLECTION.TEST_KEY, collection); + + // Verify the subscriber callback was called + expect(callbackSpy).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledWith(entryData, entryKey); + + await Onyx.disconnect(connection); + }); + + it('should set lastConnectionCallbackData for collection member key subscribers', async () => { + const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}456`; + const initialEntryData = {value: 'initial_data'}; + const updatedEntryData = {value: 'updated_data'}; + const newEntryData = {value: 'new_data'}; + const callbackSpy = jest.fn(); + + const connection = await Onyx.connect({ + key: entryKey, + callback: callbackSpy, + }); + + // Create partial collection data that includes our member key + const initialCollection = { + [entryKey]: initialEntryData, + } as Collection; + + // Clear the callback spy to focus on the keysChanged behavior + callbackSpy.mockClear(); + + OnyxUtils.keysChanged( + ONYXKEYS.COLLECTION.TEST_KEY, + {[entryKey]: updatedEntryData}, // new collection + initialCollection, // previous collection + ); + + // Should be called again because data changed + expect(callbackSpy).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledWith(undefined, entryKey); + + // Clear the callback spy to focus on the keyChanged behavior + callbackSpy.mockClear(); + + OnyxUtils.keyChanged( + entryKey, + newEntryData, // Second update with different data + () => true, // notify connect subscribers + ); + + // Should be called again because data changed + expect(callbackSpy).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledWith(newEntryData, entryKey); + + await Onyx.disconnect(connection); + }); + + it('should notify collection-level subscribers with the whole collection object', async () => { + const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}789`; + const entryData = {value: 'data'}; + + const collectionCallback = jest.fn(); + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TEST_KEY, + callback: collectionCallback, + }); + + await Onyx.set(entryKey, entryData); + collectionCallback.mockClear(); + + // Trigger keysChanged directly with a partial collection + OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[entryKey]: entryData}, {}); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + // Collection subscriber receives the full cached collection and subscriber.key + const [receivedCollection, receivedKey] = collectionCallback.mock.calls.at(0); + expect(receivedKey).toBe(ONYXKEYS.COLLECTION.TEST_KEY); + expect(receivedCollection[entryKey]).toEqual(entryData); + + Onyx.disconnect(connection); + }); + + it('should skip notification when member value has same reference in previous and current collection', async () => { + const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}same`; + const sameValue = {value: 'unchanged'}; + + await Onyx.set(entryKey, sameValue); + + const callbackSpy = jest.fn(); + const connection = Onyx.connect({ + key: entryKey, + callback: callbackSpy, + }); + await waitForPromisesToResolve(); + callbackSpy.mockClear(); + + // Simulate keysChanged where the previous and current value are the SAME reference + // (which happens with frozen snapshots when nothing changed). === should skip notification. + OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[entryKey]: sameValue}, {[entryKey]: sameValue}); + + expect(callbackSpy).not.toHaveBeenCalled(); + + Onyx.disconnect(connection); + }); + + it('should notify member subscribers only for changed keys in a batched update', async () => { + const keyA = `${ONYXKEYS.COLLECTION.TEST_KEY}A`; + const keyB = `${ONYXKEYS.COLLECTION.TEST_KEY}B`; + const keyC = `${ONYXKEYS.COLLECTION.TEST_KEY}C`; + + const dataA = {value: 'A'}; + const dataB = {value: 'B'}; + const dataC = {value: 'C'}; + + await Onyx.multiSet({[keyA]: dataA, [keyB]: dataB, [keyC]: dataC}); + + const spyA = jest.fn(); + const spyB = jest.fn(); + const spyC = jest.fn(); + const connA = Onyx.connect({key: keyA, callback: spyA}); + const connB = Onyx.connect({key: keyB, callback: spyB}); + const connC = Onyx.connect({key: keyC, callback: spyC}); + await waitForPromisesToResolve(); + spyA.mockClear(); + spyB.mockClear(); + spyC.mockClear(); + + // Update cache so keysChanged reads the new values via getCachedCollection + const newA = {value: 'A-updated'}; + const newC = {value: 'C-updated'}; + OnyxCache.set(keyA, newA); + OnyxCache.set(keyC, newC); + // keyB stays the same reference + + OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[keyA]: newA, [keyB]: dataB, [keyC]: newC}, {[keyA]: dataA, [keyB]: dataB, [keyC]: dataC}); + + expect(spyA).toHaveBeenCalledTimes(1); + expect(spyB).not.toHaveBeenCalled(); + expect(spyC).toHaveBeenCalledTimes(1); + + Onyx.disconnect(connA); + Onyx.disconnect(connB); + Onyx.disconnect(connC); + }); + + it('should catch errors thrown by subscriber callbacks and continue notifying others', async () => { + const entryKey = `${ONYXKEYS.COLLECTION.TEST_KEY}errorTest`; + const entryData = {value: 'data'}; + + await Onyx.set(entryKey, entryData); + + const failingCallback = jest.fn(); + const workingCallback = jest.fn(); + + const connFailing = Onyx.connect({ + key: entryKey, + callback: failingCallback, + reuseConnection: false, + }); + const connWorking = Onyx.connect({ + key: entryKey, + callback: workingCallback, + reuseConnection: false, + }); + await waitForPromisesToResolve(); + failingCallback.mockReset(); + failingCallback.mockImplementation(() => { + throw new Error('subscriber failure'); + }); + workingCallback.mockClear(); + + // Spy on Logger to verify the error is logged + const logSpy = jest.spyOn(Logger, 'logAlert').mockImplementation(() => undefined); + + const newData = {value: 'new'}; + // Update the cache so keysChanged sees the new value as different from previous + OnyxCache.set(entryKey, newData); + OnyxUtils.keysChanged(ONYXKEYS.COLLECTION.TEST_KEY, {[entryKey]: newData}, {[entryKey]: entryData}); + + // Both callbacks should have been attempted; error should be logged + expect(failingCallback).toHaveBeenCalled(); + expect(workingCallback).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalled(); + + logSpy.mockRestore(); + Onyx.disconnect(connFailing); + Onyx.disconnect(connWorking); + }); + }); + + describe('mergeChanges', () => { + it("should return the last change if it's an array", () => { + const {result} = OnyxUtils.mergeChanges([...testMergeChanges, [0, 1, 2]], testObject); + + expect(result).toEqual([0, 1, 2]); + }); + + it("should return the last change if the changes aren't objects", () => { + const {result} = OnyxUtils.mergeChanges(['a', 0, 'b', 1], testObject); + + expect(result).toEqual(1); + }); + + it('should merge data correctly when applying batched changes', () => { + const batchedChanges: GenericDeepRecord = { + b: { + d: { + i: 'i', + j: 'j', + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, + }, + h: 'h', + g: { + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, + k: 'k', + }, + }, + }; + + const {result} = OnyxUtils.mergeChanges([batchedChanges], testObject); + + expect(result).toEqual({ + a: 'a', + b: { + c: 'c', + d: { + i: 'i', + j: 'j', + }, + h: 'h', + g: { + k: 'k', + }, + }, + }); + }); + }); + + describe('mergeAndMarkChanges', () => { + it('should apply the replacement markers if we have properties with objects being removed and added back during the changes', () => { + const {result, replaceNullPatches} = OnyxUtils.mergeAndMarkChanges(testMergeChanges); + + expect(result).toEqual({ + b: { + d: { + i: 'i', + j: 'j', + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, + }, + h: 'h', + g: { + [utils.ONYX_INTERNALS_REPLACE_OBJECT_MARK]: true, + k: 'k', + }, + }, + }); + expect(replaceNullPatches).toEqual([ + [['b', 'd'], {i: 'i'}], + [['b', 'd'], {i: 'i', j: 'j'}], + [['b', 'g'], {k: 'k'}], + ]); + }); + }); + + describe('retryOperation', () => { + const retryOperationSpy = jest.spyOn(OnyxUtils, 'retryOperation'); + /** Mirrors StorageCircuitBreaker rolling-window trip threshold. */ + const STORAGE_FAILURE_THRESHOLD = 50; + const genericError = new Error('Generic storage error'); + const invalidDataError = new Error("Failed to execute 'put' on 'IDBObjectStore': invalid data"); + const diskFullError = new Error('database or disk is full'); + const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'}); + + // The circuit breaker is process-scoped, so reset it between tests to avoid state leaking. + beforeEach(() => StorageCircuitBreaker.reset()); + + it('should retry only one time if the operation is firstly failed and then passed', async () => { + StorageMock.setItem = jest.fn(StorageMock.setItem).mockRejectedValueOnce(genericError).mockImplementation(StorageMock.setItem); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // Should be called once, since Storage.setItem if failed only once + expect(retryOperationSpy).toHaveBeenCalledTimes(1); + }); + + it('should stop retrying after MAX_STORAGE_OPERATION_RETRY_ATTEMPTS retries for failing operation', async () => { + StorageMock.setItem = jest.fn().mockRejectedValue(genericError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // Should be called 6 times: initial attempt + 5 retries (MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) + expect(retryOperationSpy).toHaveBeenCalledTimes(6); + }); + + it('should log the full shape of an unclassified (UNKNOWN) error once per operation', async () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert'); + StorageMock.setItem = jest.fn().mockRejectedValue(genericError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // UNKNOWN is instrumented so we can see what to promote into a real class. The shape (provider + // + name + message) is logged exactly once even though the operation retries 6 times. + const unclassifiedCalls = logAlertSpy.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith('Unclassified storage error.')); + expect(unclassifiedCalls).toHaveLength(1); + expect(unclassifiedCalls.at(0)[0]).toBe( + `Unclassified storage error. provider: MemoryOnlyProvider. name: ${genericError.name}. message: ${genericError.message}. onyxMethod: setWithRetry.`, + ); + }); + + it("should throw error for if operation failed with \"Failed to execute 'put' on 'IDBObjectStore': invalid data\" error", async () => { + StorageMock.setItem = jest.fn().mockRejectedValueOnce(invalidDataError); + + await expect(Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'})).rejects.toThrow(invalidDataError); + }); + + it('should not retry in case of storage capacity error and no keys to evict', async () => { + StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // Should only be called once since there are no evictable keys + expect(retryOperationSpy).toHaveBeenCalledTimes(1); + }); + + it('should not retry for non-retriable IndexedDB backing-store errors', async () => { + StorageMock.setItem = jest.fn().mockRejectedValue(nonRetriableIdbError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // Called once (initial attempt only) -- no recursion, unlike the 6 calls for generic errors + expect(retryOperationSpy).toHaveBeenCalledTimes(1); + }); + + it('should skip retry quietly (info, not alert) for fatal connection-layer errors', async () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert'); + const logInfoSpy = jest.spyOn(Logger, 'logInfo'); + StorageMock.setItem = jest.fn().mockRejectedValue(nonRetriableIdbError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // The connection layer (createStore) owns and alerts on fatal errors; the operation layer + // just skips the retry at info level. No alert here, and no "5 retries exhausted" alert. + expect(logInfoSpy).toHaveBeenCalledWith( + `Storage operation skipped retry; fatal errors are handled by the connection layer. Error: ${nonRetriableIdbError}. onyxMethod: setWithRetry.`, + ); + expect(logAlertSpy).not.toHaveBeenCalled(); + }); + + it('should include the error in logAlert for IDBObjectStore invalid data errors', async () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert'); + StorageMock.setItem = jest.fn().mockRejectedValueOnce(invalidDataError); + + await expect(Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'})).rejects.toThrow(invalidDataError); + + expect(logAlertSpy).toHaveBeenCalledWith(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${invalidDataError}`); + }); + + it('should include the error in logs when out of storage with no evictable keys', async () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert'); + const logInfoSpy = jest.spyOn(Logger, 'logInfo'); + StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + expect(logAlertSpy).toHaveBeenCalledWith(`Out of storage. But found no acceptable keys to remove. Error: ${diskFullError}`); + expect(logInfoSpy).toHaveBeenCalledWith( + `Storage Quota Check -- bytesUsed: 0 originWideBytesRemaining (estimate, not per-DB headroom): Infinity. Original error: ${diskFullError}`, + ); + }); + + it('should include usageDetails in the storage quota log when available', async () => { + const logInfoSpy = jest.spyOn(Logger, 'logInfo'); + const usageDetails = { + caches: 1500160, + fileSystem: 1369398, + indexedDB: 10419711, + }; + StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); + StorageMock.getDatabaseSize = jest.fn().mockResolvedValue({ + bytesUsed: 13289269, + bytesRemaining: 5000000, + usageDetails, + }); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + expect(logInfoSpy).toHaveBeenCalledWith( + `Storage Quota Check -- bytesUsed: 13289269 originWideBytesRemaining (estimate, not per-DB headroom): 5000000 usageDetails: ${JSON.stringify( + usageDetails, + )}. Original error: ${diskFullError}`, + ); + }); + + it('should include the error in logAlert when out of storage and getDatabaseSize fails', async () => { + const dbSizeError = new Error('Failed to estimate storage'); + const logAlertSpy = jest.spyOn(Logger, 'logAlert'); + StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); + StorageMock.getDatabaseSize = jest.fn().mockRejectedValue(dbSizeError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + expect(logAlertSpy).toHaveBeenCalledWith(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${diskFullError}`); + }); + + it('should trip the circuit breaker and alert once after sustained capacity failures', async () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert'); + StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); + + // No evictable keys are configured, so each failing write records exactly one capacity + // failure with the breaker (it cannot evict). Enough of them within one window trips it. + for (let i = 0; i <= STORAGE_FAILURE_THRESHOLD; i++) { + await Onyx.set(ONYXKEYS.TEST_KEY, {test: i}); + } + await waitForPromisesToResolve(); + + expect(StorageCircuitBreaker.isAllowed()).toBe(false); + expect(logAlertSpy).toHaveBeenCalledWith(expect.stringContaining('Storage circuit breaker tripped')); + }); + + it('should drop capacity writes silently while the circuit breaker is open', async () => { + // Trip the breaker deterministically so every capacity failure below is observed while open. + for (let i = 0; i <= STORAGE_FAILURE_THRESHOLD; i++) { + StorageCircuitBreaker.recordCapacityFailure(); + } + expect(StorageCircuitBreaker.isAllowed()).toBe(false); + + // Clear so we only observe logging caused by the write below, not the trip alert above. + const logInfoSpy = jest.spyOn(Logger, 'logInfo').mockClear(); + const logAlertSpy = jest.spyOn(Logger, 'logAlert').mockClear(); + StorageMock.setItem = jest.fn().mockRejectedValue(diskFullError); + + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + await waitForPromisesToResolve(); + + // The write and any cascading derived writes are dropped without per-write log spam, and + // without re-alerting — the single trip alert is the only signal while open. + expect(StorageCircuitBreaker.isAllowed()).toBe(false); + expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Failed to save to storage')); + expect(logAlertSpy).not.toHaveBeenCalled(); + }); + + it('should not re-add an evicted key to recentlyAccessedKeys after removal', async () => { + // Re-init with evictable keys so getKeyForEviction() has something to return + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.TEST_KEY], + }); + await waitForPromisesToResolve(); + + const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + + await Onyx.set(evictableKey, {id: 1}); + expect(OnyxCache.getKeyForEviction()).toBe(evictableKey); + + await OnyxUtils.remove(evictableKey); + expect(OnyxCache.getKeyForEviction()).toBeUndefined(); + }); + }); + + describe('mergeCollection cache-first ordering', () => { + // Save originals so we can restore them after each test. The tests below replace + // StorageMock.multiMerge / StorageMock.multiSet with rejecting mocks; without + // restoring, the mock leaks into later describe blocks (e.g. eviction tests) whose + // setup relies on these storage methods working normally. + const originalMultiMerge = StorageMock.multiMerge; + const originalMultiSet = StorageMock.multiSet; + + afterEach(() => { + StorageMock.multiMerge = originalMultiMerge; + StorageMock.multiSet = originalMultiSet; + }); + + it('updates cache and notifies subscribers even when Storage.multiMerge rejects', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const existingMemberKey = `${collectionKey}1`; + const newMemberKey = `${collectionKey}2`; + + // Seed an existing member so the merge path exercises multiMerge (existing) + multiSet (new) + await Onyx.set(existingMemberKey, {value: 'initial'}); + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + // Force Storage.multiMerge to reject with a non-retriable IDB error so the failure + // path is taken without burning the full retry budget and without rejecting the + // outer Onyx.mergeCollection promise. + const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'}); + StorageMock.multiMerge = jest.fn().mockRejectedValue(nonRetriableIdbError); + + await Onyx.mergeCollection(collectionKey, { + [existingMemberKey]: {value: 'merged'}, + [newMemberKey]: {value: 'new'}, + }); + + // Cache must reflect the merge regardless of the multiMerge rejection. This is the + // cache-first / storage-second invariant that mergeCollectionWithPatches must honor. + const cachedCollection = OnyxCache.getCollectionData(collectionKey); + expect(cachedCollection?.[existingMemberKey]).toEqual({ + value: 'merged', + }); + expect(cachedCollection?.[newMemberKey]).toEqual({value: 'new'}); + + // Subscribers must have been notified with the merged values. + expect(collectionCallback).toHaveBeenCalled(); + const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record | undefined; + expect(lastBroadcast?.[existingMemberKey]).toEqual({value: 'merged'}); + expect(lastBroadcast?.[newMemberKey]).toEqual({value: 'new'}); + }); + + it('updates cache and notifies subscribers even when Storage.multiSet rejects', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const newMemberKey1 = `${collectionKey}1`; + const newMemberKey2 = `${collectionKey}2`; + + // No keys are seeded, so every merged key is a "new" key. This forces the merge path + // to use Storage.multiSet (existing keys would go through Storage.multiMerge). + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + // Force Storage.multiSet to reject with a non-retriable IDB error so the failure + // path is taken without burning the full retry budget and without rejecting the + // outer Onyx.mergeCollection promise. + const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'}); + StorageMock.multiSet = jest.fn().mockRejectedValue(nonRetriableIdbError); + + await Onyx.mergeCollection(collectionKey, { + [newMemberKey1]: {value: 'first'}, + [newMemberKey2]: {value: 'second'}, + }); + + // Cache must reflect the merge regardless of the multiSet rejection. This is the + // cache-first / storage-second invariant that mergeCollectionWithPatches must honor. + const cachedCollection = OnyxCache.getCollectionData(collectionKey); + expect(cachedCollection?.[newMemberKey1]).toEqual({value: 'first'}); + expect(cachedCollection?.[newMemberKey2]).toEqual({value: 'second'}); + + // Subscribers must have been notified with the merged values. + expect(collectionCallback).toHaveBeenCalled(); + const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record | undefined; + expect(lastBroadcast?.[newMemberKey1]).toEqual({value: 'first'}); + expect(lastBroadcast?.[newMemberKey2]).toEqual({value: 'second'}); + }); + }); + + describe('retry side-effect idempotency', () => { + // Save originals so each test can replace StorageMock.multiMerge / StorageMock.multiSet + // with a one-shot rejecting mock that triggers retryOperation's transient-error path. + // Restoring keeps mocks from leaking into the storage-eviction describe block below. + const originalMultiMerge = StorageMock.multiMerge; + const originalMultiSet = StorageMock.multiSet; + + afterEach(() => { + StorageMock.multiMerge = originalMultiMerge; + StorageMock.multiSet = originalMultiSet; + }); + + // A retriable error: not in NON_RETRIABLE_ERRORS, not in STORAGE_ERRORS, so retryOperation + // re-enters the failing method on the next attempt. + const transientError = new Error('Transient storage error'); + + it('mergeCollection — collection-root subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const existingMemberKey = `${collectionKey}1`; + const newMemberKey = `${collectionKey}2`; + + await Onyx.set(existingMemberKey, {value: 'initial'}); + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiMerge = jest.fn(originalMultiMerge).mockRejectedValueOnce(transientError); + + await Onyx.mergeCollection(collectionKey, { + [existingMemberKey]: {value: 'merged'}, + [newMemberKey]: {value: 'new'}, + }); + + // Before this fix, every retry attempt re-fired keysChanged() — and + // Collection-root subscribers fire on every keysChanged() call by contract. + // After the fix, retries skip the keysChanged re-fire, so subscribers are notified + // exactly once per logical operation. + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + + it('Onyx.multiSet — collection subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey1 = `${collectionKey}1`; + const memberKey2 = `${collectionKey}2`; + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError); + + await Onyx.multiSet({ + [memberKey1]: {value: 'first'}, + [memberKey2]: {value: 'second'}, + }); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + + it('Onyx.setCollection — collection subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey1 = `${collectionKey}1`; + const memberKey2 = `${collectionKey}2`; + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError); + + await Onyx.setCollection(collectionKey, { + [memberKey1]: {value: 'first'}, + [memberKey2]: {value: 'second'}, + }); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + + it('OnyxUtils.partialSetCollection — collection subscriber fires once across retries', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey1 = `${collectionKey}1`; + const memberKey2 = `${collectionKey}2`; + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + StorageMock.multiSet = jest.fn(originalMultiSet).mockRejectedValueOnce(transientError); + + await OnyxUtils.partialSetCollection({ + collectionKey, + collection: { + [memberKey1]: {value: 'first'}, + [memberKey2]: {value: 'second'}, + }, + }); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('mergeCollection pre-warm', () => { + // retryOperation tests above replace StorageMock methods without restoring them, leaving + // rejecting mocks behind. Capture pristine refs at file-load time and restore in beforeEach + // so our Onyx.set seeding actually reaches the in-memory storage provider. + const pristineSetItem = StorageMock.setItem; + const pristineMultiSet = StorageMock.multiSet; + const pristineMultiGet = StorageMock.multiGet; + const pristineGetItem = StorageMock.getItem; + const pristineMultiMerge = StorageMock.multiMerge; + + beforeEach(() => { + StorageMock.setItem = pristineSetItem; + StorageMock.multiSet = pristineMultiSet; + StorageMock.multiGet = pristineMultiGet; + StorageMock.getItem = pristineGetItem; + StorageMock.multiMerge = pristineMultiMerge; + }); + + // Make a key "cold" — value evicted from cache but still tracked as persisted. OnyxCache.drop + // also removes the key from `storageKeys`, so we re-register it afterwards to reliably hit + // the cold-but-persisted state regardless of getAllKeys()'s fallback path. + const evictFromCache = (...keys: string[]) => { + for (const key of keys) { + OnyxCache.drop(key); + OnyxCache.addKey(key); + } + }; + + it('fast path: skips storage reads entirely when every existing key is warm in cache', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const existingKey1 = `${collectionKey}1`; + const existingKey2 = `${collectionKey}2`; + + // Seed both members so they are both warm in cache and present in storage. + await Onyx.set(existingKey1, {value: 'initial-1'}); + await Onyx.set(existingKey2, {value: 'initial-2'}); + + const multiGetSpy = jest.spyOn(StorageMock, 'multiGet'); + const getItemSpy = jest.spyOn(StorageMock, 'getItem'); + + await Onyx.mergeCollection(collectionKey, { + [existingKey1]: {value: 'merged-1'}, + [existingKey2]: {value: 'merged-2'}, + }); + + // With every existingKey warm, the diff swaps Promise.all(get) for Promise.resolve(), + // so no storage reads should happen during the pre-warm. + expect(multiGetSpy).not.toHaveBeenCalled(); + expect(getItemSpy).not.toHaveBeenCalled(); + + // Cache still reflects the merge. + const cached = OnyxCache.getCollectionData(collectionKey); + expect(cached?.[existingKey1]).toEqual({value: 'merged-1'}); + expect(cached?.[existingKey2]).toEqual({value: 'merged-2'}); + }); + + it('slow path: batches cold existing keys into a single Storage.multiGet, with no individual getItem calls', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const coldKey1 = `${collectionKey}1`; + const coldKey2 = `${collectionKey}2`; + const warmKey = `${collectionKey}3`; + + // Seed all three in storage, then evict two from cache so they are cold-but-persisted. + await Onyx.set(coldKey1, {value: 'persisted-1'}); + await Onyx.set(coldKey2, {value: 'persisted-2'}); + await Onyx.set(warmKey, {value: 'persisted-3'}); + evictFromCache(coldKey1, coldKey2); + + // Reset spies AFTER seeding so we only count calls made during mergeCollection itself. + const multiGetSpy = jest.spyOn(StorageMock, 'multiGet').mockClear(); + const getItemSpy = jest.spyOn(StorageMock, 'getItem').mockClear(); + + await Onyx.mergeCollection(collectionKey, { + [coldKey1]: {value: 'merged-1'}, + [coldKey2]: {value: 'merged-2'}, + [warmKey]: {value: 'merged-3'}, + }); + + // OnyxUtils.multiGet filters to cache-missing keys before issuing Storage.multiGet, so we + // expect exactly one batched read containing only the cold keys (the warm key is skipped). + expect(multiGetSpy).toHaveBeenCalledTimes(1); + const requestedKeys = multiGetSpy.mock.calls.at(0)[0]; + expect(requestedKeys.sort()).toEqual([coldKey1, coldKey2].sort()); + + // No individual Storage.getItem calls during pre-warm. Old code path would have fired one + // get() per existing key, each potentially landing in Storage.getItem on cache miss. + expect(getItemSpy).not.toHaveBeenCalled(); + }); + + it('slow path: cold-cache merge layers the new delta on top of existing storage data (no field drops)', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const coldKey = `${collectionKey}1`; + + // Seed an object with multiple fields in storage, then evict from cache so the merge base + // must come from a storage read — not from `undefined`. + await Onyx.set(coldKey, {a: 1, b: 2}); + evictFromCache(coldKey); + + await Onyx.mergeCollection(collectionKey, { + [coldKey]: {c: 3}, + }); + + // If the pre-warm did NOT populate the cache from storage, fastMerge would treat the + // previous value as undefined and the result would drop {a:1, b:2}. With the pre-warm + // running multiGet on the cold key, the merge layers {c:3} on top of {a:1, b:2}. + const cached = OnyxCache.getCollectionData(collectionKey); + expect(cached?.[coldKey]).toEqual({a: 1, b: 2, c: 3}); + }); + + it('warm cache: subscriber receives a single merged broadcast for an Onyx.update batch (no transient undefined)', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const existingKey = `${collectionKey}1`; + + await Onyx.set(existingKey, {value: 'initial'}); + + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + await Onyx.update([ + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: collectionKey, + value: { + [existingKey]: {value: 'merged'}, + }, + }, + ]); + + // The fast path resolves the pre-warm synchronously (Promise.resolve()), preserving the + // original promise-chain depth. The Onyx.update batch must therefore broadcast exactly + // one merged value — not undefined first and the merged value on a later microtask. + const broadcasts = collectionCallback.mock.calls.map((c) => c[0]); + expect(broadcasts).toHaveLength(1); + expect(broadcasts.at(0)?.[existingKey]).toEqual({value: 'merged'}); + }); + + it('equivalence: warm-path and cold-path produce the same final cache state for the same merge', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey = `${collectionKey}1`; + const delta = {value: 'after'} as const; + + // Warm-path run. + await Onyx.set(memberKey, {value: 'before', extra: 'kept'}); + await Onyx.mergeCollection(collectionKey, { + [memberKey]: delta, + }); + const warmResult = OnyxCache.getCollectionData(collectionKey)?.[memberKey]; + + // Reset and replay with a cold cache before the merge. + await Onyx.clear(); + await Onyx.set(memberKey, {value: 'before', extra: 'kept'}); + evictFromCache(memberKey); + await Onyx.mergeCollection(collectionKey, { + [memberKey]: delta, + }); + const coldResult = OnyxCache.getCollectionData(collectionKey)?.[memberKey]; + + expect(warmResult).toEqual(coldResult); + expect(coldResult).toEqual({value: 'after', extra: 'kept'}); + }); + + it('preserves cache-first invariant when Storage.multiGet rejects on the slow path', async () => { + // A Storage.multiGet rejection during pre-warm must not skip the cache.merge() + + // keysChanged() that follow. Without the .catch() at the pre-warm call site, + // subscribers would miss the merge and Onyx.mergeCollection would reject. + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const coldMemberKey = `${collectionKey}1`; + const newMemberKey = `${collectionKey}2`; + + // Seed an existing member, then evict it from cache so it's "tracked but unloaded" — + // the slow path will try to multiGet it. + await Onyx.set(coldMemberKey, {value: 'persisted'}); + evictFromCache(coldMemberKey); + + // Connect and flush the subscriber's initial load BEFORE installing the rejecting mock — + // otherwise the connect's own multiGet (no .catch) consumes the mockRejectedValueOnce and + // leaks an unhandled rejection instead of exercising the merge pre-warm path. + const collectionCallback = jest.fn(); + Onyx.connect({ + key: collectionKey, + callback: collectionCallback, + }); + await waitForPromisesToResolve(); + collectionCallback.mockClear(); + + // The subscriber's connect re-populated cache, so re-evict to force the merge into + // the slow (cold-key) path. Then reject the next Storage.multiGet so the pre-warm + // read fails. + evictFromCache(coldMemberKey); + const transientError = new Error('Transient IndexedDB read error'); + StorageMock.multiGet = jest.fn(pristineMultiGet).mockRejectedValueOnce(transientError); + + // Outer promise must resolve, not reject, even when the pre-warm read fails. + let outerRejected: unknown = null; + const result = await Onyx.mergeCollection(collectionKey, { + [coldMemberKey]: {merged: true}, + [newMemberKey]: {value: 'new'}, + }).catch((e: unknown) => { + outerRejected = e; + }); + expect(outerRejected).toBeNull(); + expect(result).toBeUndefined(); + + // cache.merge() + keysChanged() must still fire so subscribers see the merge. Use + // toMatchObject because a concurrent read may have re-populated the persisted value; + // what matters is that the new {merged: true} delta is applied on top. + expect(collectionCallback).toHaveBeenCalled(); + const lastBroadcast = collectionCallback.mock.calls.at(-1)?.[0] as Record | undefined; + expect(lastBroadcast?.[coldMemberKey]).toMatchObject({merged: true}); + expect(lastBroadcast?.[newMemberKey]).toEqual({value: 'new'}); + }); + }); + + describe('multiGet cache hit consistency', () => { + // Same suite-pollution guard as the pre-warm block above — capture pristine StorageMock + // refs at file-load time and restore them in beforeEach, since retryOperation tests leak. + const pristineSetItem = StorageMock.setItem; + const pristineMultiGet = StorageMock.multiGet; + const pristineGetItem = StorageMock.getItem; + + beforeEach(() => { + StorageMock.setItem = pristineSetItem; + StorageMock.multiGet = pristineMultiGet; + StorageMock.getItem = pristineGetItem; + }); + + it('does not re-fetch a cached falsy value from storage', async () => { + const falsyKey = ONYXKEYS.TEST_KEY; + + // Seed cache with the falsy value 0 (a number, but the same logic applies to '', + // false, and null). Using `Onyx.set` ensures the value lands in cache and storage. + await Onyx.set(falsyKey, 0); + + // Spy on Storage methods to confirm multiGet does NOT round-trip to storage for + // the cached falsy value. + const multiGetSpy = jest.spyOn(StorageMock, 'multiGet'); + const getItemSpy = jest.spyOn(StorageMock, 'getItem'); + + const result = await OnyxUtils.multiGet([falsyKey]); + + // The cached value must be returned without any storage read. Before this fix, + // `if (cacheValue)` treated the cached 0 as a miss and triggered Storage.multiGet, + // which would then overwrite the warm value via cache.merge(). + expect(multiGetSpy).not.toHaveBeenCalled(); + expect(getItemSpy).not.toHaveBeenCalled(); + expect(result.get(falsyKey)).toBe(0); + }); + + it('prefers cache when a concurrent write lands during the storage read', async () => { + // Concurrent write during multiGet's storage read must not be overwritten by the + // stale snapshot via cache.merge. + const key = `${ONYXKEYS.COLLECTION.TEST_KEY}race`; + + OnyxCache.drop(key); + OnyxCache.addKey(key); + + // Set cache inside the mock so it lands before Storage.multiGet's promise resolves — + // multiGet's .then() then sees a populated cache and skips writing the stale value. + StorageMock.multiGet = jest.fn().mockImplementation(() => { + OnyxCache.set(key, {fresh: 'data'}); + return Promise.resolve([[key, {stale: 'a', alsoStale: 'b'}]]); + }); + + const result = await OnyxUtils.multiGet([key]); + + expect(OnyxCache.get(key)).toEqual({fresh: 'data'}); + expect(result.get(key)).toEqual({fresh: 'data'}); + }); + }); + + describe('storage eviction', () => { + const diskFullError = new Error('database or disk is full'); + + // Use local references that get fresh instances after jest.resetModules() + let LocalOnyx: typeof Onyx; + let LocalOnyxUtils: typeof OnyxUtils; + let LocalOnyxCache: typeof OnyxCache; + let LocalStorageMock: typeof StorageMock; + let LocalLogger: typeof Logger; + + // Reset all modules to get fresh singletons (OnyxCache, OnyxUtils, etc.) + // then re-init Onyx with evictableKeys configured + beforeEach(async () => { + jest.resetModules(); + + LocalOnyx = require('../../lib').default; + LocalOnyxUtils = require('../../lib/OnyxUtils').default; + LocalOnyxCache = require('../../lib/OnyxCache').default; + LocalStorageMock = require('../../lib/storage').default; + LocalLogger = require('../../lib/Logger'); + + LocalOnyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.TEST_KEY], + }); + await waitForPromisesToResolve(); + }); + + it('should evict the least recently accessed evictable key on storage capacity error and retry successfully', async () => { + const key1 = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + const key2 = `${ONYXKEYS.COLLECTION.TEST_KEY}2`; + + await LocalOnyx.set(key1, {id: 1}); + await LocalOnyx.set(key2, {id: 2}); + expect(LocalOnyxCache.hasCacheForKey(key1)).toBe(true); + expect(LocalOnyxCache.hasCacheForKey(key2)).toBe(true); + + // Fail once with capacity error, then succeed + LocalStorageMock.setItem = jest.fn(LocalStorageMock.setItem).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.setItem); + + await LocalOnyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + // key1 was least recently accessed, so it should have been evicted + expect(LocalOnyxCache.hasCacheForKey(key1)).toBe(false); + // key2 was more recently accessed, so it should still be in cache + expect(LocalOnyxCache.hasCacheForKey(key2)).toBe(true); + // The write that triggered the error should have succeeded on retry + expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({test: 'data'}); + }); + + it('should recover via a half-open eviction+retry probe after the open window clears', async () => { + // Regression for the bug where the capacity failure that triggers the half-open probe + // re-tripped the breaker before the eviction+retry could run — permanently disabling + // eviction-based recovery after the first trip. Drive the whole probe through retryOperation. + const ROLLING_WINDOW_MS = 60_000; + const FAILURE_THRESHOLD = 50; + const LocalStorageCircuitBreaker = require('../../lib/StorageCircuitBreaker').default as typeof StorageCircuitBreaker; + + let currentTime = 1_000_000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => currentTime); + + // Seed an evictable key so the probe has something to evict, then trip the breaker open. + const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + await LocalOnyx.set(evictableKey, {id: 1}); + for (let i = 0; i <= FAILURE_THRESHOLD; i++) { + LocalStorageCircuitBreaker.recordCapacityFailure(); + } + expect(LocalStorageCircuitBreaker.isAllowed()).toBe(false); + + // Let the open window clear so the next capacity write is admitted as the half-open probe. + currentTime += ROLLING_WINDOW_MS; + + // The probe write fails once with capacity (triggering the probe), then its post-eviction retry succeeds. + LocalStorageMock.setItem = jest.fn(LocalStorageMock.setItem).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.setItem); + await LocalOnyx.set(ONYXKEYS.TEST_KEY, {test: 'recovered'}); + await waitForPromisesToResolve(); + + // The probe ran: the evictable key was evicted, the write landed, and the successful retry closed the circuit. + expect(LocalOnyxCache.hasCacheForKey(evictableKey)).toBe(false); + expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({ + test: 'recovered', + }); + expect(LocalStorageCircuitBreaker.isAllowed()).toBe(true); + expect(LocalStorageCircuitBreaker.isAllowed()).toBe(true); + + nowSpy.mockRestore(); + }); + + it('should evict the least recently accessed key first (LRU order)', async () => { + const key1 = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + const key2 = `${ONYXKEYS.COLLECTION.TEST_KEY}2`; + const key3 = `${ONYXKEYS.COLLECTION.TEST_KEY}3`; + + // Set in order: key1, key2, key3 + await LocalOnyx.set(key1, {id: 1}); + await LocalOnyx.set(key2, {id: 2}); + await LocalOnyx.set(key3, {id: 3}); + + // Now access key1 again so it becomes most recent + await LocalOnyx.merge(key1, {id: 1, updated: true}); + + // LRU order should now be: key2 (least recent), key3, key1 (most recent) + expect(LocalOnyxCache.getKeyForEviction()).toBe(key2); + }); + + it('should not evict non-evictable keys', async () => { + const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + + await LocalOnyx.set(evictableKey, {id: 1}); + await LocalOnyx.set(ONYXKEYS.TEST_KEY, {test: 'not evictable'}); + + // The evictable key should be a candidate for eviction + expect(LocalOnyxCache.isEvictableKey(evictableKey)).toBe(true); + // The non-evictable key should NOT be a candidate + expect(LocalOnyxCache.isEvictableKey(ONYXKEYS.TEST_KEY)).toBe(false); + + // Evict it + await LocalOnyxUtils.remove(evictableKey); + + // No more evictable candidates + expect(LocalOnyxCache.getKeyForEviction()).toBeUndefined(); + // Non-evictable key should still be in cache + expect(LocalOnyxCache.get(ONYXKEYS.TEST_KEY)).toEqual({ + test: 'not evictable', + }); + }); + + it('should not add collection keys to eviction candidates, only their members', async () => { + const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + + await LocalOnyx.set(memberKey, {id: 1}); + + // The member key should be evictable + expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); + + // Attempting to add the collection key directly should be filtered out + LocalOnyxCache.addLastAccessedKey(ONYXKEYS.COLLECTION.TEST_KEY, true); + + // Should still return the member key, not the collection key + expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); + }); + + it('should seed evictable keys from storage at init', async () => { + // Set up storage with pre-existing evictable keys before init + jest.resetModules(); + + LocalOnyx = require('../../lib').default; + LocalOnyxCache = require('../../lib/OnyxCache').default; + const storage = require('../../lib/storage').default; + + await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre1`, { + id: 'pre1', + }); + await storage.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}pre2`, { + id: 'pre2', + }); + + // Init — addEvictableKeysToRecentlyAccessedList should seed them + LocalOnyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.TEST_KEY], + }); + await waitForPromisesToResolve(); + + // Pre-existing keys should be available for eviction without being explicitly accessed + const keyForEviction = LocalOnyxCache.getKeyForEviction(); + expect(keyForEviction).toBeDefined(); + expect(keyForEviction?.startsWith(ONYXKEYS.COLLECTION.TEST_KEY)).toBe(true); + }); + + it('should include the error in logs when evicting a key', async () => { + const logInfoSpy = jest.spyOn(LocalLogger, 'logInfo'); + const key1 = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + + await LocalOnyx.set(key1, {id: 1}); + + LocalStorageMock.setItem = jest.fn(LocalStorageMock.setItem).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.setItem); + + await LocalOnyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + + expect(logInfoSpy).toHaveBeenCalledWith(`Out of storage. Evicting least recently accessed key (${key1}) and retrying. Error: ${diskFullError}`); + expect(logInfoSpy).toHaveBeenCalledWith( + `Storage Quota Check -- bytesUsed: 0 originWideBytesRemaining (estimate, not per-DB headroom): Infinity. Original error: ${diskFullError}`, + ); + }); + + it('multiSet — eviction of an UNRELATED key still notifies its subscribers (codex regression guard)', async () => { + const evictableKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + const writeKey = `${ONYXKEYS.COLLECTION.TEST_KEY}2`; + + // Seed the evictable key first so it becomes the LRU evictable. The subsequent multiSet + // writes a DIFFERENT key, so the evicted key is unrelated to the in-flight write. + await LocalOnyx.set(evictableKey, {value: 'will-be-evicted'}); + expect(LocalOnyxCache.getKeyForEviction()).toBe(evictableKey); + + const subscriberCalls: unknown[] = []; + LocalOnyx.connect({ + key: evictableKey, + callback: (value) => subscriberCalls.push(value), + }); + await waitForPromisesToResolve(); + subscriberCalls.length = 0; + + // Storage.multiSet rejects once with disk-full, then succeeds on retry. + LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet); + + await LocalOnyx.multiSet({[writeKey]: {value: 'new'}}); + + // evictableKey was the LRU evictable, so retryOperation evicted it. It's not in the + // in-flight write's keys, so the retry's cache.set won't restore it — subscribers MUST + // see keyChanged(undefined) so they reflect the genuine removal (not stale value). + expect(LocalOnyxCache.hasCacheForKey(evictableKey)).toBe(false); + expect(subscriberCalls.at(-1)).toBeUndefined(); + }); + + it('multiSet — eviction of an IN-FLIGHT key does not strand its subscriber', async () => { + const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}1`; + + // Seed memberKey so it becomes the LRU evictable. The multiSet below writes to the SAME + // key, so eviction picks an in-flight key. + await LocalOnyx.set(memberKey, {value: 'original'}); + expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); + + const subscriberCalls: unknown[] = []; + LocalOnyx.connect({ + key: memberKey, + callback: (value) => subscriberCalls.push(value), + }); + await waitForPromisesToResolve(); + subscriberCalls.length = 0; + + LocalStorageMock.multiSet = jest.fn(LocalStorageMock.multiSet).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiSet); + + await LocalOnyx.multiSet({[memberKey]: {value: 'updated'}}); + + // The in-flight key is excluded from eviction, so its cache value (the merge base) is + // never dropped. Subscriber's last value is the new value, never a transient undefined. + expect(LocalOnyxCache.get(memberKey)).toEqual({value: 'updated'}); + expect(subscriberCalls.at(-1)).toEqual({value: 'updated'}); + // Subscriber should never have seen undefined in the middle of the eviction-retry cycle. + expect(subscriberCalls).not.toContain(undefined); + }); + + it('mergeCollection — evicts an unrelated key, not the in-flight key, so its fields survive', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey = `${collectionKey}1`; + const unrelatedKey = `${collectionKey}2`; + + // Seed the in-flight member with extra fields, plus a separate evictable key. The merge + // only touches memberKey; the unrelated key is the genuine eviction target. + await LocalOnyx.set(memberKey, {id: 1, value: 'orig'}); + await LocalOnyx.set(unrelatedKey, {value: 'evict-me'}); + + const memberCalls: unknown[] = []; + LocalOnyx.connect({ + key: memberKey, + callback: (value) => memberCalls.push(value), + }); + await waitForPromisesToResolve(); + memberCalls.length = 0; + + // Storage.multiMerge rejects once with disk-full, then succeeds on retry. + LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValueOnce(diskFullError).mockImplementation(LocalStorageMock.multiMerge); + + await LocalOnyx.mergeCollection(collectionKey, { + [memberKey]: {value: 'merged'}, + }); + + // The old code evicted the in-flight key and re-ran the merge against an empty cache, + // collapsing {id: 1, value: 'orig'} + {value: 'merged'} to just {value: 'merged'}. Now + // the in-flight key is protected, so its pre-existing {id: 1} survives. + expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls.at(-1)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls).not.toContain(undefined); + // The unrelated key was the genuine eviction target. + expect(LocalOnyxCache.hasCacheForKey(unrelatedKey)).toBe(false); + }); + + it('mergeCollection — does not truncate the in-flight key when it is the only evictable key', async () => { + const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; + const memberKey = `${collectionKey}1`; + + await LocalOnyx.set(memberKey, {id: 1, value: 'orig'}); + expect(LocalOnyxCache.getKeyForEviction()).toBe(memberKey); + + const memberCalls: unknown[] = []; + LocalOnyx.connect({ + key: memberKey, + callback: (value) => memberCalls.push(value), + }); + await waitForPromisesToResolve(); + memberCalls.length = 0; + + // The only evictable key is the in-flight one, which is now excluded — so retryOperation + // finds no acceptable key and reports the quota instead of dropping (and truncating) it. + LocalStorageMock.multiMerge = jest.fn(LocalStorageMock.multiMerge).mockRejectedValue(diskFullError); + + await LocalOnyx.mergeCollection(collectionKey, { + [memberKey]: {value: 'merged'}, + }); + + expect(LocalOnyxCache.get(memberKey)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls.at(-1)).toEqual({id: 1, value: 'merged'}); + expect(memberCalls).not.toContain(undefined); + }); + }); + + describe('afterInit', () => { + beforeEach(() => { + // Resets the deferred init task before each test. + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + return Onyx.clear(); + }); + + it('should execute the callback immediately if Onyx is already initialized', async () => { + Onyx.init({keys: ONYXKEYS}); + await act(async () => waitForPromisesToResolve()); + + const callback = jest.fn(); + OnyxUtils.afterInit(callback); + + await act(async () => waitForPromisesToResolve()); + + expect(callback).toHaveBeenCalledTimes(1); + }); - expect(callback).toHaveBeenCalledTimes(1); + it('should only execute the callback after Onyx initialization', async () => { + const callback = jest.fn(); + OnyxUtils.afterInit(callback); + + await act(async () => waitForPromisesToResolve()); + + expect(callback).not.toHaveBeenCalled(); + + Onyx.init({keys: ONYXKEYS}); + await act(async () => waitForPromisesToResolve()); + + expect(callback).toHaveBeenCalledTimes(1); + }); }); - }); }); diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index 85d886d05..29f71b8dc 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -189,18 +189,18 @@ describe('IDBKeyValProvider', () => { // rejects with as-is. Every write path must instead reject with a real Error so the failure // can be classified and retried. function abortTransactionOnPut() { - const spy = jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters) { - spy.mockRestore(); - const request = this.put(...args); + const originalPut = IDBObjectStore.prototype.put; + jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters) { + const request = originalPut.apply(this, args); this.transaction.abort(); return request; }); } function abortTransactionOnDelete() { - const spy = jest.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function del(this: IDBObjectStore, ...args: Parameters) { - spy.mockRestore(); - const request = this.delete(...args); + const originalDelete = IDBObjectStore.prototype.delete; + jest.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function del(this: IDBObjectStore, ...args: Parameters) { + const request = originalDelete.apply(this, args); this.transaction.abort(); return request; }); From f0a51a2a549a23fa2d3b0fa749a4e69eb53ed6a2 Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 1 Jul 2026 14:35:22 -0700 Subject: [PATCH 13/20] Fix lint and typecheck CI after eslint-config-expensify 4 upgrade Sync seatbelt counts, add perf-test prefer-at override, restore CustomTypeOptions interface augmentation, fix typecheck issues in perf and storage provider tests, and resolve OnyxConnectionManager seatbelt rules. Co-authored-by: Cursor --- eslint.config.mjs | 6 + eslint.seatbelt.tsv | 19 +- lib/OnyxConnectionManager.ts | 4 +- lib/OnyxUtils.ts | 2 +- lib/types.ts | 420 ++++++++---------- tests/perf-test/OnyxCache.perf-test.ts | 26 +- .../OnyxConnectionManager.perf-test.ts | 18 +- .../perf-test/OnyxSnapshotCache.perf-test.ts | 14 +- tests/unit/OnyxConnectionManagerTest.ts | 2 +- tests/unit/onyxTest.ts | 2 +- tests/unit/onyxUtilsTest.ts | 4 +- .../providers/IDBKeyvalProviderTest.ts | 12 +- .../storage/providers/SQLiteProviderTest.ts | 50 ++- 13 files changed, 287 insertions(+), 292 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index fc8c0f8d5..12234ad9b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -142,5 +142,11 @@ export default defineConfig([ }, }, }, + { + files: ['tests/perf-test/**/*'], + rules: { + 'rulesdir/prefer-at': 'off', + }, + }, prettierConfig, ]); diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv index 781ef06a2..0805976e2 100644 --- a/eslint.seatbelt.tsv +++ b/eslint.seatbelt.tsv @@ -7,15 +7,16 @@ "lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-member-access" 1 "lib/DevTools/RealDevTools.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/Onyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 -"lib/OnyxConnectionManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"lib/OnyxConnectionManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 +"lib/OnyxConnectionManager.ts" "@typescript-eslint/non-nullable-type-assertion-style" 3 "lib/OnyxKeys.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/OnyxMerge/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "lib/OnyxMerge/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-assignment" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-call" 2 -"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-member-access" 6 -"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 32 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-member-access" 5 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 31 "lib/Str.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-return" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -23,12 +24,12 @@ "lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-return" 3 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"lib/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"lib/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "lib/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-non-null-assertion" 1 -"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-assignment" 2 -"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 2 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-assignment" 1 +"tests/perf-test/OnyxCache.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/perf-test/OnyxConnectionManager.perf-test.ts" "@typescript-eslint/no-non-null-assertion" 1 "tests/perf-test/OnyxSnapshotCache.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "tests/perf-test/OnyxUtils.perf-test.ts" "@typescript-eslint/no-misused-promises" 1 @@ -41,8 +42,8 @@ "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-return" 1 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "tests/unit/OnyxSnapshotCacheTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 -"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-assignment" 6 -"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-member-access" 9 +"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-assignment" 3 +"tests/unit/collectionHydrationTest.ts" "@typescript-eslint/no-unsafe-member-access" 7 "tests/unit/mocks/sqliteMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-non-null-assertion" 3 "tests/unit/onyxCacheTest.tsx" "@typescript-eslint/no-unsafe-assignment" 7 @@ -60,7 +61,7 @@ "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-assignment" 9 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-call" 2 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-member-access" 26 -"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-return" 3 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-return" 1 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "tests/unit/onyxTest.ts" "import/no-extraneous-dependencies" 2 "tests/unit/onyxUtilsTest.ts" "@typescript-eslint/await-thenable" 6 diff --git a/lib/OnyxConnectionManager.ts b/lib/OnyxConnectionManager.ts index 1eae1cdc5..ca5a5736e 100644 --- a/lib/OnyxConnectionManager.ts +++ b/lib/OnyxConnectionManager.ts @@ -138,7 +138,7 @@ class OnyxConnectionManager { (callback as DefaultConnectCallback)(connection.cachedCallbackValue, connection.cachedCallbackKey as OnyxKey); } } catch (error) { - Logger.logAlert(`[ConnectionManager] Subscriber callback threw an error for key '${connection.onyxKey}': ${error}`); + Logger.logAlert(`[ConnectionManager] Subscriber callback threw an error for key '${connection.onyxKey}': ${String(error)}`); } } } @@ -173,7 +173,7 @@ class OnyxConnectionManager { subscriptionID = OnyxUtils.subscribeToKey({ ...connectOptions, callback, - } as ConnectOptions); + }); connectionMetadata = { subscriptionID, diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index e855e0d46..48a9c7d98 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -347,7 +347,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise { for (const [index, value] of values.entries()) { - const pendingKey = pendingKeys[index]; + const pendingKey = pendingKeys.at(index); if (pendingKey !== undefined) { dataMap.set(pendingKey, value); } diff --git a/lib/types.ts b/lib/types.ts index fe63a30e3..1905d91f1 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,7 +1,7 @@ -import type { Merge } from "type-fest"; -import type OnyxUtils from "./OnyxUtils"; -import type { OnyxMethod } from "./OnyxUtils"; -import type { FastMergeReplaceNullPatch } from "./utils"; +import type {Merge} from 'type-fest'; +import type OnyxUtils from './OnyxUtils'; +import type {OnyxMethod} from './OnyxUtils'; +import type {FastMergeReplaceNullPatch} from './utils'; /** * Utility type that excludes `null` from the type `TValue`. @@ -18,7 +18,7 @@ type NonUndefined = TValue extends undefined ? never : TValue; * and those values can either be of type `TValue` or further nested `DeepRecord` instances. */ type DeepRecord = { - [key: string]: TValue | DeepRecord; + [key: string]: TValue | DeepRecord; }; /** @@ -34,12 +34,12 @@ type DeepRecord = { * In case of conflicting properties, the ones from CustomTypeOptions are prioritized. */ type TypeOptions = Merge< - { - keys: string; - collectionKeys: string; - values: Record; - }, - CustomTypeOptions + { + keys: string; + collectionKeys: string; + values: Record; + }, + CustomTypeOptions >; /** @@ -94,12 +94,12 @@ interface CustomTypeOptions {} /** * Represents a string union of all Onyx normal keys. */ -type Key = TypeOptions["keys"]; +type Key = TypeOptions['keys']; /** * Represents a string union of all Onyx collection keys. */ -type CollectionKeyBase = TypeOptions["collectionKeys"]; +type CollectionKeyBase = TypeOptions['collectionKeys']; /** * Represents a literal string union of all Onyx collection keys. @@ -121,9 +121,7 @@ type OnyxKey = Key | CollectionKey; * The type `TKey` extends `OnyxKey` and it is the key used to access a value in `KeyValueMapping`. * `TReturnType` is the type of the returned value from the selector function. */ -type Selector = ( - value: OnyxEntry, -) => TReturnType; +type Selector = (value: OnyxEntry) => TReturnType; /** * Represents a single Onyx entry, that can be either `TOnyxValue` or `undefined` if it doesn't exist. @@ -135,9 +133,7 @@ type OnyxEntry = TOnyxValue | undefined; * Represents an Onyx collection of entries, that can be either a record of `TOnyxValue`s or `undefined` if it is empty or doesn't exist. * It can be used to specify collection data retrieved from Onyx. */ -type OnyxCollection = OnyxEntry< - Record ->; +type OnyxCollection = OnyxEntry>; /** * Represents a mapping of Onyx keys to values, where keys are either normal or collection Onyx keys @@ -149,38 +145,31 @@ type OnyxCollection = OnyxEntry< * The mapping is derived from the `values` property of the `TypeOptions` type. */ type KeyValueMapping = { - [TKey in keyof TypeOptions["values"] as TKey extends CollectionKeyBase - ? `${TKey}${string}` - : TKey]: TypeOptions["values"][TKey]; + [TKey in keyof TypeOptions['values'] as TKey extends CollectionKeyBase ? `${TKey}${string}` : TKey]: TypeOptions['values'][TKey]; }; /** * Represents a Onyx value that can be either a single entry or a collection of entries, depending on the `TKey` provided. */ -type OnyxValue = string extends TKey - ? unknown - : TKey extends CollectionKeyBase - ? OnyxCollection - : OnyxEntry; +type OnyxValue = string extends TKey ? unknown : TKey extends CollectionKeyBase ? OnyxCollection : OnyxEntry; /** Utility type to extract `TOnyxValue` from `OnyxCollection` */ -type ExtractOnyxCollectionValue = - TOnyxCollection extends NonNullable> ? U : never; +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 - | ((...args: any[]) => unknown) - | Map - | Set - | ReadonlyMap - | ReadonlySet - | unknown[] - | readonly unknown[]; + | BuiltIns + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | ((...args: any[]) => unknown) + | Map + | Set + | ReadonlyMap + | ReadonlySet + | unknown[] + | readonly unknown[]; /** * Create a type from another type with all keys and nested keys set to optional or null. @@ -201,17 +190,13 @@ type NonTransformableTypes = * * settings = applySavedSettings({textEditor: {fontWeight: 500, fontColor: null}}); */ -type NullishDeep = T extends NonTransformableTypes - ? T - : T extends Record - ? NullishObjectDeep - : T; +type NullishDeep = T extends NonTransformableTypes ? T : T extends Record ? NullishObjectDeep : T; /** * Same as `NullishDeep`, but accepts only records as inputs. Internal helper for `NullishDeep`. */ type NullishObjectDeep> = { - [KeyType in keyof ObjectType]?: NullishDeep | null; + [KeyType in keyof ObjectType]?: NullishDeep | null; }; /** @@ -223,32 +208,23 @@ 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 = Record< - `${TKey}${string}`, - TValue ->; +type Collection = Record<`${TKey}${string}`, TValue>; /** 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! type BaseConnectOptions = { - /** - * If set to `false`, the connection won't be reused between other subscribers that are listening to the same Onyx key - * with the same connect configurations. - */ - reuseConnection?: boolean; + /** + * If set to `false`, the connection won't be reused between other subscribers that are listening to the same Onyx key + * with the same connect configurations. + */ + reuseConnection?: boolean; }; /** Represents the callback function used in `Onyx.connect()` method with a regular key. */ -type DefaultConnectCallback = ( - value: OnyxEntry, - key: TKey, -) => void; +type DefaultConnectCallback = (value: OnyxEntry, key: TKey) => void; /** Represents the callback function used in `Onyx.connect()` method with a collection key. */ -type CollectionConnectCallback = ( - value: NonUndefined>, - key: TKey, -) => void; +type CollectionConnectCallback = (value: NonUndefined>, key: TKey) => void; /** * Represents the options used in `Onyx.connect()` method. @@ -260,20 +236,15 @@ type CollectionConnectCallback = ( */ // NOTE: Any changes to this type like adding or removing options must be accounted in OnyxConnectionManager's `generateConnectionID()` method! type ConnectOptions = BaseConnectOptions & { - /** The Onyx key to subscribe to. */ - key: TKey; - - /** A function that will be called when the Onyx data we are subscribed changes. */ - callback?: ( - value: TKey extends CollectionKeyBase - ? NonUndefined> - : OnyxEntry, - key: TKey, - ) => void; + /** The Onyx key to subscribe to. */ + key: TKey; + + /** A function that will be called when the Onyx data we are subscribed changes. */ + callback?: (value: TKey extends CollectionKeyBase ? NonUndefined> : OnyxEntry, key: TKey) => void; }; type CallbackToStateMapping = ConnectOptions & { - subscriptionID: number; + subscriptionID: number; }; /** @@ -285,18 +256,14 @@ type OnyxInputValue = TOnyxValue | null; /** * Represents an Onyx collection input, that can be either a record of `TOnyxValue`s or `null` if the key should be deleted. */ -type OnyxCollectionInputValue = OnyxInputValue< - Record ->; +type OnyxCollectionInputValue = OnyxInputValue>; /** * Represents an input value that can be passed to Onyx methods, that can be either `TOnyxValue` or `null`. * Setting a key to `null` will remove the key from the store. * `undefined` is not allowed for setting values, because it will have no effect on the data. */ -type OnyxInput = OnyxInputValue< - NullishDeep ->; +type OnyxInput = OnyxInputValue>; /** * Represents a mapping object where each `OnyxKey` maps to either a value of its corresponding type in `KeyValueMapping` or `null`. @@ -305,7 +272,7 @@ type OnyxInput = OnyxInputValue< * (set, merge, mergeCollection) and therefore accepts using `null` to remove a key from Onyx. */ type OnyxInputKeyValueMapping = { - [TKey in OnyxKey]: OnyxInput; + [TKey in OnyxKey]: OnyxInput; }; /** @@ -326,24 +293,16 @@ 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< - TKey, - NonNullable> ->; +type OnyxMergeCollectionInput = Collection>>; /** * This represents the value that can be passed to `Onyx.setCollection` and to `Onyx.update` with the method "SET_COLLECTION" */ -type OnyxSetCollectionInput = Collection< - TKey, - OnyxInput ->; +type OnyxSetCollectionInput = Collection>; type OnyxMethodMap = typeof OnyxUtils.METHOD; -type ExpandOnyxKeys = TKey extends CollectionKeyBase - ? NoInfer<`${TKey}${string}`> - : TKey; +type ExpandOnyxKeys = TKey extends CollectionKeyBase ? NoInfer<`${TKey}${string}`> : TKey; /** * OnyxUpdate type includes all onyx methods used in OnyxMethodValueMap. @@ -351,122 +310,122 @@ type ExpandOnyxKeys = TKey extends CollectionKeyBase * Otherwise it will show static type errors. */ type OnyxUpdate = { - // ⚠️ DO NOT CHANGE THIS TYPE, UNLESS YOU KNOW WHAT YOU ARE DOING. ⚠️ - [K in TKey]: - | { - onyxMethod: typeof OnyxUtils.METHOD.SET; - key: ExpandOnyxKeys; - value: OnyxSetInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; - key: ExpandOnyxKeys; - value: OnyxMultiSetInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.MERGE; - key: ExpandOnyxKeys; - value: OnyxMergeInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.CLEAR; - key: ExpandOnyxKeys; - value?: never; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; - key: K; - value: OnyxMergeCollectionInput; - } - | { - onyxMethod: typeof OnyxUtils.METHOD.SET_COLLECTION; - key: K; - value: OnyxSetCollectionInput; - }; + // ⚠️ DO NOT CHANGE THIS TYPE, UNLESS YOU KNOW WHAT YOU ARE DOING. ⚠️ + [K in TKey]: + | { + onyxMethod: typeof OnyxUtils.METHOD.SET; + key: ExpandOnyxKeys; + value: OnyxSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; + key: ExpandOnyxKeys; + value: OnyxMultiSetInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE; + key: ExpandOnyxKeys; + value: OnyxMergeInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.CLEAR; + key: ExpandOnyxKeys; + value?: never; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; + key: K; + value: OnyxMergeCollectionInput; + } + | { + onyxMethod: typeof OnyxUtils.METHOD.SET_COLLECTION; + key: K; + value: OnyxSetCollectionInput; + }; }[TKey]; /** * Represents the options used in `Onyx.set()` method. */ type SetOptions = { - /** Skip the deep equality check against the cached value. Improves performance for large objects. */ - skipCacheCheck?: boolean; + /** Skip the deep equality check against the cached value. Improves performance for large objects. */ + skipCacheCheck?: boolean; }; type SetParams = { - key: TKey; - value: OnyxSetInput; - options?: SetOptions; + key: TKey; + value: OnyxSetInput; + options?: SetOptions; }; type SetCollectionParams = { - collectionKey: TKey; - collection: OnyxSetCollectionInput; + collectionKey: TKey; + collection: OnyxSetCollectionInput; }; type MergeCollectionWithPatchesParams = { - collectionKey: TKey; - collection: OnyxMergeCollectionInput; - mergeReplaceNullPatches?: MultiMergeReplaceNullPatches; - isProcessingCollectionUpdate?: boolean; + collectionKey: TKey; + collection: OnyxMergeCollectionInput; + mergeReplaceNullPatches?: MultiMergeReplaceNullPatches; + isProcessingCollectionUpdate?: boolean; }; type RetriableOnyxOperation = - | typeof OnyxUtils.setWithRetry - | typeof OnyxUtils.multiSetWithRetry - | typeof OnyxUtils.setCollectionWithRetry - | typeof OnyxUtils.mergeCollectionWithPatches - | typeof OnyxUtils.partialSetCollection; + | typeof OnyxUtils.setWithRetry + | typeof OnyxUtils.multiSetWithRetry + | typeof OnyxUtils.setCollectionWithRetry + | typeof OnyxUtils.mergeCollectionWithPatches + | typeof OnyxUtils.partialSetCollection; /** * Represents the options used in `Onyx.init()` method. */ type InitOptions = { - /** `ONYXKEYS` constants object */ - keys?: DeepRecord; - - /** initial data to set when `init()` and `clear()` is called */ - initialKeyStates?: Partial; - - /** - * This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged - * as "safe" for removal. - */ - evictableKeys?: OnyxKey[]; - - /** - * Auto synchronize storage events between multiple instances - * of Onyx running in different tabs/windows. Defaults to true for platforms that support local storage (web/desktop) - */ - shouldSyncMultipleInstances?: boolean; - - /** - * If enabled, it will connect to Redux DevTools Extension for debugging. - * This allows you to see all Onyx state changes in the Redux DevTools. - * @default true - */ - enableDevTools?: boolean; - - /** - * Array of collection member IDs that Onyx should silently ignore across all operations. - * This prevents keys formed from invalid or default IDs (e.g. "-1", "0", "undefined") from - * polluting cache or triggering subscriber notifications. - */ - skippableCollectionMemberIDs?: string[]; - - /** - * Array of keys that when provided to Onyx are flagged as RAM-only keys, and thus are not saved to disk. - */ - ramOnlyKeys?: OnyxKey[]; - - /** - * A list of field names that should always be merged into snapshot entries even if those fields are - * missing in the snapshot. Snapshots are saved "views" of a key's data used to populate read-only - * or cached lists, and by default Onyx only merges fields that already exist in that saved view. - * Use this to opt-in to additional fields that must appear in snapshots (for example, pending flags) - * without hardcoding app-specific logic inside Onyx. - */ - snapshotMergeKeys?: string[]; + /** `ONYXKEYS` constants object */ + keys?: DeepRecord; + + /** initial data to set when `init()` and `clear()` is called */ + initialKeyStates?: Partial; + + /** + * This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged + * as "safe" for removal. + */ + evictableKeys?: OnyxKey[]; + + /** + * Auto synchronize storage events between multiple instances + * of Onyx running in different tabs/windows. Defaults to true for platforms that support local storage (web/desktop) + */ + shouldSyncMultipleInstances?: boolean; + + /** + * If enabled, it will connect to Redux DevTools Extension for debugging. + * This allows you to see all Onyx state changes in the Redux DevTools. + * @default true + */ + enableDevTools?: boolean; + + /** + * Array of collection member IDs that Onyx should silently ignore across all operations. + * This prevents keys formed from invalid or default IDs (e.g. "-1", "0", "undefined") from + * polluting cache or triggering subscriber notifications. + */ + skippableCollectionMemberIDs?: string[]; + + /** + * Array of keys that when provided to Onyx are flagged as RAM-only keys, and thus are not saved to disk. + */ + ramOnlyKeys?: OnyxKey[]; + + /** + * A list of field names that should always be merged into snapshot entries even if those fields are + * missing in the snapshot. Snapshots are saved "views" of a key's data used to populate read-only + * or cached lists, and by default Onyx only merges fields that already exist in that saved view. + * Use this to opt-in to additional fields that must appear in snapshots (for example, pending flags) + * without hardcoding app-specific logic inside Onyx. + */ + snapshotMergeKeys?: string[]; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -476,61 +435,58 @@ type GenericFunction = (...args: any[]) => any; * Represents a 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. */ -type MultiMergeReplaceNullPatches = Record< - OnyxKey, - FastMergeReplaceNullPatch[] ->; +type MultiMergeReplaceNullPatches = Record; /** * Represents a combination of Merge and Set operations that should be executed in Onyx */ type MixedOperationsQueue = { - merge: OnyxInputKeyValueMapping; - mergeReplaceNullPatches: MultiMergeReplaceNullPatches; - set: OnyxInputKeyValueMapping; + merge: OnyxInputKeyValueMapping; + mergeReplaceNullPatches: MultiMergeReplaceNullPatches; + set: OnyxInputKeyValueMapping; }; export type { - BaseConnectOptions, - Collection, - CollectionConnectCallback, - CollectionKey, - CollectionKeyBase, - ConnectOptions, - CustomTypeOptions, - DeepRecord, - DefaultConnectCallback, - ExtractOnyxCollectionValue, - GenericFunction, - InitOptions, - Key, - KeyValueMapping, - CallbackToStateMapping, - NonNull, - NonUndefined, - OnyxInputKeyValueMapping, - NullishDeep, - OnyxCollection, - OnyxEntry, - OnyxKey, - OnyxInputValue, - OnyxCollectionInputValue, - OnyxInput, - OnyxSetInput, - OnyxMultiSetInput, - OnyxMergeInput, - OnyxMergeCollectionInput, - OnyxSetCollectionInput, - OnyxMethod, - OnyxMethodMap, - OnyxUpdate, - OnyxValue, - Selector, - SetOptions, - SetParams, - SetCollectionParams, - MergeCollectionWithPatchesParams, - MultiMergeReplaceNullPatches, - MixedOperationsQueue, - RetriableOnyxOperation, + BaseConnectOptions, + Collection, + CollectionConnectCallback, + CollectionKey, + CollectionKeyBase, + ConnectOptions, + CustomTypeOptions, + DeepRecord, + DefaultConnectCallback, + ExtractOnyxCollectionValue, + GenericFunction, + InitOptions, + Key, + KeyValueMapping, + CallbackToStateMapping, + NonNull, + NonUndefined, + OnyxInputKeyValueMapping, + NullishDeep, + OnyxCollection, + OnyxEntry, + OnyxKey, + OnyxInputValue, + OnyxCollectionInputValue, + OnyxInput, + OnyxSetInput, + OnyxMultiSetInput, + OnyxMergeInput, + OnyxMergeCollectionInput, + OnyxSetCollectionInput, + OnyxMethod, + OnyxMethodMap, + OnyxUpdate, + OnyxValue, + Selector, + SetOptions, + SetParams, + SetCollectionParams, + MergeCollectionWithPatchesParams, + MultiMergeReplaceNullPatches, + MixedOperationsQueue, + RetriableOnyxOperation, }; diff --git a/tests/perf-test/OnyxCache.perf-test.ts b/tests/perf-test/OnyxCache.perf-test.ts index 4c05a41c5..e6469ec67 100644 --- a/tests/perf-test/OnyxCache.perf-test.ts +++ b/tests/perf-test/OnyxCache.perf-test.ts @@ -54,7 +54,7 @@ describe('OnyxCache', () => { describe('addKey', () => { test('one call adding one key', async () => { - await measureFunction(() => cache.addKey(mockedReportActionsKeys.at(0)), { + await measureFunction(() => cache.addKey(mockedReportActionsKeys[0]), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -62,7 +62,7 @@ describe('OnyxCache', () => { describe('addNullishStorageKey', () => { test('one call adding one key', async () => { - await measureFunction(() => cache.addNullishStorageKey(mockedReportActionsKeys.at(0)), { + await measureFunction(() => cache.addNullishStorageKey(mockedReportActionsKeys[0]), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -70,7 +70,7 @@ describe('OnyxCache', () => { describe('hasNullishStorageKey', () => { test('one call checking one key among 10k ones', async () => { - await measureFunction(() => cache.hasNullishStorageKey(mockedReportActionsKeys.at(0)), { + await measureFunction(() => cache.hasNullishStorageKey(mockedReportActionsKeys[0]), { beforeEach: async () => { resetCacheBeforeEachMeasure(); cache.setAllKeys(mockedReportActionsKeys); @@ -92,7 +92,7 @@ describe('OnyxCache', () => { describe('hasCacheForKey', () => { test('one call checking one key among 10k ones', async () => { - await measureFunction(() => cache.hasCacheForKey(mockedReportActionsKeys.at(0)), { + await measureFunction(() => cache.hasCacheForKey(mockedReportActionsKeys[0]), { beforeEach: async () => { resetCacheBeforeEachMeasure(); cache.setAllKeys(mockedReportActionsKeys); @@ -103,7 +103,7 @@ describe('OnyxCache', () => { describe('get', () => { test('one call getting one key among 10k ones', async () => { - await measureFunction(() => cache.get(mockedReportActionsKeys.at(0)), { + await measureFunction(() => cache.get(mockedReportActionsKeys[0]), { beforeEach: async () => { resetCacheBeforeEachMeasure(); for (const [k, v] of Object.entries(mockedReportActionsMap)) cache.set(k, v); @@ -115,7 +115,7 @@ describe('OnyxCache', () => { describe('set', () => { test('one call setting one key', async () => { const value = mockedReportActionsMap[mockedReportActionsKeys[0]]; - await measureFunction(() => cache.set(mockedReportActionsKeys.at(0), value), { + await measureFunction(() => cache.set(mockedReportActionsKeys[0], value), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -123,7 +123,7 @@ describe('OnyxCache', () => { describe('drop', () => { test('one call dropping one key among 10k ones', async () => { - await measureFunction(() => cache.drop(mockedReportActionsKeys.at(1000)), { + await measureFunction(() => cache.drop(mockedReportActionsKeys[1000]), { beforeEach: async () => { resetCacheBeforeEachMeasure(); for (const [k, v] of Object.entries(mockedReportActionsMap)) cache.set(k, v); @@ -147,10 +147,10 @@ describe('OnyxCache', () => { describe('hasPendingTask', () => { test('one call checking one task', async () => { - await measureFunction(() => cache.hasPendingTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`), { + await measureFunction(() => cache.hasPendingTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`), { beforeEach: async () => { resetCacheBeforeEachMeasure(); - cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`, Promise.resolve()); + cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()); }, }); }); @@ -158,10 +158,10 @@ describe('OnyxCache', () => { describe('getTaskPromise', () => { test('one call checking one task', async () => { - await measureAsyncFunction(() => cache.getTaskPromise(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`)!, { + await measureAsyncFunction(() => cache.getTaskPromise(`${TASK.GET}:${mockedReportActionsKeys[0]}`)!, { beforeEach: async () => { resetCacheBeforeEachMeasure(); - cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`, Promise.resolve()); + cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()); }, }); }); @@ -169,14 +169,14 @@ describe('OnyxCache', () => { describe('captureTask', () => { test('one call capturing one task', async () => { - await measureAsyncFunction(() => cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys.at(0)}`, Promise.resolve()), { + await measureAsyncFunction(() => cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()), { beforeEach: resetCacheBeforeEachMeasure, }); }); }); describe('hasValueChanged', () => { - const key = mockedReportActionsKeys.at(0); + const key = mockedReportActionsKeys[0]; const reportAction = mockedReportActionsMap[key]; const changedReportAction = createRandomReportAction(Number(reportAction.reportActionID)); diff --git a/tests/perf-test/OnyxConnectionManager.perf-test.ts b/tests/perf-test/OnyxConnectionManager.perf-test.ts index e95145677..8bcc45689 100644 --- a/tests/perf-test/OnyxConnectionManager.perf-test.ts +++ b/tests/perf-test/OnyxConnectionManager.perf-test.ts @@ -29,8 +29,10 @@ const mockedReportActionsKeys = Object.keys(mockedReportActionsMap); // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -const generateConnectionID = connectionManager.generateConnectionID.bind(connectionManager); -const fireCallbacks = connectionManager.fireCallbacks.bind(connectionManager); +// eslint-disable-next-line dot-notation +const generateConnectionID = connectionManager['generateConnectionID'].bind(connectionManager); +// eslint-disable-next-line dot-notation +const fireCallbacks = connectionManager['fireCallbacks'].bind(connectionManager); const resetConectionManagerAfterEachMeasure = () => { connectionManager.disconnectAll(); @@ -52,7 +54,7 @@ describe('OnyxConnectionManager', () => { describe('generateConnectionID', () => { test('one call', async () => { - await measureFunction(() => generateConnectionID({key: mockedReportActionsKeys.at(0)}), { + await measureFunction(() => generateConnectionID({key: mockedReportActionsKeys[0]}), { afterEach: resetConectionManagerAfterEachMeasure, }); }); @@ -65,12 +67,12 @@ describe('OnyxConnectionManager', () => { await measureFunction(() => fireCallbacks(connectionID), { beforeEach: async () => { connectionID = connectionManager.connect({ - key: mockedReportActionsKeys.at(0), + key: mockedReportActionsKeys[0], callback: jest.fn(), }).id; for (let i = 0; i < 9999; i++) { connectionManager.connect({ - key: mockedReportActionsKeys.at(0), + key: mockedReportActionsKeys[0], callback: jest.fn(), }); } @@ -89,7 +91,7 @@ describe('OnyxConnectionManager', () => { async () => { const callback = createDeferredTask(); connectionManager.connect({ - key: mockedReportActionsKeys.at(0), + key: mockedReportActionsKeys[0], callback: () => { callback.resolve?.(); }, @@ -117,7 +119,7 @@ describe('OnyxConnectionManager', () => { { beforeEach: async () => { connection = connectionManager.connect({ - key: mockedReportActionsKeys.at(0), + key: mockedReportActionsKeys[0], callback: jest.fn(), }); }, @@ -136,7 +138,7 @@ describe('OnyxConnectionManager', () => { beforeEach: async () => { for (let i = 0; i < 10000; i++) { connectionManager.connect({ - key: mockedReportActionsKeys.at(0), + key: mockedReportActionsKeys[0], callback: jest.fn(), }); } diff --git a/tests/perf-test/OnyxSnapshotCache.perf-test.ts b/tests/perf-test/OnyxSnapshotCache.perf-test.ts index aefce9985..fde86da13 100644 --- a/tests/perf-test/OnyxSnapshotCache.perf-test.ts +++ b/tests/perf-test/OnyxSnapshotCache.perf-test.ts @@ -24,7 +24,12 @@ const ONYXKEYS = { // Mock selector functions const simpleSelector: UseOnyxSelector = (data) => (data as MockData | undefined)?.value; -type ComplexSelectorResult = {id?: number; name?: string; computed: number; formatted: string}; +type ComplexSelectorResult = { + id?: number; + name?: string; + computed: number; + formatted: string; +}; const complexSelector: UseOnyxSelector = (data) => { const mockData = data as MockData | undefined; return { @@ -114,7 +119,10 @@ describe('OnyxSnapshotCache', () => { () => { for (let i = 0; i < 1000; i++) { const selector: UseOnyxSelector = (data) => ((data as MockData | undefined)?.field ?? '') + i; - const options: UseOnyxOptions = {...selectorOptions, selector}; + const options: UseOnyxOptions = { + ...selectorOptions, + selector, + }; cache.registerConsumer(ONYXKEYS.TEST_KEY, options); } }, @@ -171,7 +179,7 @@ describe('OnyxSnapshotCache', () => { // Pre-populate cache with 1000 entries for (let i = 0; i < 1000; i++) { const key = `test_key_${i}`; - const result = mockResults.at(i); + const result = mockResults[i]; cache.setCachedResult(key, `cache_key_${i}`, result); } // Set our target entry diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index 5b5b87b88..ba9b949e8 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -434,7 +434,7 @@ describe('OnyxConnectionManager', () => { const setCallsForKey = setSpy.mock.calls.filter((call) => call[0] === ONYXKEYS.TEST_KEY); expect(setCallsForKey.length).toBeGreaterThan(0); - const updatedIDs = setCallsForKey.at(setCallsForKey.length - 1)[1] as number[]; + const updatedIDs = setCallsForKey.at(setCallsForKey.length - 1)![1] as number[]; expect(updatedIDs).not.toContain(subscriptionIdA); expect(updatedIDs).toContain(subscriptionIdB); diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index ec89fe650..53e688f43 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -2450,8 +2450,8 @@ describe('Onyx', () => { await act(async () => Onyx.update([ {onyxMethod: 'set', key: ONYX_KEYS.TEST_KEY, value: 'test1'}, - // @ts-expect-error invalid method { + // @ts-expect-error invalid method onyxMethod: 'invalidMethod', key: ONYX_KEYS.OTHER_TEST, value: 'test2', diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 046ab72d2..87576136f 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -778,7 +778,7 @@ describe('OnyxUtils', () => { // + name + message) is logged exactly once even though the operation retries 6 times. const unclassifiedCalls = logAlertSpy.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith('Unclassified storage error.')); expect(unclassifiedCalls).toHaveLength(1); - expect(unclassifiedCalls.at(0)[0]).toBe( + expect(unclassifiedCalls.at(0)![0]).toBe( `Unclassified storage error. provider: MemoryOnlyProvider. name: ${genericError.name}. message: ${genericError.message}. onyxMethod: setWithRetry.`, ); }); @@ -1228,7 +1228,7 @@ describe('OnyxUtils', () => { // OnyxUtils.multiGet filters to cache-missing keys before issuing Storage.multiGet, so we // expect exactly one batched read containing only the cold keys (the warm key is skipped). expect(multiGetSpy).toHaveBeenCalledTimes(1); - const requestedKeys = multiGetSpy.mock.calls.at(0)[0]; + const requestedKeys = multiGetSpy.mock.calls.at(0)![0]; expect(requestedKeys.sort()).toEqual([coldKey1, coldKey2].sort()); // No individual Storage.getItem calls during pre-warm. Old code path would have fired one diff --git a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts index 29f71b8dc..b057ba8fc 100644 --- a/tests/unit/storage/providers/IDBKeyvalProviderTest.ts +++ b/tests/unit/storage/providers/IDBKeyvalProviderTest.ts @@ -132,13 +132,13 @@ describe('IDBKeyValProvider', () => { ]; const expectedEntries = structuredClone(changedEntries); - const expectedTestKey3Value = structuredClone(testEntries.at(2))[1] as GenericDeepRecord; + const expectedTestKey3Value = structuredClone(testEntries[2])[1] as GenericDeepRecord; expectedTestKey3Value.key = 'value_changed'; expectedTestKey3Value.property.nestedProperty = { nestedKey2: 'nestedValue2_changed', }; expectedTestKey3Value.property.newKey = 'newValue'; - expectedEntries.at(2)[1] = expectedTestKey3Value; + expectedEntries[2][1] = expectedTestKey3Value; await IDBKeyValProvider.multiMerge(changedEntries); expect( @@ -160,9 +160,9 @@ describe('IDBKeyValProvider', () => { ]; const expectedEntries = structuredClone(changedEntries); - const expectedTestKey3Value = structuredClone(testEntries.at(2))[1] as GenericDeepRecord; + const expectedTestKey3Value = structuredClone(testEntries[2])[1] as GenericDeepRecord; expectedTestKey3Value.key = 'value_changed'; - expectedEntries.at(2)[1] = expectedTestKey3Value; + expectedEntries[2][1] = expectedTestKey3Value; await IDBKeyValProvider.multiMerge(changedEntries); // ONYXKEYS.TEST_KEY_3 and `${ONYXKEYS.COLLECTION.TEST_KEY}id2`. @@ -271,8 +271,8 @@ describe('IDBKeyValProvider', () => { newKey: 'newValue', }, }); - await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, false); - await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {newKey: 'newValue'}]); + await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, false); + await IDBKeyValProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {newKey: 'newValue'}]); expect(await IDB.get(ONYXKEYS.TEST_KEY, IDBKeyValProvider.store)).toEqual('value_changed'); expect(await IDB.get(ONYXKEYS.TEST_KEY_2, IDBKeyValProvider.store)).toEqual(1001); diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 4e81ccb70..87f85a696 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -12,7 +12,9 @@ import {resetAllDatabases} from '../../mocks/sqliteMock'; // `jest.mock` is hoisted by Jest above the imports — register the SQLite mock // (overriding the global jestSetup.js mock) and a tiny device-info stub. jest.mock('react-native-nitro-sqlite', () => require('../../mocks/sqliteMock')); -jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => 12345})); +jest.mock('react-native-device-info', () => ({ + getFreeDiskStorage: () => 12345, +})); const ONYXKEYS = { TEST_KEY: 'test', @@ -157,9 +159,11 @@ describe('SQLiteProvider', () => { [`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {newKey: 'newValue'}]], ]; - const expectedTestKey3Value = structuredClone(testEntries.at(2))[1] as GenericDeepRecord; + const expectedTestKey3Value = structuredClone(testEntries[2])[1] as GenericDeepRecord; expectedTestKey3Value.key = 'value_changed'; - expectedTestKey3Value.property.nestedProperty = {nestedKey2: 'nestedValue2_changed'}; + expectedTestKey3Value.property.nestedProperty = { + nestedKey2: 'nestedValue2_changed', + }; expectedTestKey3Value.property.newKey = 'newValue'; await SQLiteProvider.multiMerge(changedEntries); @@ -173,13 +177,19 @@ describe('SQLiteProvider', () => { it('should insert a new record when key does not exist', async () => { await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]); - expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true}); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({ + fresh: true, + }); }); it('should shallow-merge existing record_key value', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, {a: 1, b: 2}); await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_3, {b: 99, c: 3}]]); - expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({a: 1, b: 99, c: 3}); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({ + a: 1, + b: 99, + c: 3, + }); }); it('should deep-merge nested objects', async () => { @@ -198,7 +208,11 @@ describe('SQLiteProvider', () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { keepMe: 'still here', removeMe: 'gone soon', - outer: {keepInner: 1, removeInner: 2, deeper: {keepDeep: 'a', removeDeep: 'b'}}, + outer: { + keepInner: 1, + removeInner: 2, + deeper: {keepDeep: 'a', removeDeep: 'b'}, + }, }); await SQLiteProvider.multiMerge([ @@ -255,9 +269,12 @@ describe('SQLiteProvider', () => { it('should merge all the supported kinds of data correctly', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_2, 1000); - await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, {key: 'value', property: {propertyKey: 'propertyValue'}}); - await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, true); - await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {key: 'value'}, 1, true]); + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { + key: 'value', + property: {propertyKey: 'propertyValue'}, + }); + await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, true); + await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {key: 'value'}, 1, true]); await SQLiteProvider.mergeItem(ONYXKEYS.TEST_KEY, 'value_changed'); await SQLiteProvider.mergeItem(ONYXKEYS.TEST_KEY_2, 1001); @@ -272,12 +289,15 @@ describe('SQLiteProvider', () => { }, [[['property'], {newKey: 'newValue'}]], ); - await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, false); - await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {newKey: 'newValue'}]); + await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, false); + await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {newKey: 'newValue'}]); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value_changed'); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual(1001); - expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({key: 'value_changed', property: {newKey: 'newValue'}}); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({ + key: 'value_changed', + property: {newKey: 'newValue'}, + }); expect(await SQLiteProvider.getItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`)).toEqual(false); expect(await SQLiteProvider.getItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`)).toEqual(['a', {newKey: 'newValue'}]); }); @@ -317,7 +337,7 @@ describe('SQLiteProvider', () => { describe('SQL-injection safety', () => { it('should treat a key containing SQL fragments as a literal record_key', async () => { const nastyKey = "'; DROP TABLE keyvaluepairs; --"; - await SQLiteProvider.setItem(nastyKey, 'survived'); + await SQLiteProvider.setItem(nastyKey as string, 'survived'); expect(await SQLiteProvider.getItem(nastyKey)).toEqual('survived'); expect(await SQLiteProvider.getAllKeys()).toEqual([nastyKey]); }); @@ -338,7 +358,9 @@ describe('SQLiteProvider', () => { // SQLite allocates pages on init (table + WAL), so bytesUsed is non-0 from the // start; assert that a write increases it rather than comparing to 0. const before = await SQLiteProvider.getDatabaseSize(); - await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, {payload: 'x'.repeat(64 * 1024)}); + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, { + payload: 'x'.repeat(64 * 1024), + }); const after = await SQLiteProvider.getDatabaseSize(); expect(after.bytesUsed).toBeGreaterThan(before.bytesUsed); From eaee7bc4088209aacdc07a96c1ebc2ff570dfdfc Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 1 Jul 2026 14:40:08 -0700 Subject: [PATCH 14/20] Fix lint CI by syncing seatbelt with eslint-config-expensify 4 rules Add seatbelt entries for new rule violations, disable dot-notation in tests that access private APIs, and restore no-unsafe-argument budget. Co-authored-by: Cursor --- eslint.config.mjs | 6 ++++++ eslint.seatbelt.tsv | 23 ++++++++++++++++++++++- lib/storage/InstanceSync/index.ts | 2 +- lib/types.ts | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 12234ad9b..ad88beaa1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -142,6 +142,12 @@ export default defineConfig([ }, }, }, + { + files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], + rules: { + '@typescript-eslint/dot-notation': 'off', + }, + }, { files: ['tests/perf-test/**/*'], rules: { diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv index 0805976e2..f6cce0caa 100644 --- a/eslint.seatbelt.tsv +++ b/eslint.seatbelt.tsv @@ -13,10 +13,12 @@ "lib/OnyxMerge/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "lib/OnyxMerge/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "lib/OnyxSnapshotCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-argument" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-assignment" 6 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-call" 2 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-member-access" 5 "lib/OnyxUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 31 +"lib/OnyxUtils.ts" "@typescript-eslint/non-nullable-type-assertion-style" 1 "lib/Str.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-return" 1 "lib/storage/providers/IDBKeyValProvider/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -60,7 +62,7 @@ "tests/unit/onyxTest.ts" "@typescript-eslint/no-non-null-assertion" 1 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-assignment" 9 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-call" 2 -"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-member-access" 26 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-member-access" 31 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-return" 1 "tests/unit/onyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "tests/unit/onyxTest.ts" "import/no-extraneous-dependencies" 2 @@ -79,3 +81,22 @@ "tests/unit/storage/tryOrDegradePerformanceTest.ts" "@typescript-eslint/no-unsafe-member-access" 1 "tests/unit/useOnyxTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 "tests/unit/utilsTest.ts" "@typescript-eslint/no-unsafe-member-access" 3 +"lib/StateMachine.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"lib/storage/__mocks__/index.ts" "@typescript-eslint/no-unsafe-argument" 1 +"lib/storage/index.ts" "no-nested-ternary" 1 +"lib/storage/providers/IDBKeyValProvider/index.ts" "rulesdir/prefer-at" 1 +"lib/storage/providers/NoopProvider.ts" "no-void" 1 +"lib/utils.ts" "@typescript-eslint/no-redundant-type-constituents" 1 +"tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-non-null-assertion" 1 +"tests/unit/onyxTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 4 +"tests/unit/onyxTest.ts" "rulesdir/prefer-at" 11 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-non-null-assertion" 2 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-member-access" 21 +"tests/unit/onyxUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-assignment" 21 +"tests/unit/storage/providers/createStoreTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "@typescript-eslint/unbound-method" 2 +"tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "rulesdir/prefer-at" 4 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 +"tests/unit/storage/providers/SQLiteProviderTest.ts" "rulesdir/prefer-at" 1 diff --git a/lib/storage/InstanceSync/index.ts b/lib/storage/InstanceSync/index.ts index f8a5be162..46511894a 100644 --- a/lib/storage/InstanceSync/index.ts +++ b/lib/storage/InstanceSync/index.ts @@ -1,4 +1,4 @@ -const NOOP = (..._args: unknown[]) => {}; +const NOOP = (() => {}) as (...args: unknown[]) => void; /** * This is used to keep multiple browser tabs in sync, therefore only needed on web diff --git a/lib/types.ts b/lib/types.ts index 1905d91f1..1d71a12d3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -88,7 +88,7 @@ type TypeOptions = Merge< * } * ``` */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- must remain an interface so consumers can augment via declare module +// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/consistent-type-definitions -- must remain an interface so consumers can augment via declare module interface CustomTypeOptions {} /** From 62e9276496c27a4630750b87748c5800240d86cc Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 1 Jul 2026 14:44:24 -0700 Subject: [PATCH 15/20] Remove unused dot-notation eslint-disable directives in tests Dot-notation is disabled for test files globally, so the per-line directives were unused and caused lint to fail CI. Co-authored-by: Cursor --- tests/perf-test/OnyxConnectionManager.perf-test.ts | 2 -- tests/unit/OnyxConnectionManagerTest.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/tests/perf-test/OnyxConnectionManager.perf-test.ts b/tests/perf-test/OnyxConnectionManager.perf-test.ts index 8bcc45689..50a95ea94 100644 --- a/tests/perf-test/OnyxConnectionManager.perf-test.ts +++ b/tests/perf-test/OnyxConnectionManager.perf-test.ts @@ -29,9 +29,7 @@ const mockedReportActionsKeys = Object.keys(mockedReportActionsMap); // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -// eslint-disable-next-line dot-notation const generateConnectionID = connectionManager['generateConnectionID'].bind(connectionManager); -// eslint-disable-next-line dot-notation const fireCallbacks = connectionManager['fireCallbacks'].bind(connectionManager); const resetConectionManagerAfterEachMeasure = () => { diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index ba9b949e8..b6e427a9d 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -7,11 +7,8 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -// eslint-disable-next-line dot-notation const connectionsMap = connectionManager['connectionsMap']; -// eslint-disable-next-line dot-notation const generateConnectionID = connectionManager['generateConnectionID'].bind(connectionManager); -// eslint-disable-next-line dot-notation const getSessionID = () => connectionManager['sessionID']; const ONYXKEYS = { From b949a8ed3362f35865ea7b707173cfa85a6f05fc Mon Sep 17 00:00:00 2001 From: rory Date: Wed, 1 Jul 2026 15:29:46 -0700 Subject: [PATCH 16/20] Use @types/lodash.bindall instead of hand-rolled declaration Replace the local lodash.bindall module shim with the official DefinitelyTyped package now that imports use lodash.bindall directly. Co-authored-by: Cursor --- lib/lodash.bindall.d.ts | 4 --- package-lock.json | 68 +++++++++++++++++++---------------------- package.json | 3 +- 3 files changed, 34 insertions(+), 41 deletions(-) delete mode 100644 lib/lodash.bindall.d.ts diff --git a/lib/lodash.bindall.d.ts b/lib/lodash.bindall.d.ts deleted file mode 100644 index 21433f245..000000000 --- a/lib/lodash.bindall.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'lodash.bindall' { - function bindAll(object: T, ...methodNames: Array): T; - export default bindAll; -} diff --git a/package-lock.json b/package-lock.json index dc8da9d16..ab7f1272f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@types/jest": "^29.5.14", "@types/jsdoc-to-markdown": "^7.0.6", "@types/lodash": "^4.14.202", + "@types/lodash.bindall": "^4.4.9", "@types/node": "^20.11.5", "@types/react": "^18.2.14", "@types/react-native": "^0.70.0", @@ -189,6 +190,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -547,7 +549,6 @@ "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" @@ -565,7 +566,6 @@ "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -582,7 +582,6 @@ "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -599,7 +598,6 @@ "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" @@ -617,7 +615,6 @@ "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", @@ -636,7 +633,6 @@ "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" @@ -725,7 +721,6 @@ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" }, @@ -839,7 +834,6 @@ "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1040,7 +1034,6 @@ "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1110,7 +1103,6 @@ "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1160,7 +1152,6 @@ "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -1233,7 +1224,6 @@ "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -1251,7 +1241,6 @@ "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1268,7 +1257,6 @@ "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -1286,7 +1274,6 @@ "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1303,7 +1290,6 @@ "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7" @@ -1321,7 +1307,6 @@ "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1338,7 +1323,6 @@ "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1407,7 +1391,6 @@ "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1456,7 +1439,6 @@ "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1473,7 +1455,6 @@ "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -1508,7 +1489,6 @@ "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", @@ -1528,7 +1508,6 @@ "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -1563,7 +1542,6 @@ "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1632,7 +1610,6 @@ "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7" @@ -1734,7 +1711,6 @@ "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1835,7 +1811,6 @@ "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -1853,7 +1828,6 @@ "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1940,7 +1914,6 @@ "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1957,7 +1930,6 @@ "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1994,7 +1966,6 @@ "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -2011,7 +1982,6 @@ "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -2046,7 +2016,6 @@ "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" @@ -2064,7 +2033,6 @@ "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", @@ -2151,7 +2119,6 @@ "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" @@ -2184,7 +2151,6 @@ "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", @@ -3416,6 +3382,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4120,12 +4087,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash.bindall": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@types/lodash.bindall/-/lodash.bindall-4.4.9.tgz", + "integrity": "sha512-sG6V5+T3JcB39nXfORM+kHRH6DiCB+95DfGrH2YpPMXRpt6q3rn7VrW3dItiOLasJYeHq1jxc+tOW1ijT+d1wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -4144,6 +4122,7 @@ "integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4230,6 +4209,7 @@ "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.1", @@ -4269,6 +4249,7 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -4839,6 +4820,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5599,6 +5581,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -5717,6 +5700,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7301,6 +7285,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10194,6 +10179,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11474,6 +11460,7 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -12932,6 +12919,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13300,6 +13288,7 @@ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13353,6 +13342,7 @@ "integrity": "sha512-0TUhgmlouRNf6yuDIIAdbQl0g1VsONgCMsLs7Et64hjj5VLMCA7np+4dMrZvGZ3wRNqzgeyT9oWJsUm49AcwSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native/assets-registry": "0.76.3", @@ -13425,6 +13415,7 @@ "integrity": "sha512-Eho1yEcLbsteGpBFn2XZOp5FIptnEciWzuYBW49S0jo41Un2LeyesIO/MqYLY/c5o7D9Fw9th4pxGtV7OAb0+g==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -13542,6 +13533,7 @@ "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "react-is": "^18.2.0", "react-shallow-renderer": "^16.15.0", @@ -15576,6 +15568,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15959,6 +15952,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16149,6 +16143,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -16697,6 +16692,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 7db281c64..7b5816d8d 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,8 @@ "devDependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.1", - "@jest/globals": "^29.7.0", "@eslint/eslintrc": "^3.3.1", + "@jest/globals": "^29.7.0", "@ngneat/falso": "^7.3.0", "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", @@ -64,6 +64,7 @@ "@types/jest": "^29.5.14", "@types/jsdoc-to-markdown": "^7.0.6", "@types/lodash": "^4.14.202", + "@types/lodash.bindall": "^4.4.9", "@types/node": "^20.11.5", "@types/react": "^18.2.14", "@types/react-native": "^0.70.0", From 364e50e1c03faaa9ebe9b585ec05ed2fb5a32cfe Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 2 Jul 2026 16:44:38 -0700 Subject: [PATCH 17/20] Remove test ESLint overrides and fix violations in code Drop dot-notation and prefer-at disables for tests; use typed test harnesses for private member access, getAtIndex for array indexing, and restore useLiveRef deprecation with seatbelt-documented exceptions. Co-authored-by: Cursor --- eslint.config.mjs | 12 ------ eslint.seatbelt.tsv | 5 +++ lib/useLiveRef.ts | 6 +-- tests/perf-test/OnyxCache.perf-test.ts | 31 +++++++------ .../OnyxConnectionManager.perf-test.ts | 20 +++++---- .../perf-test/OnyxSnapshotCache.perf-test.ts | 3 +- tests/unit/DevToolsTest.ts | 43 +++++++++++-------- tests/unit/OnyxConnectionManagerTest.ts | 8 ++-- tests/utils/getAtIndex.ts | 15 +++++++ .../utils/onyxConnectionManagerTestHarness.ts | 16 +++++++ 10 files changed, 100 insertions(+), 59 deletions(-) create mode 100644 tests/utils/getAtIndex.ts create mode 100644 tests/utils/onyxConnectionManagerTestHarness.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index ad88beaa1..fc8c0f8d5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -142,17 +142,5 @@ export default defineConfig([ }, }, }, - { - files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], - rules: { - '@typescript-eslint/dot-notation': 'off', - }, - }, - { - files: ['tests/perf-test/**/*'], - rules: { - 'rulesdir/prefer-at': 'off', - }, - }, prettierConfig, ]); diff --git a/eslint.seatbelt.tsv b/eslint.seatbelt.tsv index f6cce0caa..782e9e40d 100644 --- a/eslint.seatbelt.tsv +++ b/eslint.seatbelt.tsv @@ -26,6 +26,10 @@ "lib/storage/providers/NoopProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-return" 3 "lib/storage/providers/SQLiteProvider.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +# useLiveRef is deprecated but still used by useOnyx; refs assignment during render is intentional +"lib/useLiveRef.ts" "@typescript-eslint/no-deprecated" 1 +"lib/useLiveRef.ts" "react-hooks/refs" 1 +"lib/useOnyx.ts" "@typescript-eslint/no-deprecated" 1 "lib/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "lib/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "tests/perf-test/Onyx.perf-test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -41,6 +45,7 @@ "tests/perf-test/utils.perf-test.ts" "@typescript-eslint/no-unsafe-member-access" 3 "tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-assignment" 2 "tests/unit/DevToolsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 +"tests/utils/onyxConnectionManagerTestHarness.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-return" 1 "tests/unit/OnyxConnectionManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "tests/unit/OnyxSnapshotCacheTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 diff --git a/lib/useLiveRef.ts b/lib/useLiveRef.ts index c28b77451..869f439db 100644 --- a/lib/useLiveRef.ts +++ b/lib/useLiveRef.ts @@ -4,16 +4,14 @@ import {useRef} from 'react'; * Creates a mutable reference to a value, useful when you need to * maintain a reference to a value that may change over time without triggering re-renders. * - * This hook intentionally assigns to ref.current during render. The migration effort to - * remove it safely is not currently planned. + * @deprecated This hook breaks the Rules of React, and should not be used. + * The migration effort to remove it safely is not currently planned. */ -/* eslint-disable react-hooks/refs -- Intentional live-ref pattern for dependency tracking without re-renders */ function useLiveRef(value: T) { const ref = useRef(value); ref.current = value; return ref; } -/* eslint-enable react-hooks/refs */ export default useLiveRef; diff --git a/tests/perf-test/OnyxCache.perf-test.ts b/tests/perf-test/OnyxCache.perf-test.ts index e6469ec67..27f99b5ee 100644 --- a/tests/perf-test/OnyxCache.perf-test.ts +++ b/tests/perf-test/OnyxCache.perf-test.ts @@ -2,6 +2,7 @@ import {measureAsyncFunction, measureFunction} from 'reassure'; import type OnyxCache from '../../lib/OnyxCache'; import createRandomReportAction, {getRandomReportActions} from '../utils/collections/reportActions'; import {TASK} from '../../lib/OnyxCache'; +import getAtIndex from '../utils/getAtIndex'; const ONYXKEYS = { TEST_KEY: 'test', @@ -22,6 +23,8 @@ const ONYXKEYS = { const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; const mockedReportActionsMap = getRandomReportActions(collectionKey); const mockedReportActionsKeys = Object.keys(mockedReportActionsMap); +const firstMockedReportActionKey = getAtIndex(mockedReportActionsKeys, 0); +const mockedReportActionKeyAt1000 = getAtIndex(mockedReportActionsKeys, 1000); let cache: typeof OnyxCache; @@ -54,7 +57,7 @@ describe('OnyxCache', () => { describe('addKey', () => { test('one call adding one key', async () => { - await measureFunction(() => cache.addKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.addKey(firstMockedReportActionKey), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -62,7 +65,7 @@ describe('OnyxCache', () => { describe('addNullishStorageKey', () => { test('one call adding one key', async () => { - await measureFunction(() => cache.addNullishStorageKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.addNullishStorageKey(firstMockedReportActionKey), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -70,7 +73,7 @@ describe('OnyxCache', () => { describe('hasNullishStorageKey', () => { test('one call checking one key among 10k ones', async () => { - await measureFunction(() => cache.hasNullishStorageKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.hasNullishStorageKey(firstMockedReportActionKey), { beforeEach: async () => { resetCacheBeforeEachMeasure(); cache.setAllKeys(mockedReportActionsKeys); @@ -92,7 +95,7 @@ describe('OnyxCache', () => { describe('hasCacheForKey', () => { test('one call checking one key among 10k ones', async () => { - await measureFunction(() => cache.hasCacheForKey(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.hasCacheForKey(firstMockedReportActionKey), { beforeEach: async () => { resetCacheBeforeEachMeasure(); cache.setAllKeys(mockedReportActionsKeys); @@ -103,7 +106,7 @@ describe('OnyxCache', () => { describe('get', () => { test('one call getting one key among 10k ones', async () => { - await measureFunction(() => cache.get(mockedReportActionsKeys[0]), { + await measureFunction(() => cache.get(firstMockedReportActionKey), { beforeEach: async () => { resetCacheBeforeEachMeasure(); for (const [k, v] of Object.entries(mockedReportActionsMap)) cache.set(k, v); @@ -114,8 +117,8 @@ describe('OnyxCache', () => { describe('set', () => { test('one call setting one key', async () => { - const value = mockedReportActionsMap[mockedReportActionsKeys[0]]; - await measureFunction(() => cache.set(mockedReportActionsKeys[0], value), { + const value = mockedReportActionsMap[firstMockedReportActionKey]; + await measureFunction(() => cache.set(firstMockedReportActionKey, value), { beforeEach: resetCacheBeforeEachMeasure, }); }); @@ -123,7 +126,7 @@ describe('OnyxCache', () => { describe('drop', () => { test('one call dropping one key among 10k ones', async () => { - await measureFunction(() => cache.drop(mockedReportActionsKeys[1000]), { + await measureFunction(() => cache.drop(mockedReportActionKeyAt1000), { beforeEach: async () => { resetCacheBeforeEachMeasure(); for (const [k, v] of Object.entries(mockedReportActionsMap)) cache.set(k, v); @@ -147,10 +150,10 @@ describe('OnyxCache', () => { describe('hasPendingTask', () => { test('one call checking one task', async () => { - await measureFunction(() => cache.hasPendingTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`), { + await measureFunction(() => cache.hasPendingTask(`${TASK.GET}:${firstMockedReportActionKey}`), { beforeEach: async () => { resetCacheBeforeEachMeasure(); - cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()); + cache.captureTask(`${TASK.GET}:${firstMockedReportActionKey}`, Promise.resolve()); }, }); }); @@ -158,10 +161,10 @@ describe('OnyxCache', () => { describe('getTaskPromise', () => { test('one call checking one task', async () => { - await measureAsyncFunction(() => cache.getTaskPromise(`${TASK.GET}:${mockedReportActionsKeys[0]}`)!, { + await measureAsyncFunction(() => cache.getTaskPromise(`${TASK.GET}:${firstMockedReportActionKey}`)!, { beforeEach: async () => { resetCacheBeforeEachMeasure(); - cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()); + cache.captureTask(`${TASK.GET}:${firstMockedReportActionKey}`, Promise.resolve()); }, }); }); @@ -169,14 +172,14 @@ describe('OnyxCache', () => { describe('captureTask', () => { test('one call capturing one task', async () => { - await measureAsyncFunction(() => cache.captureTask(`${TASK.GET}:${mockedReportActionsKeys[0]}`, Promise.resolve()), { + await measureAsyncFunction(() => cache.captureTask(`${TASK.GET}:${firstMockedReportActionKey}`, Promise.resolve()), { beforeEach: resetCacheBeforeEachMeasure, }); }); }); describe('hasValueChanged', () => { - const key = mockedReportActionsKeys[0]; + const key = firstMockedReportActionKey; const reportAction = mockedReportActionsMap[key]; const changedReportAction = createRandomReportAction(Number(reportAction.reportActionID)); diff --git a/tests/perf-test/OnyxConnectionManager.perf-test.ts b/tests/perf-test/OnyxConnectionManager.perf-test.ts index 50a95ea94..83bf5f11a 100644 --- a/tests/perf-test/OnyxConnectionManager.perf-test.ts +++ b/tests/perf-test/OnyxConnectionManager.perf-test.ts @@ -4,6 +4,8 @@ import type {Connection} from '../../lib/OnyxConnectionManager'; import connectionManager from '../../lib/OnyxConnectionManager'; import createDeferredTask from '../../lib/createDeferredTask'; import {getRandomReportActions} from '../utils/collections/reportActions'; +import getAtIndex from '../utils/getAtIndex'; +import {getOnyxConnectionManagerTestHarness} from '../utils/onyxConnectionManagerTestHarness'; const ONYXKEYS = { TEST_KEY: 'test', @@ -26,11 +28,13 @@ const ONYXKEYS = { const collectionKey = ONYXKEYS.COLLECTION.TEST_KEY; const mockedReportActionsMap = getRandomReportActions(collectionKey); const mockedReportActionsKeys = Object.keys(mockedReportActionsMap); +const firstMockedReportActionKey = getAtIndex(mockedReportActionsKeys, 0); // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -const generateConnectionID = connectionManager['generateConnectionID'].bind(connectionManager); -const fireCallbacks = connectionManager['fireCallbacks'].bind(connectionManager); +const connectionManagerTestHarness = getOnyxConnectionManagerTestHarness(); +const generateConnectionID = connectionManagerTestHarness.generateConnectionID.bind(connectionManager); +const fireCallbacks = connectionManagerTestHarness.fireCallbacks.bind(connectionManager); const resetConectionManagerAfterEachMeasure = () => { connectionManager.disconnectAll(); @@ -52,7 +56,7 @@ describe('OnyxConnectionManager', () => { describe('generateConnectionID', () => { test('one call', async () => { - await measureFunction(() => generateConnectionID({key: mockedReportActionsKeys[0]}), { + await measureFunction(() => generateConnectionID({key: firstMockedReportActionKey}), { afterEach: resetConectionManagerAfterEachMeasure, }); }); @@ -65,12 +69,12 @@ describe('OnyxConnectionManager', () => { await measureFunction(() => fireCallbacks(connectionID), { beforeEach: async () => { connectionID = connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: firstMockedReportActionKey, callback: jest.fn(), }).id; for (let i = 0; i < 9999; i++) { connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: firstMockedReportActionKey, callback: jest.fn(), }); } @@ -89,7 +93,7 @@ describe('OnyxConnectionManager', () => { async () => { const callback = createDeferredTask(); connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: firstMockedReportActionKey, callback: () => { callback.resolve?.(); }, @@ -117,7 +121,7 @@ describe('OnyxConnectionManager', () => { { beforeEach: async () => { connection = connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: firstMockedReportActionKey, callback: jest.fn(), }); }, @@ -136,7 +140,7 @@ describe('OnyxConnectionManager', () => { beforeEach: async () => { for (let i = 0; i < 10000; i++) { connectionManager.connect({ - key: mockedReportActionsKeys[0], + key: firstMockedReportActionKey, callback: jest.fn(), }); } diff --git a/tests/perf-test/OnyxSnapshotCache.perf-test.ts b/tests/perf-test/OnyxSnapshotCache.perf-test.ts index fde86da13..7d39d391d 100644 --- a/tests/perf-test/OnyxSnapshotCache.perf-test.ts +++ b/tests/perf-test/OnyxSnapshotCache.perf-test.ts @@ -2,6 +2,7 @@ import {measureFunction} from 'reassure'; import {OnyxSnapshotCache} from '../../lib/OnyxSnapshotCache'; import type {UseOnyxOptions, UseOnyxResult, UseOnyxSelector} from '../../lib/useOnyx'; import type {OnyxKey} from '../../lib'; +import getAtIndex from '../utils/getAtIndex'; // Define types for test data type MockData = { @@ -179,7 +180,7 @@ describe('OnyxSnapshotCache', () => { // Pre-populate cache with 1000 entries for (let i = 0; i < 1000; i++) { const key = `test_key_${i}`; - const result = mockResults[i]; + const result = getAtIndex(mockResults, i); cache.setCachedResult(key, `cache_key_${i}`, result); } // Set our target entry diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index 92f1cbb39..fc195a488 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -1,9 +1,18 @@ import Onyx from '../../lib'; import {getDevToolsInstance} from '../../lib/DevTools'; -import type {DevtoolsConnection, RealDevTools as RealDevToolsType} from '../../lib/DevTools'; +import type {DevtoolsConnection} from '../../lib/DevTools'; import RealDevTools from '../../lib/DevTools/RealDevTools'; import utils from '../../lib/utils'; +type RealDevToolsTestHarness = { + state: Record; + defaultState: Record; +}; + +function getDevToolsTestHarness(): RealDevToolsTestHarness { + return getDevToolsInstance() as RealDevToolsTestHarness; +} + const ONYX_KEYS = { NUM_KEY: 'numKey', OBJECT_KEY: 'objectKey', @@ -68,12 +77,12 @@ describe('DevTools', () => { expect(initMock).toHaveBeenCalledWith(initialKeyStates); }); it('Sets the default state correctly', () => { - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['defaultState']).toEqual(initialKeyStates); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.defaultState).toEqual(initialKeyStates); }); it('Sets the internal state correctly', () => { - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(initialKeyStates); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(initialKeyStates); }); }); @@ -90,8 +99,8 @@ describe('DevTools', () => { }); it('Sets the internal state correctly', async () => { await Onyx.set(ONYX_KEYS.SOME_KEY, 3); - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({ + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ ...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3, }); @@ -111,8 +120,8 @@ describe('DevTools', () => { }); it('Sets the internal state correctly', async () => { await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(mergedObject); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedObject); }); }); @@ -129,8 +138,8 @@ describe('DevTools', () => { }); it('Sets the internal state correctly', async () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(mergedCollection); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedCollection); }); }); @@ -147,8 +156,8 @@ describe('DevTools', () => { }); it('Sets the internal state correctly', async () => { await Onyx.multiSet(exampleCollection); - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual(mergedCollection); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedCollection); }); }); @@ -165,8 +174,8 @@ describe('DevTools', () => { it('Clears internal state correctly', async () => { await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); await Onyx.clear([ONYX_KEYS.NUM_KEY]); - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({ + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ ...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2, }); @@ -175,8 +184,8 @@ describe('DevTools', () => { it('Preserves collection member keys when a collection key is passed to keysToPreserve', async () => { await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); - const devToolsInstance = getDevToolsInstance() as RealDevToolsType; - expect(devToolsInstance['state']).toEqual({ + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ ...initialKeyStates, ...exampleCollection, }); diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts index b6e427a9d..857324f62 100644 --- a/tests/unit/OnyxConnectionManagerTest.ts +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -3,13 +3,15 @@ import Onyx from '../../lib'; import type {Connection} from '../../lib/OnyxConnectionManager'; import connectionManager from '../../lib/OnyxConnectionManager'; import StorageMock from '../../lib/storage'; +import {getOnyxConnectionManagerTestHarness} from '../utils/onyxConnectionManagerTestHarness'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // We need access to some internal properties of `connectionManager` during the tests but they are private, // so this workaround allows us to have access to them. -const connectionsMap = connectionManager['connectionsMap']; -const generateConnectionID = connectionManager['generateConnectionID'].bind(connectionManager); -const getSessionID = () => connectionManager['sessionID']; +const testConnectionManager = getOnyxConnectionManagerTestHarness(); +const connectionsMap = testConnectionManager.connectionsMap; +const generateConnectionID = testConnectionManager.generateConnectionID.bind(connectionManager); +const getSessionID = () => testConnectionManager.sessionID; const ONYXKEYS = { TEST_KEY: 'test', diff --git a/tests/utils/getAtIndex.ts b/tests/utils/getAtIndex.ts new file mode 100644 index 000000000..e27069613 --- /dev/null +++ b/tests/utils/getAtIndex.ts @@ -0,0 +1,15 @@ +/** + * Returns the value at the given index, throwing if it is missing. + * Prefer this over non-null assertions when satisfying rulesdir/prefer-at. + */ +function getAtIndex(array: T[], index: number): T { + const value = array.at(index); + + if (value === undefined) { + throw new Error(`Expected value at index ${index}`); + } + + return value; +} + +export default getAtIndex; diff --git a/tests/utils/onyxConnectionManagerTestHarness.ts b/tests/utils/onyxConnectionManagerTestHarness.ts new file mode 100644 index 000000000..7af7d770b --- /dev/null +++ b/tests/utils/onyxConnectionManagerTestHarness.ts @@ -0,0 +1,16 @@ +import connectionManager from '../../lib/OnyxConnectionManager'; + +export type ConnectionMetadataForTest = { + subscriptionID: number; +}; + +type OnyxConnectionManagerTestHarness = { + connectionsMap: Map; + generateConnectionID: (options: {key: string}) => string; + sessionID: string; + fireCallbacks: (connectionID: string) => void; +}; + +export function getOnyxConnectionManagerTestHarness(): OnyxConnectionManagerTestHarness { + return connectionManager as OnyxConnectionManagerTestHarness; +} From c30508ecd53b72eece1d1e211414fea4e8bf94ae Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 2 Jul 2026 17:01:44 -0700 Subject: [PATCH 18/20] Fix typecheck errors in test harness casts Use unknown intermediate casts and ConnectOptions for generateConnectionID so test harness types match the real API. Co-authored-by: Cursor --- tests/unit/DevToolsTest.ts | 359 +++++++++--------- .../utils/onyxConnectionManagerTestHarness.ts | 18 +- 2 files changed, 199 insertions(+), 178 deletions(-) diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index fc195a488..b2b01b83e 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -1,194 +1,211 @@ -import Onyx from '../../lib'; -import {getDevToolsInstance} from '../../lib/DevTools'; -import type {DevtoolsConnection} from '../../lib/DevTools'; -import RealDevTools from '../../lib/DevTools/RealDevTools'; -import utils from '../../lib/utils'; +import Onyx from "../../lib"; +import { getDevToolsInstance } from "../../lib/DevTools"; +import type { DevtoolsConnection } from "../../lib/DevTools"; +import RealDevTools from "../../lib/DevTools/RealDevTools"; +import utils from "../../lib/utils"; type RealDevToolsTestHarness = { - state: Record; - defaultState: Record; + state: Record; + defaultState: Record; }; function getDevToolsTestHarness(): RealDevToolsTestHarness { - return getDevToolsInstance() as RealDevToolsTestHarness; + return getDevToolsInstance() as unknown as RealDevToolsTestHarness; } const ONYX_KEYS = { - NUM_KEY: 'numKey', - OBJECT_KEY: 'objectKey', - SOME_KEY: 'someKey', - COLLECTION: { - NUM_KEY: 'test_', - TEST_CONNECT_COLLECTION: 'testConnectCollection_', - TEST_POLICY: 'testPolicy_', - TEST_UPDATE: 'testUpdate_', - }, + NUM_KEY: "numKey", + OBJECT_KEY: "objectKey", + SOME_KEY: "someKey", + COLLECTION: { + NUM_KEY: "test_", + TEST_CONNECT_COLLECTION: "testConnectCollection_", + TEST_POLICY: "testPolicy_", + TEST_UPDATE: "testUpdate_", + }, }; const initialKeyStates = { - [ONYX_KEYS.NUM_KEY]: 1, - [ONYX_KEYS.OBJECT_KEY]: {id: 42}, + [ONYX_KEYS.NUM_KEY]: 1, + [ONYX_KEYS.OBJECT_KEY]: { id: 42 }, }; const exampleCollection = { - [`${ONYX_KEYS.COLLECTION.NUM_KEY}1`]: 1, - [`${ONYX_KEYS.COLLECTION.NUM_KEY}2`]: 2, + [`${ONYX_KEYS.COLLECTION.NUM_KEY}1`]: 1, + [`${ONYX_KEYS.COLLECTION.NUM_KEY}2`]: 2, }; -const exampleObject = {name: 'Pedro'}; +const exampleObject = { name: "Pedro" }; -const mergedCollection = {...initialKeyStates, ...exampleCollection}; +const mergedCollection = { ...initialKeyStates, ...exampleCollection }; const mergedObject = { - ...initialKeyStates, - [ONYX_KEYS.OBJECT_KEY]: {...exampleObject, id: 42}, + ...initialKeyStates, + [ONYX_KEYS.OBJECT_KEY]: { ...exampleObject, id: 42 }, }; -describe('DevTools', () => { - let initMock: jest.Mock; - let sendMock: jest.Mock; - - beforeEach(() => { - // Mock DevTools - need to mock the connectViaExtension method BEFORE Onyx.init() is called - initMock = jest.fn(); - sendMock = jest.fn(); - - // Mock the connectViaExtension method to return our mock connection - // This needs to happen before RealDevTools is instantiated - const mockConnection = { - init: initMock, - send: sendMock, - } as unknown as DevtoolsConnection; - jest.spyOn(RealDevTools.prototype, 'connectViaExtension').mockReturnValue(mockConnection); - - Onyx.init({ - keys: ONYX_KEYS, - initialKeyStates, - enableDevTools: true, // Enable DevTools for testing - }); - }); - afterEach(() => { - Onyx.clear(); - jest.restoreAllMocks(); - }); - - describe('Init', () => { - it('Sends the initial state correctly to the extension', () => { - expect(initMock).toHaveBeenCalledWith(initialKeyStates); - }); - it('Sets the default state correctly', () => { - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.defaultState).toEqual(initialKeyStates); - }); - it('Sets the internal state correctly', () => { - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(initialKeyStates); - }); - }); - - describe('Set', () => { - it('Sends the set state correctly to the extension', async () => { - await Onyx.set(ONYX_KEYS.SOME_KEY, 3); - expect(sendMock).toHaveBeenCalledWith( - { - payload: 3, - type: utils.formatActionName(Onyx.METHOD.SET, ONYX_KEYS.SOME_KEY), - }, - {...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3}, - ); - }); - it('Sets the internal state correctly', async () => { - await Onyx.set(ONYX_KEYS.SOME_KEY, 3); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual({ - ...initialKeyStates, - [ONYX_KEYS.SOME_KEY]: 3, - }); - }); - }); - - describe('Merge', () => { - it('Sends the merged state correctly to the extension', async () => { - await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); - expect(sendMock).toHaveBeenCalledWith( - { - payload: exampleObject, - type: utils.formatActionName(Onyx.METHOD.MERGE, ONYX_KEYS.OBJECT_KEY), - }, - mergedObject, - ); - }); - it('Sets the internal state correctly', async () => { - await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(mergedObject); - }); - }); - - describe('MergeCollection', () => { - it('Sends the mergecollection state correctly to the extension', async () => { - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); - expect(sendMock).toHaveBeenCalledWith( - { - payload: exampleCollection, - type: utils.formatActionName(Onyx.METHOD.MERGE_COLLECTION), - }, - mergedCollection, - ); - }); - it('Sets the internal state correctly', async () => { - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(mergedCollection); - }); - }); - - describe('MultiSet', () => { - it('Sends the multiset state correctly to the extension', async () => { - await Onyx.multiSet(exampleCollection); - expect(sendMock).toHaveBeenCalledWith( - { - payload: exampleCollection, - type: utils.formatActionName(Onyx.METHOD.MULTI_SET), - }, - mergedCollection, - ); - }); - it('Sets the internal state correctly', async () => { - await Onyx.multiSet(exampleCollection); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(mergedCollection); - }); - }); - - describe('Clear', () => { - it('Sends the clear state correctly to the extension when no keys should be preserved', async () => { - await Onyx.clear(); - expect(sendMock).toHaveBeenCalledWith({payload: undefined, type: 'CLEAR'}, initialKeyStates); - }); - it('Sends the clear state correctly to the extension when there are keys that should be preserved', async () => { - await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); - await Onyx.clear([ONYX_KEYS.NUM_KEY]); - expect(sendMock).toHaveBeenCalledWith({payload: undefined, type: 'CLEAR'}, {...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2}); - }); - it('Clears internal state correctly', async () => { - await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); - await Onyx.clear([ONYX_KEYS.NUM_KEY]); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual({ - ...initialKeyStates, - [ONYX_KEYS.NUM_KEY]: 2, - }); - }); - - it('Preserves collection member keys when a collection key is passed to keysToPreserve', async () => { - await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); - await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual({ - ...initialKeyStates, - ...exampleCollection, - }); - }); +describe("DevTools", () => { + let initMock: jest.Mock; + let sendMock: jest.Mock; + + beforeEach(() => { + // Mock DevTools - need to mock the connectViaExtension method BEFORE Onyx.init() is called + initMock = jest.fn(); + sendMock = jest.fn(); + + // Mock the connectViaExtension method to return our mock connection + // This needs to happen before RealDevTools is instantiated + const mockConnection = { + init: initMock, + send: sendMock, + } as unknown as DevtoolsConnection; + jest + .spyOn(RealDevTools.prototype, "connectViaExtension") + .mockReturnValue(mockConnection); + + Onyx.init({ + keys: ONYX_KEYS, + initialKeyStates, + enableDevTools: true, // Enable DevTools for testing }); + }); + afterEach(() => { + Onyx.clear(); + jest.restoreAllMocks(); + }); + + describe("Init", () => { + it("Sends the initial state correctly to the extension", () => { + expect(initMock).toHaveBeenCalledWith(initialKeyStates); + }); + it("Sets the default state correctly", () => { + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.defaultState).toEqual(initialKeyStates); + }); + it("Sets the internal state correctly", () => { + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(initialKeyStates); + }); + }); + + describe("Set", () => { + it("Sends the set state correctly to the extension", async () => { + await Onyx.set(ONYX_KEYS.SOME_KEY, 3); + expect(sendMock).toHaveBeenCalledWith( + { + payload: 3, + type: utils.formatActionName(Onyx.METHOD.SET, ONYX_KEYS.SOME_KEY), + }, + { ...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3 }, + ); + }); + it("Sets the internal state correctly", async () => { + await Onyx.set(ONYX_KEYS.SOME_KEY, 3); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ + ...initialKeyStates, + [ONYX_KEYS.SOME_KEY]: 3, + }); + }); + }); + + describe("Merge", () => { + it("Sends the merged state correctly to the extension", async () => { + await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleObject, + type: utils.formatActionName(Onyx.METHOD.MERGE, ONYX_KEYS.OBJECT_KEY), + }, + mergedObject, + ); + }); + it("Sets the internal state correctly", async () => { + await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedObject); + }); + }); + + describe("MergeCollection", () => { + it("Sends the mergecollection state correctly to the extension", async () => { + await Onyx.mergeCollection( + ONYX_KEYS.COLLECTION.NUM_KEY, + exampleCollection, + ); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleCollection, + type: utils.formatActionName(Onyx.METHOD.MERGE_COLLECTION), + }, + mergedCollection, + ); + }); + it("Sets the internal state correctly", async () => { + await Onyx.mergeCollection( + ONYX_KEYS.COLLECTION.NUM_KEY, + exampleCollection, + ); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedCollection); + }); + }); + + describe("MultiSet", () => { + it("Sends the multiset state correctly to the extension", async () => { + await Onyx.multiSet(exampleCollection); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleCollection, + type: utils.formatActionName(Onyx.METHOD.MULTI_SET), + }, + mergedCollection, + ); + }); + it("Sets the internal state correctly", async () => { + await Onyx.multiSet(exampleCollection); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedCollection); + }); + }); + + describe("Clear", () => { + it("Sends the clear state correctly to the extension when no keys should be preserved", async () => { + await Onyx.clear(); + expect(sendMock).toHaveBeenCalledWith( + { payload: undefined, type: "CLEAR" }, + initialKeyStates, + ); + }); + it("Sends the clear state correctly to the extension when there are keys that should be preserved", async () => { + await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); + await Onyx.clear([ONYX_KEYS.NUM_KEY]); + expect(sendMock).toHaveBeenCalledWith( + { payload: undefined, type: "CLEAR" }, + { ...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2 }, + ); + }); + it("Clears internal state correctly", async () => { + await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); + await Onyx.clear([ONYX_KEYS.NUM_KEY]); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ + ...initialKeyStates, + [ONYX_KEYS.NUM_KEY]: 2, + }); + }); + + it("Preserves collection member keys when a collection key is passed to keysToPreserve", async () => { + await Onyx.mergeCollection( + ONYX_KEYS.COLLECTION.NUM_KEY, + exampleCollection, + ); + await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ + ...initialKeyStates, + ...exampleCollection, + }); + }); + }); }); diff --git a/tests/utils/onyxConnectionManagerTestHarness.ts b/tests/utils/onyxConnectionManagerTestHarness.ts index 7af7d770b..eb21de725 100644 --- a/tests/utils/onyxConnectionManagerTestHarness.ts +++ b/tests/utils/onyxConnectionManagerTestHarness.ts @@ -1,16 +1,20 @@ -import connectionManager from '../../lib/OnyxConnectionManager'; +import connectionManager from "../../lib/OnyxConnectionManager"; +import type { ConnectOptions } from "../../lib/Onyx"; +import type { OnyxKey } from "../../lib/types"; export type ConnectionMetadataForTest = { - subscriptionID: number; + subscriptionID: number; }; type OnyxConnectionManagerTestHarness = { - connectionsMap: Map; - generateConnectionID: (options: {key: string}) => string; - sessionID: string; - fireCallbacks: (connectionID: string) => void; + connectionsMap: Map; + generateConnectionID: ( + connectOptions: ConnectOptions, + ) => string; + sessionID: string; + fireCallbacks: (connectionID: string) => void; }; export function getOnyxConnectionManagerTestHarness(): OnyxConnectionManagerTestHarness { - return connectionManager as OnyxConnectionManagerTestHarness; + return connectionManager as unknown as OnyxConnectionManagerTestHarness; } From a2561d53366e2fb7750e7f96254652b9d7208c73 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 2 Jul 2026 17:03:47 -0700 Subject: [PATCH 19/20] Run prettier on test harness files Fix lint CI prettier diff check after typecheck harness changes. Co-authored-by: Cursor --- tests/unit/DevToolsTest.ts | 359 +++++++++--------- .../utils/onyxConnectionManagerTestHarness.ts | 20 +- 2 files changed, 180 insertions(+), 199 deletions(-) diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index b2b01b83e..444c594c5 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -1,211 +1,194 @@ -import Onyx from "../../lib"; -import { getDevToolsInstance } from "../../lib/DevTools"; -import type { DevtoolsConnection } from "../../lib/DevTools"; -import RealDevTools from "../../lib/DevTools/RealDevTools"; -import utils from "../../lib/utils"; +import Onyx from '../../lib'; +import {getDevToolsInstance} from '../../lib/DevTools'; +import type {DevtoolsConnection} from '../../lib/DevTools'; +import RealDevTools from '../../lib/DevTools/RealDevTools'; +import utils from '../../lib/utils'; type RealDevToolsTestHarness = { - state: Record; - defaultState: Record; + state: Record; + defaultState: Record; }; function getDevToolsTestHarness(): RealDevToolsTestHarness { - return getDevToolsInstance() as unknown as RealDevToolsTestHarness; + return getDevToolsInstance() as unknown as RealDevToolsTestHarness; } const ONYX_KEYS = { - NUM_KEY: "numKey", - OBJECT_KEY: "objectKey", - SOME_KEY: "someKey", - COLLECTION: { - NUM_KEY: "test_", - TEST_CONNECT_COLLECTION: "testConnectCollection_", - TEST_POLICY: "testPolicy_", - TEST_UPDATE: "testUpdate_", - }, + NUM_KEY: 'numKey', + OBJECT_KEY: 'objectKey', + SOME_KEY: 'someKey', + COLLECTION: { + NUM_KEY: 'test_', + TEST_CONNECT_COLLECTION: 'testConnectCollection_', + TEST_POLICY: 'testPolicy_', + TEST_UPDATE: 'testUpdate_', + }, }; const initialKeyStates = { - [ONYX_KEYS.NUM_KEY]: 1, - [ONYX_KEYS.OBJECT_KEY]: { id: 42 }, + [ONYX_KEYS.NUM_KEY]: 1, + [ONYX_KEYS.OBJECT_KEY]: {id: 42}, }; const exampleCollection = { - [`${ONYX_KEYS.COLLECTION.NUM_KEY}1`]: 1, - [`${ONYX_KEYS.COLLECTION.NUM_KEY}2`]: 2, + [`${ONYX_KEYS.COLLECTION.NUM_KEY}1`]: 1, + [`${ONYX_KEYS.COLLECTION.NUM_KEY}2`]: 2, }; -const exampleObject = { name: "Pedro" }; +const exampleObject = {name: 'Pedro'}; -const mergedCollection = { ...initialKeyStates, ...exampleCollection }; +const mergedCollection = {...initialKeyStates, ...exampleCollection}; const mergedObject = { - ...initialKeyStates, - [ONYX_KEYS.OBJECT_KEY]: { ...exampleObject, id: 42 }, + ...initialKeyStates, + [ONYX_KEYS.OBJECT_KEY]: {...exampleObject, id: 42}, }; -describe("DevTools", () => { - let initMock: jest.Mock; - let sendMock: jest.Mock; - - beforeEach(() => { - // Mock DevTools - need to mock the connectViaExtension method BEFORE Onyx.init() is called - initMock = jest.fn(); - sendMock = jest.fn(); - - // Mock the connectViaExtension method to return our mock connection - // This needs to happen before RealDevTools is instantiated - const mockConnection = { - init: initMock, - send: sendMock, - } as unknown as DevtoolsConnection; - jest - .spyOn(RealDevTools.prototype, "connectViaExtension") - .mockReturnValue(mockConnection); - - Onyx.init({ - keys: ONYX_KEYS, - initialKeyStates, - enableDevTools: true, // Enable DevTools for testing +describe('DevTools', () => { + let initMock: jest.Mock; + let sendMock: jest.Mock; + + beforeEach(() => { + // Mock DevTools - need to mock the connectViaExtension method BEFORE Onyx.init() is called + initMock = jest.fn(); + sendMock = jest.fn(); + + // Mock the connectViaExtension method to return our mock connection + // This needs to happen before RealDevTools is instantiated + const mockConnection = { + init: initMock, + send: sendMock, + } as unknown as DevtoolsConnection; + jest.spyOn(RealDevTools.prototype, 'connectViaExtension').mockReturnValue(mockConnection); + + Onyx.init({ + keys: ONYX_KEYS, + initialKeyStates, + enableDevTools: true, // Enable DevTools for testing + }); + }); + afterEach(() => { + Onyx.clear(); + jest.restoreAllMocks(); + }); + + describe('Init', () => { + it('Sends the initial state correctly to the extension', () => { + expect(initMock).toHaveBeenCalledWith(initialKeyStates); + }); + it('Sets the default state correctly', () => { + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.defaultState).toEqual(initialKeyStates); + }); + it('Sets the internal state correctly', () => { + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(initialKeyStates); + }); + }); + + describe('Set', () => { + it('Sends the set state correctly to the extension', async () => { + await Onyx.set(ONYX_KEYS.SOME_KEY, 3); + expect(sendMock).toHaveBeenCalledWith( + { + payload: 3, + type: utils.formatActionName(Onyx.METHOD.SET, ONYX_KEYS.SOME_KEY), + }, + {...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3}, + ); + }); + it('Sets the internal state correctly', async () => { + await Onyx.set(ONYX_KEYS.SOME_KEY, 3); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ + ...initialKeyStates, + [ONYX_KEYS.SOME_KEY]: 3, + }); + }); + }); + + describe('Merge', () => { + it('Sends the merged state correctly to the extension', async () => { + await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleObject, + type: utils.formatActionName(Onyx.METHOD.MERGE, ONYX_KEYS.OBJECT_KEY), + }, + mergedObject, + ); + }); + it('Sets the internal state correctly', async () => { + await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedObject); + }); + }); + + describe('MergeCollection', () => { + it('Sends the mergecollection state correctly to the extension', async () => { + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleCollection, + type: utils.formatActionName(Onyx.METHOD.MERGE_COLLECTION), + }, + mergedCollection, + ); + }); + it('Sets the internal state correctly', async () => { + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedCollection); + }); + }); + + describe('MultiSet', () => { + it('Sends the multiset state correctly to the extension', async () => { + await Onyx.multiSet(exampleCollection); + expect(sendMock).toHaveBeenCalledWith( + { + payload: exampleCollection, + type: utils.formatActionName(Onyx.METHOD.MULTI_SET), + }, + mergedCollection, + ); + }); + it('Sets the internal state correctly', async () => { + await Onyx.multiSet(exampleCollection); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual(mergedCollection); + }); + }); + + describe('Clear', () => { + it('Sends the clear state correctly to the extension when no keys should be preserved', async () => { + await Onyx.clear(); + expect(sendMock).toHaveBeenCalledWith({payload: undefined, type: 'CLEAR'}, initialKeyStates); + }); + it('Sends the clear state correctly to the extension when there are keys that should be preserved', async () => { + await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); + await Onyx.clear([ONYX_KEYS.NUM_KEY]); + expect(sendMock).toHaveBeenCalledWith({payload: undefined, type: 'CLEAR'}, {...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2}); + }); + it('Clears internal state correctly', async () => { + await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); + await Onyx.clear([ONYX_KEYS.NUM_KEY]); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ + ...initialKeyStates, + [ONYX_KEYS.NUM_KEY]: 2, + }); + }); + + it('Preserves collection member keys when a collection key is passed to keysToPreserve', async () => { + await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.NUM_KEY, exampleCollection); + await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); + const devToolsInstance = getDevToolsTestHarness(); + expect(devToolsInstance.state).toEqual({ + ...initialKeyStates, + ...exampleCollection, + }); + }); }); - }); - afterEach(() => { - Onyx.clear(); - jest.restoreAllMocks(); - }); - - describe("Init", () => { - it("Sends the initial state correctly to the extension", () => { - expect(initMock).toHaveBeenCalledWith(initialKeyStates); - }); - it("Sets the default state correctly", () => { - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.defaultState).toEqual(initialKeyStates); - }); - it("Sets the internal state correctly", () => { - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(initialKeyStates); - }); - }); - - describe("Set", () => { - it("Sends the set state correctly to the extension", async () => { - await Onyx.set(ONYX_KEYS.SOME_KEY, 3); - expect(sendMock).toHaveBeenCalledWith( - { - payload: 3, - type: utils.formatActionName(Onyx.METHOD.SET, ONYX_KEYS.SOME_KEY), - }, - { ...initialKeyStates, [ONYX_KEYS.SOME_KEY]: 3 }, - ); - }); - it("Sets the internal state correctly", async () => { - await Onyx.set(ONYX_KEYS.SOME_KEY, 3); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual({ - ...initialKeyStates, - [ONYX_KEYS.SOME_KEY]: 3, - }); - }); - }); - - describe("Merge", () => { - it("Sends the merged state correctly to the extension", async () => { - await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); - expect(sendMock).toHaveBeenCalledWith( - { - payload: exampleObject, - type: utils.formatActionName(Onyx.METHOD.MERGE, ONYX_KEYS.OBJECT_KEY), - }, - mergedObject, - ); - }); - it("Sets the internal state correctly", async () => { - await Onyx.merge(ONYX_KEYS.OBJECT_KEY, exampleObject); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(mergedObject); - }); - }); - - describe("MergeCollection", () => { - it("Sends the mergecollection state correctly to the extension", async () => { - await Onyx.mergeCollection( - ONYX_KEYS.COLLECTION.NUM_KEY, - exampleCollection, - ); - expect(sendMock).toHaveBeenCalledWith( - { - payload: exampleCollection, - type: utils.formatActionName(Onyx.METHOD.MERGE_COLLECTION), - }, - mergedCollection, - ); - }); - it("Sets the internal state correctly", async () => { - await Onyx.mergeCollection( - ONYX_KEYS.COLLECTION.NUM_KEY, - exampleCollection, - ); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(mergedCollection); - }); - }); - - describe("MultiSet", () => { - it("Sends the multiset state correctly to the extension", async () => { - await Onyx.multiSet(exampleCollection); - expect(sendMock).toHaveBeenCalledWith( - { - payload: exampleCollection, - type: utils.formatActionName(Onyx.METHOD.MULTI_SET), - }, - mergedCollection, - ); - }); - it("Sets the internal state correctly", async () => { - await Onyx.multiSet(exampleCollection); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual(mergedCollection); - }); - }); - - describe("Clear", () => { - it("Sends the clear state correctly to the extension when no keys should be preserved", async () => { - await Onyx.clear(); - expect(sendMock).toHaveBeenCalledWith( - { payload: undefined, type: "CLEAR" }, - initialKeyStates, - ); - }); - it("Sends the clear state correctly to the extension when there are keys that should be preserved", async () => { - await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); - await Onyx.clear([ONYX_KEYS.NUM_KEY]); - expect(sendMock).toHaveBeenCalledWith( - { payload: undefined, type: "CLEAR" }, - { ...initialKeyStates, [ONYX_KEYS.NUM_KEY]: 2 }, - ); - }); - it("Clears internal state correctly", async () => { - await Onyx.merge(ONYX_KEYS.NUM_KEY, 2); - await Onyx.clear([ONYX_KEYS.NUM_KEY]); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual({ - ...initialKeyStates, - [ONYX_KEYS.NUM_KEY]: 2, - }); - }); - - it("Preserves collection member keys when a collection key is passed to keysToPreserve", async () => { - await Onyx.mergeCollection( - ONYX_KEYS.COLLECTION.NUM_KEY, - exampleCollection, - ); - await Onyx.clear([ONYX_KEYS.COLLECTION.NUM_KEY]); - const devToolsInstance = getDevToolsTestHarness(); - expect(devToolsInstance.state).toEqual({ - ...initialKeyStates, - ...exampleCollection, - }); - }); - }); }); diff --git a/tests/utils/onyxConnectionManagerTestHarness.ts b/tests/utils/onyxConnectionManagerTestHarness.ts index eb21de725..02add2447 100644 --- a/tests/utils/onyxConnectionManagerTestHarness.ts +++ b/tests/utils/onyxConnectionManagerTestHarness.ts @@ -1,20 +1,18 @@ -import connectionManager from "../../lib/OnyxConnectionManager"; -import type { ConnectOptions } from "../../lib/Onyx"; -import type { OnyxKey } from "../../lib/types"; +import connectionManager from '../../lib/OnyxConnectionManager'; +import type {ConnectOptions} from '../../lib/Onyx'; +import type {OnyxKey} from '../../lib/types'; export type ConnectionMetadataForTest = { - subscriptionID: number; + subscriptionID: number; }; type OnyxConnectionManagerTestHarness = { - connectionsMap: Map; - generateConnectionID: ( - connectOptions: ConnectOptions, - ) => string; - sessionID: string; - fireCallbacks: (connectionID: string) => void; + connectionsMap: Map; + generateConnectionID: (connectOptions: ConnectOptions) => string; + sessionID: string; + fireCallbacks: (connectionID: string) => void; }; export function getOnyxConnectionManagerTestHarness(): OnyxConnectionManagerTestHarness { - return connectionManager as unknown as OnyxConnectionManagerTestHarness; + return connectionManager as unknown as OnyxConnectionManagerTestHarness; } From d32edbdbe931c81d9d0a9856f9604b3af91fa0f8 Mon Sep 17 00:00:00 2001 From: rory Date: Thu, 2 Jul 2026 17:20:32 -0700 Subject: [PATCH 20/20] Remove unused SQLiteProvider naming-convention override The shared typescript config does not lint typeProperty, so the snake_case exception had no effect. Document why the tests override must repeat the base naming-convention rules. Co-authored-by: Cursor --- eslint.config.mjs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index fc8c0f8d5..d97c94e0e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,6 +63,8 @@ export default defineConfig([ '@typescript-eslint/no-use-before-define': 'off', }, }, + // Flat config replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript + // and adds objectLiteralProperty exceptions for Onyx key strings (underscores) and numeric collection IDs. { files: ['tests/**/*'], rules: { @@ -122,18 +124,6 @@ export default defineConfig([ 'import/extensions': 'off', }, }, - { - files: ['lib/storage/providers/SQLiteProvider.ts'], - rules: { - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'typeProperty', - format: ['camelCase', 'snake_case'], - }, - ], - }, - }, { files: ['lib/storage/providers/MemoryOnlyProvider.ts'], languageOptions: {