diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 0805c4f..e49d153 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -16,7 +16,9 @@ import { import type {InvalidateOptions, InvalidateRepeatOptions} from '../core/types/DataManagerOptions'; import type {QueryNormalizer} from './types/normalizer'; +import {checkMutationObjectsKeys} from './utils/checkMutationObjectsKeys'; import {createQueryNormalizer} from './utils/normalize'; +import {parseQueryKey} from './utils/parseQueryKey'; export interface ClientDataManagerConfig extends QueryClientConfig { normalizerConfig?: NormalizerConfig | boolean; @@ -26,8 +28,11 @@ export class ClientDataManager implements DataManager { readonly queryClient: QueryClient; readonly normalizer?: Normalizer | undefined; readonly queryNormalizer?: QueryNormalizer | undefined; + readonly normalizerConfig?: NormalizerConfig | boolean; constructor(config: ClientDataManagerConfig = {}) { + this.normalizerConfig = config.normalizerConfig; + this.queryClient = new QueryClient({ ...config, defaultOptions: { @@ -53,41 +58,98 @@ export class ClientDataManager implements DataManager { ); } - optimisticUpdate(mutationData: Data) { + optimisticUpdate(mutationData: Data, queryKey?: QueryKey, queryData?: Data) { if (!this.normalizer) { return; } + if (queryKey && queryData) { + this.optimisticUpdateQuery(queryKey, queryData); + + return; + } + const queriesToUpdate = this.normalizer.getQueriesToUpdate(mutationData); queriesToUpdate.forEach((query) => { - const queryKey = JSON.parse(query.queryKey) as QueryKey; + const parsedQueryKey = parseQueryKey(query.queryKey); - const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); + this.optimisticUpdateQuery(parsedQueryKey, query.data); + }); + } - const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; - const isInvalidated = cachedQuery?.state.isInvalidated; - const error = cachedQuery?.state.error; - const status = cachedQuery?.state.status; + invalidateData(data: Data, queryKey?: QueryKey): void { + if (!this.normalizer) { + return; + } - this.queryClient.setQueryData(queryKey, () => query.data, { - updatedAt: dataUpdatedAt, - }); + if (queryKey) { + this.invalidateQuery(queryKey); - cachedQuery?.setState({isInvalidated, error, status}); + return; + } + + const queriesToUpdate = this.normalizer.getQueriesToUpdate(data); + + queriesToUpdate.forEach((query) => { + const parsedQueryKey = parseQueryKey(query.queryKey); + + this.invalidateQuery(parsedQueryKey); }); } - invalidateData(data: Data): void { + update(data: Data) { if (!this.normalizer) { return; } + const {optimistic: globalOptimistic, invalidate: globalInvalidate} = + typeof this.normalizerConfig === 'object' + ? this.normalizerConfig + : {optimistic: false, invalidate: false}; + const queriesToUpdate = this.normalizer.getQueriesToUpdate(data); + if (queriesToUpdate.length === 0) { + const completeness = checkMutationObjectsKeys(data, this.normalizer); + const dependentQueries = this.normalizer.getDependentQueries(data); + + if (completeness.needsRefetch) { + dependentQueries.forEach((queryKeyString) => { + const parsedQueryKey = parseQueryKey(queryKeyString); + + const cachedQuery = this.queryClient + .getQueryCache() + .find({queryKey: parsedQueryKey}); + + const {invalidate} = cachedQuery?.meta ?? {}; + + if ( + invalidate === true || + (invalidate === undefined && globalInvalidate === true) + ) { + this.invalidateData(data, parsedQueryKey); + } + }); + } + + return; + } + queriesToUpdate.forEach((query) => { - const queryKey = JSON.parse(query.queryKey) as QueryKey; - this.queryClient.invalidateQueries({queryKey}); + const parsedQueryKey = parseQueryKey(query.queryKey); + + const cachedQuery = this.queryClient.getQueryCache().find({queryKey: parsedQueryKey}); + + const {optimistic, invalidate} = cachedQuery?.meta ?? {}; + + if (optimistic === true || (optimistic === undefined && globalOptimistic === true)) { + this.optimisticUpdate(data, parsedQueryKey, query.data); + } + + if (invalidate === true || (invalidate === undefined && globalInvalidate === true)) { + this.invalidateData(data, parsedQueryKey); + } }); } @@ -201,4 +263,31 @@ export class ClientDataManager implements DataManager { return createNormalizer(config); } + + private invalidateQuery(queryKey: QueryKey) { + const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); + + if ( + cachedQuery?.state.fetchStatus !== 'fetching' && + cachedQuery?.state.status === 'success' && + !cachedQuery?.state.isInvalidated + ) { + this.queryClient.invalidateQueries({queryKey}); + } + } + + private optimisticUpdateQuery(queryKey: QueryKey, queryData: Data) { + 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, () => queryData, { + updatedAt: dataUpdatedAt, + }); + + cachedQuery?.setState({isInvalidated, error, status}); + } } diff --git a/src/react-query/__tests__/threeLevelIntegration.test.tsx b/src/react-query/__tests__/threeLevelIntegration.test.tsx index 415ce8c..2acbd0d 100644 --- a/src/react-query/__tests__/threeLevelIntegration.test.tsx +++ b/src/react-query/__tests__/threeLevelIntegration.test.tsx @@ -281,4 +281,116 @@ describe('Normalization Configuration Integration', () => { dmWithBoth.queryClient.clear(); }); }); + + describe('ClientDataManager.update()', () => { + it('should work with array of objects for optimistic update', async () => { + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + }, + }); + + dm.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + const initialData = [ + {id: '1', name: 'User 1'}, + {id: '2', name: 'User 2'}, + ]; + + dm.queryClient.setQueryData(queryKey, initialData); + dm.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Update both objects + dm.update([ + {id: '1', name: 'Updated 1'}, + {id: '2', name: 'Updated 2'}, + ]); + + const data = dm.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + + expect(data[0].name).toBe('Updated 1'); + expect(data[1].name).toBe('Updated 2'); + + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + + it('should call invalidateData when invalidate option is enabled', async () => { + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + invalidate: true, + }, + }); + + dm.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + const initialData = [{id: '1', name: 'User 1'}]; + + dm.queryClient.setQueryData(queryKey, initialData); + dm.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Set query state to success so it can be invalidated + const cache = dm.queryClient.getQueryCache().find({queryKey}); + cache?.setState({status: 'success', fetchStatus: 'idle', isInvalidated: false}); + + const invalidateSpy = jest.spyOn(dm, 'invalidateData'); + + dm.update({id: '1', name: 'Updated'}); + + expect(invalidateSpy).toHaveBeenCalled(); + + invalidateSpy.mockRestore(); + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + + it('should trigger refetch when mutation has fewer keys and normy returns empty queriesToUpdate', async () => { + // checkMutationObjectsKeys is called only when getQueriesToUpdate returns [] + // This happens when normy can't compute a diff (e.g., structure mismatch) + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + invalidate: true, // Need to enable invalidate for refetch to work + }, + }); + + dm.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + // Store data with more keys + const initialData = [{id: '1', name: 'User 1', email: 'user@test.com', age: 25}]; + + dm.queryClient.setQueryData(queryKey, initialData); + dm.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Set query state to success so it can be invalidated + const cache = dm.queryClient.getQueryCache().find({queryKey}); + cache?.setState({status: 'success', fetchStatus: 'idle', isInvalidated: false}); + + // Mock getQueriesToUpdate to return empty array (simulating normy can't compute diff) + const originalGetQueriesToUpdate = dm.normalizer!.getQueriesToUpdate; + dm.normalizer!.getQueriesToUpdate = jest.fn().mockReturnValue([]); + + const invalidateSpy = jest.spyOn(dm.queryClient, 'invalidateQueries'); + + // Update with fewer keys - should trigger refetch via checkMutationObjectsKeys + dm.update({id: '1', name: 'Updated'}); + + // Check if invalidation was triggered due to fewer keys + expect(invalidateSpy).toHaveBeenCalled(); + + // Restore mocks + dm.normalizer!.getQueriesToUpdate = originalGetQueriesToUpdate; + invalidateSpy.mockRestore(); + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + }); }); diff --git a/src/react-query/impl/infinite/utils.ts b/src/react-query/impl/infinite/utils.ts index b88ca9d..2c0a355 100644 --- a/src/react-query/impl/infinite/utils.ts +++ b/src/react-query/impl/infinite/utils.ts @@ -57,6 +57,11 @@ export const composeOptions = ( return transformResponse ? transformResponse(actualResponse) : actualResponse; }; + const meta = { + invalidate: options?.invalidate, + optimistic: options?.optimistic, + }; + return { queryKey: composeFullKey(dataSource, params), queryFn: params === idle ? skipToken : queryFn, @@ -64,6 +69,7 @@ export const composeOptions = ( initialPageParam: EMPTY_OBJECT, getNextPageParam: next, getPreviousPageParam: prev, + meta, ...dataSource.options, ...options, }; diff --git a/src/react-query/impl/plain/utils.ts b/src/react-query/impl/plain/utils.ts index acff46d..b3f42fa 100644 --- a/src/react-query/impl/plain/utils.ts +++ b/src/react-query/impl/plain/utils.ts @@ -50,10 +50,16 @@ export const composeOptions = ( return transformResponse ? transformResponse(actualResponse) : actualResponse; }; + const meta = { + invalidate: options?.invalidate, + optimistic: options?.optimistic, + }; + return { queryKey: composeFullKey(dataSource, params), queryFn: params === idle ? skipToken : queryFn, select, + meta, ...dataSource.options, ...options, }; diff --git a/src/react-query/types/query-meta.d.ts b/src/react-query/types/query-meta.d.ts new file mode 100644 index 0000000..1ef2a2e --- /dev/null +++ b/src/react-query/types/query-meta.d.ts @@ -0,0 +1,12 @@ +import '@tanstack/react-query'; + +import type {OptimisticConfig} from '../../core/types/Normalizer'; + +declare module '@tanstack/react-query' { + interface Register { + queryMeta: { + optimistic?: boolean | OptimisticConfig; + invalidate?: boolean; + }; + } +} diff --git a/src/react-query/utils/__tests__/checkMutationObjectsKeys.test.ts b/src/react-query/utils/__tests__/checkMutationObjectsKeys.test.ts new file mode 100644 index 0000000..f2c47fa --- /dev/null +++ b/src/react-query/utils/__tests__/checkMutationObjectsKeys.test.ts @@ -0,0 +1,267 @@ +import type {Data} from '@normy/core'; +import {createNormalizer} from '@normy/core'; + +import {checkMutationObjectsKeys} from '../checkMutationObjectsKeys'; + +const createMockNormalizer = (objects: Record>) => { + const normalizer = createNormalizer({}); + + // Manually set normalized data for testing + Object.keys(objects).forEach((key) => { + const id = key.replace('@@', ''); + // Use object as-is, id is already included in objects[key] + normalizer.setQuery(`test-${id}`, objects[key] as Data); + }); + + return normalizer; +}; + +describe('checkMutationObjectsKeys', () => { + describe('when mutation data has no normalizable objects', () => { + it('should return needsRefetch: false for primitive values', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys('string value', normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should return needsRefetch: false for null', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys(null, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should return needsRefetch: false for empty array', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys([], normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should return needsRefetch: false for object without id', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys({name: 'test'}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + }); + + describe('when mutation object is not in normalized store', () => { + it('should return needsRefetch: false for new object', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys({id: 'new-id', name: 'test'}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + }); + + describe('when mutation has same keys as normalized data', () => { + it('should return needsRefetch: false for identical keys', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test', value: 100}, + }); + + const result = checkMutationObjectsKeys( + {id: '1', name: 'updated', value: 200}, + normalizer, + ); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should return needsRefetch: false when keys are same but in different order', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', a: 1, b: 2, c: 3}, + }); + + const result = checkMutationObjectsKeys({c: 30, a: 10, b: 20, id: '1'}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + }); + + describe('when mutation has fewer keys than normalized data', () => { + it('should return needsRefetch: true with missing keys', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test', email: 'test@example.com', age: 25}, + }); + + const result = checkMutationObjectsKeys({id: '1', name: 'updated'}, normalizer); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(1); + expect(result.details[0].id).toBe('1'); + expect(result.details[0].missingKeys).toContain('email'); + expect(result.details[0].missingKeys).toContain('age'); + }); + + it('should return needsRefetch: true when only id is present', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', field1: 'a', field2: 'b', field3: 'c'}, + }); + + const result = checkMutationObjectsKeys({id: '1'}, normalizer); + + expect(result.needsRefetch).toBe(true); + expect(result.details[0].missingKeys).toEqual( + expect.arrayContaining(['field1', 'field2', 'field3']), + ); + }); + }); + + describe('when mutation has different keys than normalized data', () => { + it('should return needsRefetch: true when mutation has fewer keys even if some are different', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', oldField: 'old', anotherField: 'value'}, + }); + + // Mutation has fewer keys (2 vs 3), so needsRefetch should be true + const result = checkMutationObjectsKeys({id: '1', newField: 'new'}, normalizer); + + expect(result.needsRefetch).toBe(true); + expect(result.details[0].missingKeys).toContain('oldField'); + expect(result.details[0].missingKeys).toContain('anotherField'); + }); + }); + + describe('with array of objects', () => { + it('should check all objects in array', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'first', extra: 'data'}, + '@@2': {id: '2', name: 'second', extra: 'data'}, + }); + + const result = checkMutationObjectsKeys( + [ + {id: '1', name: 'updated'}, + {id: '2', name: 'updated'}, + ], + normalizer, + ); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(2); + expect(result.details[0].id).toBe('1'); + expect(result.details[1].id).toBe('2'); + }); + + it('should handle nested arrays', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', field: 'a', extra: 'b'}, + '@@2': {id: '2', field: 'c', extra: 'd'}, + }); + + const result = checkMutationObjectsKeys( + [[{id: '1', field: 'updated'}], [{id: '2', field: 'updated'}]], + normalizer, + ); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(2); + }); + }); + + describe('with custom getNormalizationObjectKey', () => { + it('should use custom key function', () => { + const normalizer = createNormalizer({ + getNormalizationObjectKey: (obj) => + obj && typeof obj === 'object' && '_id' in obj + ? String((obj as {_id: unknown})._id) + : undefined, + }); + + // Set query with custom key + normalizer.setQuery('test', {_id: 'custom-1', name: 'test', extra: 'field'}); + + const result = checkMutationObjectsKeys( + {_id: 'custom-1', name: 'updated'}, + normalizer, + { + getNormalizationObjectKey: (obj) => + '_id' in obj ? String(obj._id) : undefined, + }, + ); + + expect(result.needsRefetch).toBe(true); + expect(result.details[0].id).toBe('custom-1'); + }); + }); + + describe('edge cases', () => { + it('should handle object with undefined id', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys({id: undefined, name: 'test'}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should handle object with numeric id', () => { + const normalizer = createMockNormalizer({ + '@@123': {id: '123', value: 'test', extra: 'field'}, + }); + + const result = checkMutationObjectsKeys({id: '123', value: 'updated'}, normalizer); + + expect(result.needsRefetch).toBe(true); + }); + + it('should handle large number of objects', () => { + const objects: Record> = {}; + const mutationData: Array<{id: string; name: string}> = []; + + for (let i = 0; i < 100; i++) { + objects[`@@${i}`] = {id: String(i), name: `item-${i}`, extra: 'field'}; + mutationData.push({id: String(i), name: `updated-${i}`}); + } + + const normalizer = createMockNormalizer(objects); + + const result = checkMutationObjectsKeys(mutationData, normalizer); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(100); + }); + + it('should handle empty object', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys({}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + }); +}); diff --git a/src/react-query/utils/checkMutationObjectsKeys.ts b/src/react-query/utils/checkMutationObjectsKeys.ts new file mode 100644 index 0000000..7e89cd9 --- /dev/null +++ b/src/react-query/utils/checkMutationObjectsKeys.ts @@ -0,0 +1,105 @@ +import {getId} from '@normy/core'; + +import type {Normalizer} from '../../core'; + +type DataObject = Record; +type NormalizableObject = DataObject & {id?: string}; + +export interface AffectedObject { + id: string; + missingKeys: string[]; +} + +export interface MutationObjectsKeysResult { + needsRefetch: boolean; + details: AffectedObject[]; +} + +const hasFewerKeys = (mutation: DataObject, existing: DataObject): boolean => { + const mutationKeys = Object.keys(mutation); + const existingKeys = Object.keys(existing); + + if (mutationKeys.length >= existingKeys.length) { + return false; + } + + for (const key of mutationKeys) { + if (!existingKeys.includes(key)) { + return true; + } + } + + return true; +}; + +const extractNormalizableObjects = ( + data: unknown, + getNormalizationObjectKey: (obj: NormalizableObject) => string | undefined, +): NormalizableObject[] => { + const objects: NormalizableObject[] = []; + + function extract(item: unknown): void { + if (Array.isArray(item)) { + item.forEach(extract); + } else if (item !== null && typeof item === 'object' && !(item instanceof Date)) { + const obj = item as NormalizableObject; + + if (getNormalizationObjectKey(obj)) { + objects.push(obj); + } + } + } + + extract(data); + + return objects; +}; + +export const checkMutationObjectsKeys = ( + mutationData: unknown, + normalizer: Normalizer, + config?: {getNormalizationObjectKey?: (obj: NormalizableObject) => string | undefined}, +): MutationObjectsKeysResult => { + const getNormalizationObjectKey = + config?.getNormalizationObjectKey || ((obj: NormalizableObject) => obj.id); + + const normalizedState = normalizer.getNormalizedData(); + + const mutationObjects = extractNormalizableObjects(mutationData, getNormalizationObjectKey); + + if (mutationObjects.length === 0) { + return { + needsRefetch: false, + details: [], + }; + } + + const details: AffectedObject[] = []; + + for (const obj of mutationObjects) { + const objectKey = getNormalizationObjectKey(obj); + + if (!objectKey) { + continue; + } + + const normalizedKey = getId(objectKey); + + const existingObject = normalizedState.objects[normalizedKey] as DataObject | undefined; + + if (existingObject && hasFewerKeys(obj, existingObject)) { + const mutationKeys = Object.keys(obj); + const existingKeys = Object.keys(existingObject); + + details.push({ + id: objectKey, + missingKeys: existingKeys.filter((k) => !mutationKeys.includes(k)), + }); + } + } + + return { + needsRefetch: details.length > 0, + details, + }; +}; diff --git a/src/react-query/utils/parseQueryKey.ts b/src/react-query/utils/parseQueryKey.ts new file mode 100644 index 0000000..902401d --- /dev/null +++ b/src/react-query/utils/parseQueryKey.ts @@ -0,0 +1,5 @@ +import type {QueryKey} from '@tanstack/react-query'; + +export const parseQueryKey = (queryKeyString: string): QueryKey => { + return JSON.parse(queryKeyString) as QueryKey; +};