Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 103 additions & 14 deletions src/react-query/ClientDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: {
Expand All @@ -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);
}
});
}

Expand Down Expand Up @@ -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});
}
}
112 changes: 112 additions & 0 deletions src/react-query/__tests__/threeLevelIntegration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
6 changes: 6 additions & 0 deletions src/react-query/impl/infinite/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,19 @@ export const composeOptions = <TDataSource extends AnyInfiniteQueryDataSource>(
return transformResponse ? transformResponse(actualResponse) : actualResponse;
};

const meta = {
invalidate: options?.invalidate,
optimistic: options?.optimistic,
};

return {
queryKey: composeFullKey(dataSource, params),
queryFn: params === idle ? skipToken : queryFn,
select: (data) => ({...data, pages: data.pages.map(selectPage)}),
initialPageParam: EMPTY_OBJECT,
getNextPageParam: next,
getPreviousPageParam: prev,
meta,
...dataSource.options,
...options,
};
Expand Down
6 changes: 6 additions & 0 deletions src/react-query/impl/plain/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ export const composeOptions = <TDataSource extends AnyPlainQueryDataSource>(
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,
};
Expand Down
12 changes: 12 additions & 0 deletions src/react-query/types/query-meta.d.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
}
Loading
Loading