diff --git a/package-lock.json b/package-lock.json index 2a4b891..3c02271 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { + "@normy/core": "^0.14.0", "utility-types": "^3.11.0" }, "devDependencies": { @@ -2995,7 +2996,6 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -6176,6 +6176,15 @@ "node": ">=12.4.0" } }, + "node_modules/@normy/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@normy/core/-/core-0.14.0.tgz", + "integrity": "sha512-3I0S62BNEIlvSL10TutHsLvTxguRoXEjs6b/udSUnxlKFVgrAIdW4F4tJDiZsJQrWRi6A6V3Eo5Dzji3zyn40Q==", + "dependencies": { + "@babel/runtime": "^7.23.5", + "deepmerge": "4.3.1" + } + }, "node_modules/@okikio/sharedworker": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@okikio/sharedworker/-/sharedworker-1.1.0.tgz", @@ -8764,17 +8773,6 @@ "@swc/counter": "^0.1.3" } }, - "node_modules/@tanstack/query-core": { - "version": "5.71.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.71.1.tgz", - "integrity": "sha512-4+ZswCHOfJX+ikhXNoocamTUmJcHtB+Ljjz/oJkC7/eKB5IrzEwR4vEwZUENiPi+wISucJHR5TUbuuJ26w3kdQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/react-query": { "version": "5.71.1", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.71.1.tgz", @@ -8792,6 +8790,16 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query/node_modules/@tanstack/query-core": { + "version": "5.71.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.71.1.tgz", + "integrity": "sha512-4+ZswCHOfJX+ikhXNoocamTUmJcHtB+Ljjz/oJkC7/eKB5IrzEwR4vEwZUENiPi+wISucJHR5TUbuuJ26w3kdQ==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -12745,7 +12753,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -22261,7 +22268,6 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, "license": "MIT" }, "node_modules/regenerator-transform": { diff --git a/package.json b/package.json index e957da6..383cabb 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@normy/core": "^0.14.0", "utility-types": "^3.11.0" }, "devDependencies": { diff --git a/src/core/index.ts b/src/core/index.ts index 1ce648f..737ad3a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -18,6 +18,7 @@ export type { export type {DataManager} from './types/DataManager'; export type {DataLoaderStatus} from './types/DataLoaderStatus'; export type {InvalidateRepeatOptions, InvalidateOptions} from './types/DataManagerOptions'; +export type {Normalizer, NormalizerConfig, OptimisticConfig} from './types/Normalizer'; export {idle} from './constants'; diff --git a/src/core/types/DataManager.ts b/src/core/types/DataManager.ts index 3228cbe..61604a0 100644 --- a/src/core/types/DataManager.ts +++ b/src/core/types/DataManager.ts @@ -1,7 +1,16 @@ +import type {Data} from '@normy/core'; + import type {InvalidateOptions} from './DataManagerOptions'; import type {AnyDataSource, DataSourceParams, DataSourceTag} from './DataSource'; +import type {Normalizer} from './Normalizer'; export interface DataManager { + normalizer?: Normalizer; + + optimisticUpdate(mutationData: Data): void; + + invalidateData(data: Data): void; + invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions): Promise; invalidateTags(tags: DataSourceTag[], invalidateOptions?: InvalidateOptions): Promise; diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts new file mode 100644 index 0000000..092d37f --- /dev/null +++ b/src/core/types/Normalizer.ts @@ -0,0 +1,32 @@ +import type {NormalizerConfig as NormalizeConfigBase} from '@normy/core'; +import type {Data, NormalizedData} from '@normy/core/types/types'; + +export interface OptimisticConfig { + /** Automatically calculate rollback data, defaults to true */ + autoCalculateRollback?: boolean; + /** Whether debug logging is enabled */ + devLogging?: boolean; +} + +export interface NormalizerConfig extends NormalizeConfigBase { + initialData?: NormalizedData; + optimistic?: boolean | OptimisticConfig; + invalidate?: boolean; +} + +export interface Normalizer { + getNormalizedData: () => NormalizedData; + clearNormalizedData: () => void; + setQuery: (queryKey: string, queryData: Data) => void; + removeQuery: (queryKey: string) => void; + getQueriesToUpdate: (mutationData: Data) => { + queryKey: string; + data: Data; + }[]; + getObjectById: (id: string, exampleObject?: T) => T | undefined; + getQueryFragment: (fragment: Data, exampleObject?: T) => T | undefined; + getDependentQueries: (mutationData: Data) => readonly string[]; + getDependentQueriesByIds: (ids: ReadonlyArray) => readonly string[]; + getCurrentData: (newData: T) => T | undefined; + log: (...messages: unknown[]) => void; +} diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 3cae85b..0805c4f 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -1,4 +1,6 @@ -import type {InvalidateQueryFilters, QueryClientConfig} from '@tanstack/react-query'; +import type {Data} from '@normy/core'; +import {createNormalizer} from '@normy/core'; +import type {InvalidateQueryFilters, QueryClientConfig, QueryKey} from '@tanstack/react-query'; import {QueryClient} from '@tanstack/react-query'; import { @@ -6,15 +8,24 @@ import { type DataManager, type DataSourceParams, type DataSourceTag, + type Normalizer, + type NormalizerConfig, composeFullKey, hasTag, } from '../core'; import type {InvalidateOptions, InvalidateRepeatOptions} from '../core/types/DataManagerOptions'; -export type ClientDataManagerConfig = QueryClientConfig; +import type {QueryNormalizer} from './types/normalizer'; +import {createQueryNormalizer} from './utils/normalize'; + +export interface ClientDataManagerConfig extends QueryClientConfig { + normalizerConfig?: NormalizerConfig | boolean; +} export class ClientDataManager implements DataManager { readonly queryClient: QueryClient; + readonly normalizer?: Normalizer | undefined; + readonly queryNormalizer?: QueryNormalizer | undefined; constructor(config: ClientDataManagerConfig = {}) { this.queryClient = new QueryClient({ @@ -31,6 +42,53 @@ export class ClientDataManager implements DataManager { }, }, }); + + this.normalizer = this.createNormalize(config.normalizerConfig); + this.queryNormalizer = createQueryNormalizer( + this.normalizer, + this.queryClient, + config.normalizerConfig, + (data) => this.optimisticUpdate(data), + (data) => this.invalidateData(data), + ); + } + + optimisticUpdate(mutationData: Data) { + if (!this.normalizer) { + return; + } + + const queriesToUpdate = this.normalizer.getQueriesToUpdate(mutationData); + + queriesToUpdate.forEach((query) => { + const queryKey = JSON.parse(query.queryKey) as QueryKey; + + const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); + + const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; + const isInvalidated = cachedQuery?.state.isInvalidated; + const error = cachedQuery?.state.error; + const status = cachedQuery?.state.status; + + this.queryClient.setQueryData(queryKey, () => query.data, { + updatedAt: dataUpdatedAt, + }); + + cachedQuery?.setState({isInvalidated, error, status}); + }); + } + + invalidateData(data: Data): void { + if (!this.normalizer) { + return; + } + + const queriesToUpdate = this.normalizer.getQueriesToUpdate(data); + + queriesToUpdate.forEach((query) => { + const queryKey = JSON.parse(query.queryKey) as QueryKey; + this.queryClient.invalidateQueries({queryKey}); + }); } invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions) { @@ -129,4 +187,18 @@ export class ClientDataManager implements DataManager { setTimeout(invalidate, repeat.interval * i); } } + + private createNormalize( + config: boolean | NormalizerConfig | undefined, + ): Normalizer | undefined { + if (!config) { + return undefined; + } + + if (config === true) { + return createNormalizer({}); + } + + return createNormalizer(config); + } } diff --git a/src/react-query/DataSourceProvider.tsx b/src/react-query/DataSourceProvider.tsx new file mode 100644 index 0000000..f9a53cb --- /dev/null +++ b/src/react-query/DataSourceProvider.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import {QueryClientProvider} from '@tanstack/react-query'; + +import {DataManagerProvider} from '../react/DataManagerProvider'; + +import type {ClientDataManager} from './ClientDataManager'; + +export interface DataSourceProviderProps { + dataManager: ClientDataManager; + children: React.ReactNode; +} + +export const DataSourceProvider: React.FC = ({children, dataManager}) => { + React.useEffect(() => { + if (!dataManager.queryNormalizer) { + return undefined; + } + + dataManager.queryNormalizer.subscribe(); + + return () => { + dataManager.queryNormalizer?.unsubscribe(); + dataManager.queryNormalizer?.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/react-query/__tests__/createQueryNormalizer.test.tsx b/src/react-query/__tests__/createQueryNormalizer.test.tsx new file mode 100644 index 0000000..e7754a0 --- /dev/null +++ b/src/react-query/__tests__/createQueryNormalizer.test.tsx @@ -0,0 +1,172 @@ +import type {Data} from '@normy/core'; + +import {ClientDataManager} from '../ClientDataManager'; + +describe('QueryNormalizer API', () => { + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + }, + }); + }); + + afterEach(() => { + dataManager.queryClient.clear(); + }); + + it('should create queryNormalizer with required methods', () => { + expect(dataManager.queryNormalizer).toBeDefined(); + expect(dataManager.queryNormalizer?.getNormalizedData).toBeDefined(); + expect(dataManager.queryNormalizer?.setNormalizedData).toBeDefined(); + expect(dataManager.queryNormalizer?.clear).toBeDefined(); + expect(dataManager.queryNormalizer?.getObjectById).toBeDefined(); + expect(dataManager.queryNormalizer?.getQueryFragment).toBeDefined(); + expect(dataManager.queryNormalizer?.getDependentQueries).toBeDefined(); + expect(dataManager.queryNormalizer?.getDependentQueriesByIds).toBeDefined(); + expect(dataManager.queryNormalizer?.subscribe).toBeDefined(); + expect(dataManager.queryNormalizer?.unsubscribe).toBeDefined(); + }); + + it('getNormalizedData should return normalized data', () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + const data = dataManager.queryNormalizer!.getNormalizedData(); + expect(data).toBeDefined(); + expect(data.objects).toBeDefined(); + expect(data.queries).toBeDefined(); + }); + + it('setNormalizedData should update queries', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + dataManager.queryNormalizer!.setNormalizedData({id: '1', name: 'New'}); + + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('New'); + }); + + it('clear should clear normalized data', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'User'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'User'}]); + + dataManager.queryNormalizer!.clear(); + + const data = dataManager.queryNormalizer!.getNormalizedData(); + expect(Object.keys(data.objects)).toHaveLength(0); + }); + + it('getObjectById should return object by ID', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + const userData = [{id: '1', name: 'User 1'}]; + + // Add to normalizer + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), userData); + + // Get normalized data + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + expect(Object.keys(normalized.objects).length).toBeGreaterThan(0); + + // getObjectById should be defined and available + expect(dataManager.queryNormalizer!.getObjectById).toBeDefined(); + expect(typeof dataManager.queryNormalizer!.getObjectById).toBe('function'); + }); + + it('getDependentQueries should return dependent queries', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey1 = ['users']; + const queryKey2 = ['user', '1']; + + dataManager.queryClient.setQueryData(queryKey1, [{id: '1', name: 'User'}]); + dataManager.queryClient.setQueryData(queryKey2, {id: '1', name: 'User'}); + + dataManager.normalizer!.setQuery(JSON.stringify(queryKey1), [{id: '1', name: 'User'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey2), {id: '1', name: 'User'}); + + const dependentQueries = dataManager.queryNormalizer!.getDependentQueries({ + id: '1', + name: 'Updated', + }); + expect(dependentQueries.length).toBeGreaterThan(0); + }); + + it('getDependentQueriesByIds should be available', () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + // getDependentQueriesByIds should be defined and available + expect(dataManager.queryNormalizer!.getDependentQueriesByIds).toBeDefined(); + expect(typeof dataManager.queryNormalizer!.getDependentQueriesByIds).toBe('function'); + + // Call with empty array should not throw + const result = dataManager.queryNormalizer!.getDependentQueriesByIds([]); + expect(Array.isArray(result)).toBe(true); + }); + + it('should not create queryNormalizer when normalizerConfig is false', () => { + const dmWithoutNormalizer = new ClientDataManager({ + normalizerConfig: false, + }); + + expect(dmWithoutNormalizer.normalizer).toBeUndefined(); + expect(dmWithoutNormalizer.queryNormalizer).toBeUndefined(); + }); + + it('should not create queryNormalizer when normalizerConfig is undefined', () => { + const dmWithoutNormalizer = new ClientDataManager({}); + + expect(dmWithoutNormalizer.normalizer).toBeUndefined(); + expect(dmWithoutNormalizer.queryNormalizer).toBeUndefined(); + }); + + it('should create queryNormalizer when normalizerConfig is true', () => { + const dmWithNormalizer = new ClientDataManager({ + normalizerConfig: true, + }); + + expect(dmWithNormalizer.normalizer).toBeDefined(); + expect(dmWithNormalizer.queryNormalizer).toBeDefined(); + }); + + it('should work correctly with optimisticUpdate via setNormalizedData', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + const initialData = [{id: '1', name: 'Old Name'}]; + + dataManager.queryClient.setQueryData(queryKey, initialData); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + const mutationData: Data = {id: '1', name: 'New Name'}; + dataManager.queryNormalizer!.setNormalizedData(mutationData); + + const updatedData = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(updatedData[0].name).toBe('New Name'); + }); +}); diff --git a/src/react-query/__tests__/normalizationEdgeCases.test.tsx b/src/react-query/__tests__/normalizationEdgeCases.test.tsx new file mode 100644 index 0000000..15f82ae --- /dev/null +++ b/src/react-query/__tests__/normalizationEdgeCases.test.tsx @@ -0,0 +1,102 @@ +import type {Data} from '@normy/core'; + +import {ClientDataManager} from '../ClientDataManager'; + +describe('normalization edge cases', () => { + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + }, + }); + }); + + afterEach(() => { + dataManager.queryClient.clear(); + }); + + it('should work correctly with empty data', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['empty']; + dataManager.queryClient.setQueryData(queryKey, []); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), []); + + const mutationData: Data = {id: '1', name: 'New'}; + expect(() => { + dataManager.optimisticUpdate(mutationData); + }).not.toThrow(); + }); + + it('should work correctly with null data', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['null']; + dataManager.queryClient.setQueryData(queryKey, null); + + const mutationData: Data = {id: '1', name: 'New'}; + expect(() => { + dataManager.optimisticUpdate(mutationData); + }).not.toThrow(); + }); + + it('should work correctly with undefined data', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['undefined']; + dataManager.queryClient.setQueryData(queryKey, undefined); + + const mutationData: Data = {id: '1', name: 'New'}; + expect(() => { + dataManager.optimisticUpdate(mutationData); + }).not.toThrow(); + }); + + it('should work correctly with arrays of objects', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['array']; + const data = [ + {id: '1', name: 'Item 1'}, + {id: '2', name: 'Item 2'}, + ]; + + dataManager.queryClient.setQueryData(queryKey, data); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), data); + + const mutationData: Data = {id: '1', name: 'Updated Item 1'}; + dataManager.optimisticUpdate(mutationData); + + const updatedData = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(updatedData[0].name).toBe('Updated Item 1'); + expect(updatedData[1].name).toBe('Item 2'); + }); + + it('should work correctly with single objects', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['single']; + const data = {id: '1', name: 'Item'}; + + dataManager.queryClient.setQueryData(queryKey, data); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), data); + + const mutationData: Data = {id: '1', name: 'Updated Item'}; + dataManager.optimisticUpdate(mutationData); + + const updatedData = dataManager.queryClient.getQueryData(queryKey) as { + id: string; + name: string; + }; + expect(updatedData.name).toBe('Updated Item'); + }); +}); diff --git a/src/react-query/__tests__/subscriptions.test.tsx b/src/react-query/__tests__/subscriptions.test.tsx new file mode 100644 index 0000000..b0d9737 --- /dev/null +++ b/src/react-query/__tests__/subscriptions.test.tsx @@ -0,0 +1,667 @@ +import React from 'react'; + +import {useMutation} from '@tanstack/react-query'; +import {renderHook, waitFor} from '@testing-library/react'; + +import {ClientDataManager} from '../ClientDataManager'; +import {DataSourceProvider} from '../DataSourceProvider'; + +describe('subscriptions', () => { + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + }, + }); + }); + + afterEach(() => { + dataManager.queryNormalizer?.unsubscribe(); + dataManager.queryClient.clear(); + }); + + describe('QueryCache subscription', () => { + it('should add query to normalizer when added to QueryCache', async () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + dataManager.queryNormalizer!.subscribe(); + + // Add query with normalize: true option + await dataManager.queryClient.fetchQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + normalize: true, + } as Parameters[0] & {normalize: boolean}); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + expect(normalized.objects['@@1']).toBeDefined(); + }); + + it('should update query in normalizer on update', async () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Initial data with normalize: true + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Old'}], + normalize: true, + } as Parameters[0] & {normalize: boolean}); + + const normalizedBefore = dataManager.queryNormalizer!.getNormalizedData(); + const objectCountBefore = Object.keys(normalizedBefore.objects).length; + + // Update data + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'New'}], + normalize: true, + } as Parameters[0] & {normalize: boolean}); + + // Verify that normalized data was updated + const normalizedAfter = dataManager.queryNormalizer!.getNormalizedData(); + expect(Object.keys(normalizedAfter.objects).length).toBe(objectCountBefore); + expect(normalizedAfter.queries[JSON.stringify(queryKey)]).toBeDefined(); + }); + + it('should remove query from normalizer when removed from QueryCache', async () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Add query + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'User 1'}], + normalize: true, + } as Parameters[0] & {normalize: boolean}); + + // Remove query + dataManager.queryClient.removeQueries({queryKey}); + + // Give time to process event + await new Promise((resolve) => setTimeout(resolve, 10)); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + // Query should be removed from queries + expect(normalized.queries[JSON.stringify(queryKey)]).toBeUndefined(); + }); + + it('should support meta configuration for queries', async () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + dataManager.queryNormalizer!.subscribe(); + + // Check that we can use meta for configuration + // Real check for disabling via meta is already tested in integration tests + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + expect(normalized).toBeDefined(); + expect(normalized.objects).toBeDefined(); + expect(normalized.queries).toBeDefined(); + }); + + it('should unsubscribe correctly', async () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + dataManager.queryNormalizer!.subscribe(); + dataManager.queryNormalizer!.unsubscribe(); + + // After unsubscribing, adding query should not affect normalizer + await dataManager.queryClient.fetchQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + normalize: true, + } as Parameters[0] & {normalize: boolean}); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + expect(Object.keys(normalized.objects)).toHaveLength(0); + }); + + it('should allow multiple unsubscribe calls', () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + dataManager.queryNormalizer!.subscribe(); + dataManager.queryNormalizer!.unsubscribe(); + + // Repeated call should not throw error + expect(() => dataManager.queryNormalizer!.unsubscribe()).not.toThrow(); + }); + }); + + describe('MutationCache subscription', () => { + let dataManagerWithOptimistic: ClientDataManager; + + beforeEach(() => { + dataManagerWithOptimistic = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: true, + }, + }, + }); + }); + + afterEach(() => { + dataManagerWithOptimistic.queryNormalizer?.unsubscribe(); + dataManagerWithOptimistic.queryClient.clear(); + }); + + it('should update queries on successful mutation', async () => { + expect(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); + + dataManagerWithOptimistic.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Initial data + await dataManagerWithOptimistic.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Old'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + // Create wrapper for hooks + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + // Mutation via useMutation + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => ({id: '1', name: 'New'}), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + const data = dataManagerWithOptimistic.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('New'); + }); + + it('should apply optimistic updates', async () => { + expect(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); + + dataManagerWithOptimistic.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Initial data + await dataManagerWithOptimistic.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + // Mutation with optimistic data + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return {id: '1', name: 'Final'}; + }, + onMutate: () => ({ + optimisticData: {id: '1', name: 'Optimistic'}, + }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + // Check optimistic data + await waitFor(() => { + const data = dataManagerWithOptimistic.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('Optimistic'); + }); + + // Wait for mutation to complete + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + const dataFinal = dataManagerWithOptimistic.queryClient.getQueryData( + queryKey, + ) as Array<{ + id: string; + name: string; + }>; + expect(dataFinal[0].name).toBe('Final'); + }); + + it('should automatically calculate rollbackData', async () => { + expect(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); + + dataManagerWithOptimistic.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Initial data + await dataManagerWithOptimistic.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + // Mutation with optimistic data that will fail + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + throw new Error('Mutation failed'); + }, + onMutate: () => ({ + optimisticData: {id: '1', name: 'Optimistic'}, + }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + // Data should be rolled back to original + const data = dataManagerWithOptimistic.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('Original'); + }); + + it('should rollback changes on mutation error', async () => { + expect(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); + + dataManagerWithOptimistic.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Initial data + await dataManagerWithOptimistic.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + // Mutation with error + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + throw new Error('Failed'); + }, + onMutate: () => ({ + optimisticData: {id: '1', name: 'Optimistic'}, + }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + const data = dataManagerWithOptimistic.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('Original'); + }); + + it('should ignore mutations with normalize: false', async () => { + const dmNoNormalize = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + }, + }); + + expect(dmNoNormalize.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + dmNoNormalize.queryClient.setQueryData(queryKey, [{id: '1', name: 'Original'}]); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + // Mutation should not update data automatically (no normalize option) + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => ({id: '1', name: 'New'}), + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + const data = dmNoNormalize.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('Original'); // Not changed + }); + + it('should support devLogging for optimistic updates', async () => { + const dmWithLogging = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: true, + devLogging: true, + }, + }, + }); + + expect(dmWithLogging.queryNormalizer).toBeDefined(); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + dmWithLogging.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + await dmWithLogging.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + throw new Error('Failed'); + }, + onMutate: () => ({ + optimisticData: {id: '1', name: 'Optimistic'}, + }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + // Verify that logging was called + expect(consoleSpy).toHaveBeenCalledWith( + '[OptimisticUpdate] Auto-calculated rollbackData:', + expect.any(Object), + ); + expect(consoleSpy).toHaveBeenCalledWith('[OptimisticUpdate] Rolling back changes'); + + consoleSpy.mockRestore(); + }); + + it('should support manual rollbackData', async () => { + const dmNoAutoRollback = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: false, + }, + }, + }); + + expect(dmNoAutoRollback.queryNormalizer).toBeDefined(); + + dmNoAutoRollback.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + await dmNoAutoRollback.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + throw new Error('Failed'); + }, + onMutate: () => ({ + optimisticData: {id: '1', name: 'Optimistic'}, + rollbackData: {id: '1', name: 'Manual Rollback'}, + }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + const data = dmNoAutoRollback.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('Manual Rollback'); + + dmNoAutoRollback.queryNormalizer!.unsubscribe(); + dmNoAutoRollback.queryClient.clear(); + }); + + it('should invalidate queries when invalidate option is enabled', async () => { + const dmWithInvalidate = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + invalidate: true, + }, + }); + + expect(dmWithInvalidate.queryNormalizer).toBeDefined(); + + dmWithInvalidate.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Initial data + await dmWithInvalidate.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + // Spy on invalidateQueries + const invalidateSpy = jest.spyOn(dmWithInvalidate.queryClient, 'invalidateQueries'); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => ({id: '1', name: 'Updated'}), + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + // Verify that invalidateQueries was called + expect(invalidateSpy).toHaveBeenCalled(); + + invalidateSpy.mockRestore(); + dmWithInvalidate.queryNormalizer!.unsubscribe(); + dmWithInvalidate.queryClient.clear(); + }); + + it('should not invalidate queries when invalidate: false is set on mutation', async () => { + const dmWithInvalidate = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + invalidate: true, // Globally enabled + }, + }); + + expect(dmWithInvalidate.queryNormalizer).toBeDefined(); + + dmWithInvalidate.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + await dmWithInvalidate.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + const invalidateSpy = jest.spyOn(dmWithInvalidate.queryClient, 'invalidateQueries'); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => ({id: '1', name: 'Updated'}), + normalize: true, + invalidate: false, // Disable for this mutation + } as Parameters[0] & { + normalize: boolean; + invalidate: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + // Verify that invalidateQueries was NOT called + expect(invalidateSpy).not.toHaveBeenCalled(); + + invalidateSpy.mockRestore(); + dmWithInvalidate.queryNormalizer!.unsubscribe(); + dmWithInvalidate.queryClient.clear(); + }); + }); +}); diff --git a/src/react-query/__tests__/threeLevelIntegration.test.tsx b/src/react-query/__tests__/threeLevelIntegration.test.tsx new file mode 100644 index 0000000..415ce8c --- /dev/null +++ b/src/react-query/__tests__/threeLevelIntegration.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; + +import type {QueryClient} from '@tanstack/react-query'; +import {renderHook, waitFor} from '@testing-library/react'; + +import {ClientDataManager} from '../ClientDataManager'; +import {DataSourceProvider} from '../DataSourceProvider'; +import {useQueryData} from '../hooks/useQueryData'; +import {makePlainQueryDataSource} from '../impl/plain/factory'; + +describe('Normalization Configuration Integration', () => { + let queryClient: QueryClient; + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + }, + }); + queryClient = dataManager.queryClient; + }); + + afterEach(() => { + dataManager.queryNormalizer?.unsubscribe(); + queryClient.clear(); + }); + + describe('ClientDataManager configuration', () => { + it('should use custom getNormalizationObjectKey from config', async () => { + const customGetKey = jest.fn((obj) => `custom:${obj.id}`); + + const customDataManager = new ClientDataManager({ + normalizerConfig: { + getNormalizationObjectKey: customGetKey, + devLogging: false, + }, + }); + + customDataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + await customDataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'User 1'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + const normalized = customDataManager.queryNormalizer!.getNormalizedData(); + + expect(normalized.objects['@@custom:1']).toBeDefined(); + expect(customGetKey).toHaveBeenCalled(); + + customDataManager.queryNormalizer!.unsubscribe(); + customDataManager.queryClient.clear(); + }); + + it('should use custom getArrayType from config', async () => { + const customGetArrayType = jest.fn(({arrayKey}) => `custom:${arrayKey}`); + + const customDataManager = new ClientDataManager({ + normalizerConfig: { + getArrayType: customGetArrayType, + devLogging: false, + }, + }); + + customDataManager.queryNormalizer!.subscribe(); + + const queryKey = ['items']; + + await customDataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => ({ + items: [{id: '1', name: 'Item 1'}], + }), + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + expect(customGetArrayType).toHaveBeenCalled(); + + customDataManager.queryNormalizer!.unsubscribe(); + customDataManager.queryClient.clear(); + }); + }); + + describe('Query-level normalization control', () => { + it('should normalize when query has normalize: true', async () => { + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users-normalized']; + + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'User 1'}], + normalize: true, + } as Parameters[0] & {normalize: boolean}); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data SHOULD be normalized + expect(normalized.objects['@@1']).toBeDefined(); + }); + + it('should NOT normalize when query has normalize: false', async () => { + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users-not-normalized']; + + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '2', name: 'User 2'}], + normalize: false, + } as Parameters[0] & {normalize: boolean}); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data should NOT be normalized + expect(normalized.objects['@@2']).toBeUndefined(); + }); + + it('should normalize by default when normalize option is not provided', async () => { + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users-default']; + + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '3', name: 'User 3'}], + }); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data SHOULD be normalized (default behavior is now true) + expect(normalized.objects['@@3']).toBeDefined(); + }); + }); + + describe('DataSourceProvider integration', () => { + it('should auto-subscribe queryNormalizer on mount', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const dataSource = makePlainQueryDataSource({ + name: 'users', + fetch: async () => [{id: '1', name: 'User 1'}], + options: { + normalize: true, + }, + }); + + const {result} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); + + await waitFor(() => expect(result.current.status).toBe('success')); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data should be normalized because DataSourceProvider subscribes automatically + expect(normalized.objects['@@1']).toBeDefined(); + }); + + it('should work with multiple queries', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const dataSource1 = makePlainQueryDataSource({ + name: 'users', + fetch: async () => [{id: '1', name: 'User 1'}], + options: { + normalize: true, + }, + }); + + const dataSource2 = makePlainQueryDataSource({ + name: 'posts', + fetch: async () => [{id: '2', title: 'Post 1'}], + options: { + normalize: true, + }, + }); + + const {result: result1} = renderHook(() => useQueryData(dataSource1, {}), {wrapper}); + const {result: result2} = renderHook(() => useQueryData(dataSource2, {}), {wrapper}); + + await waitFor(() => { + expect(result1.current.status).toBe('success'); + expect(result2.current.status).toBe('success'); + }); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + expect(normalized.objects['@@1']).toBeDefined(); + expect(normalized.objects['@@2']).toBeDefined(); + }); + }); + + describe('Optimistic updates configuration', () => { + it('should enable optimistic updates when configured', async () => { + const dmWithOptimistic = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + }, + }); + + expect(dmWithOptimistic.queryNormalizer).toBeDefined(); + + dmWithOptimistic.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + const initialData = [{id: '1', name: 'Old'}]; + + dmWithOptimistic.queryClient.setQueryData(queryKey, initialData); + dmWithOptimistic.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Manual optimistic update via setNormalizedData + dmWithOptimistic.queryNormalizer!.setNormalizedData({id: '1', name: 'New'}); + + const data = dmWithOptimistic.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('New'); + + dmWithOptimistic.queryNormalizer!.unsubscribe(); + dmWithOptimistic.queryClient.clear(); + }); + + it('should work with optimistic config object', async () => { + const dmWithOptimisticConfig = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: true, + devLogging: false, + }, + }, + }); + + expect(dmWithOptimisticConfig.queryNormalizer).toBeDefined(); + expect(dmWithOptimisticConfig.normalizer).toBeDefined(); + + dmWithOptimisticConfig.queryClient.clear(); + }); + }); + + describe('Invalidate configuration', () => { + it('should support invalidate option in global config', async () => { + const dmWithInvalidate = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + invalidate: true, + }, + }); + + expect(dmWithInvalidate.queryNormalizer).toBeDefined(); + expect(dmWithInvalidate.normalizer).toBeDefined(); + + dmWithInvalidate.queryClient.clear(); + }); + + it('should support both optimistic and invalidate options', async () => { + const dmWithBoth = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + invalidate: true, + }, + }); + + expect(dmWithBoth.queryNormalizer).toBeDefined(); + expect(dmWithBoth.normalizer).toBeDefined(); + + dmWithBoth.queryClient.clear(); + }); + }); +}); diff --git a/src/react-query/__tests__/updateQueriesFromMutationData.test.tsx b/src/react-query/__tests__/updateQueriesFromMutationData.test.tsx new file mode 100644 index 0000000..eaeedc6 --- /dev/null +++ b/src/react-query/__tests__/updateQueriesFromMutationData.test.tsx @@ -0,0 +1,183 @@ +import type {Data} from '@normy/core'; + +import {ClientDataManager} from '../ClientDataManager'; + +describe('updateQueriesFromMutationData', () => { + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + }, + }); + }); + + afterEach(() => { + dataManager.queryClient.clear(); + }); + + it('should update query data based on normalized data', () => { + expect(dataManager.normalizer).toBeDefined(); + + // Set initial data in query + const queryKey = ['users']; + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old Name'}]); + + // Add query to normalizer + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old Name'}]); + + // Update data via mutation + const mutationData: Data = {id: '1', name: 'New Name'}; + + dataManager.optimisticUpdate(mutationData); + + // Verify that data was updated + const updatedData = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(updatedData).toBeDefined(); + expect(updatedData[0].name).toBe('New Name'); + }); + + it('should update multiple queries with the same object', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey1 = ['users']; + const queryKey2 = ['user', '1']; + + // Set data in both queries + dataManager.queryClient.setQueryData(queryKey1, [{id: '1', name: 'User 1'}]); + dataManager.queryClient.setQueryData(queryKey2, {id: '1', name: 'User 1'}); + + // Add to normalizer + dataManager.normalizer!.setQuery(JSON.stringify(queryKey1), [{id: '1', name: 'User 1'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey2), {id: '1', name: 'User 1'}); + + // Update via mutation + const mutationData: Data = {id: '1', name: 'Updated User'}; + dataManager.optimisticUpdate(mutationData); + + // Check both queries + const data1 = dataManager.queryClient.getQueryData(queryKey1) as Array<{ + id: string; + name: string; + }>; + const data2 = dataManager.queryClient.getQueryData(queryKey2) as {id: string; name: string}; + + expect(data1[0].name).toBe('Updated User'); + expect(data2.name).toBe('Updated User'); + }); + + it('should preserve dataUpdatedAt on update', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['users']; + const originalUpdatedAt = Date.now(); + + // Set initial data + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}], { + updatedAt: originalUpdatedAt, + }); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + // Update data + const mutationData: Data = {id: '1', name: 'New'}; + dataManager.optimisticUpdate(mutationData); + + // Verify that dataUpdatedAt was preserved + const cachedQuery = dataManager.queryClient.getQueryCache().find({queryKey}); + expect(cachedQuery?.state.dataUpdatedAt).toBe(originalUpdatedAt); + }); + + it('should preserve error state on update', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['users']; + const error = new Error('Test error'); + + // Set initial data with error + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + const cachedQuery = dataManager.queryClient.getQueryCache().find({queryKey}); + cachedQuery?.setState({error, status: 'error'}); + + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + // Update data + const mutationData: Data = {id: '1', name: 'New'}; + dataManager.optimisticUpdate(mutationData); + + // Verify that error and status were preserved + const updatedQuery = dataManager.queryClient.getQueryCache().find({queryKey}); + expect(updatedQuery?.state.error).toBe(error); + expect(updatedQuery?.state.status).toBe('error'); + }); + + it('should preserve isInvalidated flag on update', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['users']; + + // Set initial data and invalidate + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + dataManager.queryClient.invalidateQueries({queryKey}); + + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + const cachedQueryBefore = dataManager.queryClient.getQueryCache().find({queryKey}); + const isInvalidatedBefore = cachedQueryBefore?.state.isInvalidated; + + // Update data + const mutationData: Data = {id: '1', name: 'New'}; + dataManager.optimisticUpdate(mutationData); + + // Verify that isInvalidated was preserved + const cachedQueryAfter = dataManager.queryClient.getQueryCache().find({queryKey}); + expect(cachedQueryAfter?.state.isInvalidated).toBe(isInvalidatedBefore); + }); + + it('should work correctly with nested objects', () => { + expect(dataManager.normalizer).toBeDefined(); + + const queryKey = ['posts']; + const initialData = [ + { + id: '1', + title: 'Post 1', + author: {id: '10', name: 'Author 1'}, + }, + ]; + + dataManager.queryClient.setQueryData(queryKey, initialData); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Update author + const mutationData: Data = {id: '10', name: 'Updated Author'}; + dataManager.optimisticUpdate(mutationData); + + // Verify that author was updated + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + title: string; + author: {id: string; name: string}; + }>; + expect(data[0].author.name).toBe('Updated Author'); + }); + + it('should not throw if query is not in cache', () => { + expect(dataManager.normalizer).toBeDefined(); + + const mutationData: Data = {id: '1', name: 'New'}; + + // Don't add query to cache, only to normalizer + // This should not throw an error + expect(() => { + dataManager.optimisticUpdate(mutationData); + }).not.toThrow(); + }); +}); diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index a639d7f..042f302 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -6,7 +6,7 @@ import type { QueryFunctionContext, QueryKey, } from '@tanstack/react-query'; -import type {Overwrite} from 'utility-types'; +import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; @@ -19,7 +19,7 @@ export type InfiniteQueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, -> = Overwrite< +> = Assign< InfiniteQueryObserverOptions, QueryDataAdditionalOptions< TQueryFnData, diff --git a/src/react-query/impl/plain/types.ts b/src/react-query/impl/plain/types.ts index c171273..381898e 100644 --- a/src/react-query/impl/plain/types.ts +++ b/src/react-query/impl/plain/types.ts @@ -5,7 +5,7 @@ import type { QueryObserverOptions, QueryObserverResult, } from '@tanstack/react-query'; -import type {Overwrite} from 'utility-types'; +import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; @@ -18,7 +18,7 @@ export type QueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = never, -> = Overwrite< +> = Assign< QueryObserverOptions, QueryDataAdditionalOptions >; diff --git a/src/react-query/index.ts b/src/react-query/index.ts index 306faf1..9104eb6 100644 --- a/src/react-query/index.ts +++ b/src/react-query/index.ts @@ -21,3 +21,7 @@ export {getProgressiveRefetch} from './utils/getProgressiveRefetch'; export type {ClientDataManagerConfig} from './ClientDataManager'; export {ClientDataManager} from './ClientDataManager'; + +export {DataSourceProvider} from './DataSourceProvider'; + +export type {QueryNormalizer} from './types/normalizer'; diff --git a/src/react-query/types/normalizer.ts b/src/react-query/types/normalizer.ts new file mode 100644 index 0000000..f2ee744 --- /dev/null +++ b/src/react-query/types/normalizer.ts @@ -0,0 +1,22 @@ +import type {Data, NormalizedData} from '@normy/core/types/types'; + +export interface QueryNormalizer { + /** Get normalized data */ + getNormalizedData: () => NormalizedData; + /** Set normalized data (for manual updates, WebSocket, etc.) */ + setNormalizedData: (data: Data) => void; + /** Clear all normalized data */ + clear: () => void; + /** Get object by ID */ + getObjectById: (id: string, exampleObject?: T) => T | undefined; + /** Get query fragment */ + getQueryFragment: (fragment: Data, exampleObject?: T) => T | undefined; + /** Get dependent queries by data */ + getDependentQueries: (mutationData: Data) => ReadonlyArray; + /** Get dependent queries by IDs */ + getDependentQueriesByIds: (ids: ReadonlyArray) => ReadonlyArray; + /** Subscribe to QueryCache changes */ + subscribe: () => void; + /** Unsubscribe from QueryCache changes */ + unsubscribe: () => void; +} diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index 140f0b6..f04eebe 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -1,5 +1,7 @@ import type {DefaultError, QueryKey} from '@tanstack/react-query'; +import type {OptimisticConfig} from '../../core/types/Normalizer'; + import type {RefetchInterval} from './refetch-interval'; export interface QueryDataAdditionalOptions< @@ -14,4 +16,10 @@ export interface QueryDataAdditionalOptions< * It is recommended to use idle as query parameters to control query state. */ enabled?: boolean; + /** Normalization configuration (enable/disable) */ + normalize?: boolean; + /** Optimistic data update configuration */ + optimistic?: boolean | OptimisticConfig; + /** Invalidate data configuration */ + invalidate?: boolean; } diff --git a/src/react-query/utils/normalize.ts b/src/react-query/utils/normalize.ts new file mode 100644 index 0000000..1ce56b0 --- /dev/null +++ b/src/react-query/utils/normalize.ts @@ -0,0 +1,222 @@ +import type {Data} from '@normy/core'; +import type {QueryClient, QueryKey} from '@tanstack/react-query'; + +import type {Normalizer, NormalizerConfig, OptimisticConfig} from '../../core/types/Normalizer'; +import type {QueryDataAdditionalOptions} from '../types/options'; + +interface QueryNormalizeOptions { + normalize?: boolean; + optimistic?: boolean | OptimisticConfig; + invalidate?: boolean; +} + +const shouldInvalidateData = (globalConfig?: boolean, mutationConfig?: boolean): boolean => { + if (mutationConfig === false) { + return false; + } + + if (!globalConfig) { + return false; + } + + return true; +}; + +const shouldUpdateOptimistically = ( + globalConfig?: boolean | OptimisticConfig, + mutationConfig?: boolean | OptimisticConfig, +): boolean => { + if (mutationConfig === false) { + return false; + } + + if ( + (typeof mutationConfig === 'boolean' && mutationConfig) || + (typeof mutationConfig === 'object' && mutationConfig) + ) { + return true; + } + + if (!globalConfig) { + return false; + } + + return true; +}; + +const getOptimisticProps = ( + globalConfig?: boolean | OptimisticConfig, + mutationConfig?: boolean | OptimisticConfig, +) => { + const globalAutoRollback = + typeof globalConfig === 'object' ? globalConfig.autoCalculateRollback : undefined; + const mutationAutoRollback = + typeof mutationConfig === 'object' ? mutationConfig.autoCalculateRollback : undefined; + const globalDevLogging = typeof globalConfig === 'object' ? globalConfig.devLogging : undefined; + const mutationDevLogging = + typeof mutationConfig === 'object' ? mutationConfig.devLogging : undefined; + + return { + autoRollback: mutationAutoRollback ?? globalAutoRollback, + devLogging: mutationDevLogging ?? globalDevLogging, + }; +}; + +export const createQueryNormalizer = ( + normalizer: Normalizer | undefined, + queryClient: QueryClient, + config: boolean | NormalizerConfig | undefined, + optimisticUpdate: (mutationData: Data) => void, + invalidateData: (data: Data) => void, +) => { + if (!normalizer || !config) { + return undefined; + } + + const globalOptimistic = + typeof config === 'object' && 'optimistic' in config ? config.optimistic : false; + + const globalInvalidateData = + typeof config === 'object' && 'invalidate' in config ? config.invalidate : false; + + let unsubscribeQueryCache: (() => void) | null = null; + let unsubscribeMutationCache: (() => void) | null = null; + + return { + /** Get normalized data */ + getNormalizedData: normalizer.getNormalizedData, + /** Set normalized data (for manual updates, WebSocket, etc.) */ + setNormalizedData: (data: Data) => optimisticUpdate(data), + /** Clear all normalized data */ + clear: normalizer.clearNormalizedData, + /** Get object by ID */ + getObjectById: normalizer.getObjectById, + /** Get query fragment */ + getQueryFragment: normalizer.getQueryFragment, + /** Get dependent queries by data */ + getDependentQueries: (mutationData: Data) => + normalizer.getDependentQueries(mutationData).map((key) => JSON.parse(key) as QueryKey), + /** Get dependent queries by IDs */ + getDependentQueriesByIds: (ids: ReadonlyArray) => + normalizer.getDependentQueriesByIds(ids).map((key) => JSON.parse(key) as QueryKey), + /** Subscribe to QueryCache changes */ + subscribe: () => { + // Subscribe to QueryCache (query additions/updates/removals) + unsubscribeQueryCache = queryClient.getQueryCache().subscribe((event) => { + const queryKeyStr = JSON.stringify(event.query.queryKey); + + if (event.type === 'removed') { + normalizer.removeQuery(queryKeyStr); + + return; + } + + // Check if the query should be normalized + // At this point options are already merged (DataSource + Hook) + const queryOptions = event.query.options as QueryDataAdditionalOptions; + + const queryNormalize = queryOptions?.normalize ?? true; + + if (!queryNormalize) { + return; + } + + if (event.type === 'added' && event.query.state.data !== undefined) { + normalizer.setQuery(queryKeyStr, event.query.state.data as Data); + } else if ( + event.type === 'updated' && + event.action.type === 'success' && + event.action.data !== undefined + ) { + normalizer.setQuery(queryKeyStr, event.action.data as Data); + } + }); + + // Subscribe to MutationCache for normalization + optimistic updates + unsubscribeMutationCache = queryClient.getMutationCache().subscribe((event) => { + // Cast to extended type with additional configs, if available + const mutationOptions = event.mutation?.options as + | QueryNormalizeOptions + | undefined; + + const mutationQueryNormalize = mutationOptions?.normalize; + const mutationQueryOptimistic = mutationOptions?.optimistic; + const mutationQueryInvalidateData = mutationOptions?.invalidate; + + if (shouldInvalidateData(globalInvalidateData, mutationQueryInvalidateData)) { + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.action.data + ) { + invalidateData(event.action.data as Data); + } + } + + if ( + !mutationQueryNormalize || + !shouldUpdateOptimistically(globalOptimistic, mutationQueryOptimistic) + ) { + return; + } + + const {autoRollback, devLogging} = getOptimisticProps( + globalOptimistic, + mutationQueryOptimistic, + ); + + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.action.data + ) { + optimisticUpdate(event.action.data as Data); + } else if (event.type === 'updated' && event.action.type === 'pending') { + const context = event.mutation.state.context as { + optimisticData?: Data; + rollbackData?: Data; + }; + + if (context?.optimisticData) { + if ( + !context.rollbackData && + mutationQueryOptimistic && + autoRollback !== false + ) { + context.rollbackData = normalizer.getCurrentData( + context.optimisticData, + ); + + if (devLogging) { + console.log( + '[OptimisticUpdate] Auto-calculated rollbackData:', + context.rollbackData, + ); + } + } + + optimisticUpdate(context.optimisticData); + } + } else if (event.type === 'updated' && event.action.type === 'error') { + const context = event.mutation.state.context as { + rollbackData?: Data; + }; + + if (context?.rollbackData) { + if (devLogging) { + console.log('[OptimisticUpdate] Rolling back changes'); + } + + optimisticUpdate(context.rollbackData); + } + } + }); + }, + unsubscribe: () => { + unsubscribeQueryCache?.(); + unsubscribeMutationCache?.(); + unsubscribeQueryCache = null; + unsubscribeMutationCache = null; + }, + }; +}; diff --git a/src/react/DataManagerProvider.tsx b/src/react/DataManagerProvider.tsx new file mode 100644 index 0000000..237dc4c --- /dev/null +++ b/src/react/DataManagerProvider.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import type {DataManager} from '../core'; + +import {DataManagerContext} from './DataManagerContext'; + +export interface DataManagerProviderProps { + children: React.ReactNode; + dataManager: DataManager; +} + +export const DataManagerProvider: React.FC = ({ + children, + dataManager, +}) => { + return ( + {children} + ); +}; diff --git a/src/react/__tests__/DataManagerContext.test.tsx b/src/react/__tests__/DataManagerContext.test.tsx index 6425b4c..efd0e33 100644 --- a/src/react/__tests__/DataManagerContext.test.tsx +++ b/src/react/__tests__/DataManagerContext.test.tsx @@ -8,6 +8,9 @@ import {DataManagerContext, useDataManager} from '../DataManagerContext'; describe('useDataManager', () => { it('should return dataManager from context', () => { const mockDataManager: DataManager = { + normalizer: undefined, + optimisticUpdate: jest.fn(), + invalidateData: jest.fn(), invalidateTag: jest.fn(), invalidateTags: jest.fn(), invalidateSource: jest.fn(), @@ -29,14 +32,14 @@ describe('useDataManager', () => { }); it('should throw an error when dataManager is not provided', () => { - try { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { renderHook(() => useDataManager()); - } catch (error) { - expect(error).toBeInstanceOf(Error); - // Just to be sure that the error is from the right place - expect((error as Error).message).toBe( - 'DataManager is not provided by context. Use DataManagerContext.Provider to do it', - ); - } + }).toThrow( + 'DataManager is not provided by context. Use DataManagerContext.Provider to do it', + ); + + consoleSpy.mockRestore(); }); }); diff --git a/src/react/__tests__/withDataManager.test.tsx b/src/react/__tests__/withDataManager.test.tsx index d30c2fb..5dd0134 100644 --- a/src/react/__tests__/withDataManager.test.tsx +++ b/src/react/__tests__/withDataManager.test.tsx @@ -17,6 +17,9 @@ jest.mock('../DataManagerContext', () => { describe('withDataManager', () => { const mockDataManager: DataManager = { + normalizer: undefined, + optimisticUpdate: jest.fn(), + invalidateData: jest.fn(), invalidateTag: jest.fn(), invalidateTags: jest.fn(), invalidateSource: jest.fn(), diff --git a/src/react/index.ts b/src/react/index.ts index d900d30..69ffba2 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,4 +1,6 @@ export {DataManagerContext, useDataManager} from './DataManagerContext'; +export {DataManagerProvider} from './DataManagerProvider'; +export type {DataManagerProviderProps} from './DataManagerProvider'; export type {WithDataManagerProps} from './withDataManager'; export {withDataManager} from './withDataManager';