From 25cdbfc284ee966bc8b37eec2d4ba807d36b0605 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 21 Oct 2025 10:02:57 +0200 Subject: [PATCH 01/11] feat: add normalize --- README-ru.md | 281 +++++++++ README.md | 281 +++++++++ src/react-query/impl/infinite/types.ts | 3 +- .../normalize/QueryNormalizerProvider.tsx | 78 +++ .../QueryNormalizerProvider.test.tsx | 357 ++++++++++++ .../__tests__/createQueryNormalizer.test.tsx | 196 +++++++ .../__tests__/normalizationEdgeCases.test.tsx | 92 +++ .../__tests__/subscriptions.test.tsx | 536 ++++++++++++++++++ .../__tests__/threeLevelIntegration.test.tsx | 165 ++++++ .../updateQueriesFromMutationData.test.tsx | 166 ++++++ src/react-query/normalize/normalization.ts | 200 +++++++ src/react-query/types/options.ts | 4 + .../utils/__tests__/normalize.test.ts | 94 +++ 13 files changed, 2451 insertions(+), 2 deletions(-) create mode 100644 src/react-query/normalize/QueryNormalizerProvider.tsx create mode 100644 src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx create mode 100644 src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx create mode 100644 src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx create mode 100644 src/react-query/normalize/__tests__/subscriptions.test.tsx create mode 100644 src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx create mode 100644 src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx create mode 100644 src/react-query/normalize/normalization.ts create mode 100644 src/react-query/utils/__tests__/normalize.test.ts diff --git a/README-ru.md b/README-ru.md index 0eba432..9a4bca7 100644 --- a/README-ru.md +++ b/README-ru.md @@ -39,6 +39,20 @@ function App() { } ``` +Или используйте упрощенный `DataSourceProvider` (рекомендуется): + +```tsx +import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; + +function App() { + return ( + + + + ); +} +``` + ### 2. Определение типов ошибок и оберток Определите тип ошибки и создайте свои конструкторы для источников данных на основе стандартных конструкторов: @@ -764,6 +778,273 @@ const UserProfile: React.FC<{userId: number}> = ({userId}) => { }; ``` +## Нормализация данных + +Data Source предоставляет встроенную нормализацию данных с использованием [@normy/core](https://github.com/klis87/normy). Нормализация помогает управлять реляционными данными, храня сущности по их ID и автоматически обновляя все связанные запросы при изменении данных. + +### Зачем нужна нормализация? + +Без нормализации одна и та же сущность может дублироваться в нескольких запросах. Когда вы обновляете эту сущность в одном месте, другие запросы остаются устаревшими до повторной загрузки. Нормализация решает эту проблему: + +1. **Хранение сущностей один раз** - Каждая сущность хранится по её ID в нормализованном хранилище +2. **Автоматические обновления** - При изменении сущности все запросы, использующие её, автоматически обновляются +3. **Консистентность** - Ваш UI всегда отображает актуальные данные во всех компонентах +4. **Экономия памяти** - Нет дублирования одних и тех же данных + +### Настройка с DataSourceProvider + +Самый простой способ включить нормализацию - использовать `DataSourceProvider`: + +```tsx +import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; + +const dataManager = new ClientDataManager({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + }, + }, +}); + +function App() { + return ( + + + + ); +} +``` + +`DataSourceProvider` - это удобная обертка, которая объединяет: + +- `QueryNormalizerProvider` - Обрабатывает нормализацию данных +- `QueryClientProvider` - Провайдер React Query +- `DataManagerContext.Provider` - Контекст Data Source + +### Ручная настройка с QueryNormalizerProvider + +Для большего контроля можно настроить провайдеры вручную: + +```tsx +import {QueryClientProvider} from '@tanstack/react-query'; +import { + ClientDataManager, + DataManagerContext, + QueryNormalizerProvider, +} from '@gravity-ui/data-source'; + +function App() { + return ( + + + + + + + + ); +} +``` + +### Настройка источников данных для нормализации + +Вы можете включать/выключать нормализацию для конкретного источника данных или запроса: + +```ts +const userDataSource = makePlainQueryDataSource({ + name: 'user', + fetch: skipContext(fetchUser), + options: { + normalizationConfig: { + normalize: true, // Переопределить глобальную настройку для этого источника + }, + }, +}); + +// В компоненте - переопределить для конкретного запроса +const {data} = useQueryData( + userDataSource, + {userId: 123}, + { + normalizationConfig: { + normalize: false, // Отключить нормализацию для этого конкретного запроса + }, + }, +); +``` + +### Оптимистичные обновления + +Оптимистичные обновления позволяют обновить UI немедленно до ответа сервера, обеспечивая мгновенную обратную связь: + +```tsx +import {useMutation} from '@tanstack/react-query'; +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function UserProfile() { + const queryNormalizer = useQueryNormalizer(); + + const mutation = useMutation({ + mutationFn: updateUser, + onMutate: async (newUser) => { + // Вернуть оптимистичные данные, которые будут автоматически применены + return { + optimisticData: { + users: { + [newUser.id]: newUser, + }, + }, + }; + }, + // Откат рассчитывается автоматически если autoCalculateRollback: true + // Иначе вы можете предоставить его вручную: + onMutate: async (newUser) => { + const currentUser = queryNormalizer.getObjectById('users', newUser.id); + return { + optimisticData: { + users: {[newUser.id]: newUser}, + }, + rollbackData: { + users: {[newUser.id]: currentUser}, + }, + }; + }, + // Настройка для конкретной мутации + optimisticUpdateConfig: { + enabled: true, + devLogging: true, + }, + }); +} +``` + +### Работа с нормализованными данными + +`QueryNormalizerProvider` предоставляет хук `useQueryNormalizer` с вспомогательными методами: + +```tsx +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function MyComponent() { + const queryNormalizer = useQueryNormalizer(); + + // Получить все нормализованные данные + const normalizedData = queryNormalizer.getNormalizedData(); + + // Получить конкретную сущность по ID + const user = queryNormalizer.getObjectById('users', '123'); + + // Получить фрагмент данных + const fragment = queryNormalizer.getQueryFragment({ + users: { + '123': {id: true, name: true}, + }, + }); + + // Найти запросы, зависящие от конкретных данных + const dependentQueries = queryNormalizer.getDependentQueries({ + users: { + '123': {id: '123', name: 'Updated Name'}, + }, + }); + + // Найти запросы по ID сущностей + const queries = queryNormalizer.getDependentQueriesByIds(['users.123', 'posts.456']); + + // Вручную обновить нормализованные данные (например, из WebSocket) + queryNormalizer.setNormalizedData({ + users: { + '123': {id: '123', name: 'Real-time Update'}, + }, + }); + + // Очистить все нормализованные данные + queryNormalizer.clear(); +} +``` + +### Пример real-time обновлений + +Нормализация отлично работает с WebSocket или другими real-time обновлениями: + +```tsx +import {useEffect} from 'react'; +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function useWebSocketUpdates() { + const queryNormalizer = useQueryNormalizer(); + + useEffect(() => { + const ws = new WebSocket('wss://api.example.com/updates'); + + ws.onmessage = (event) => { + const update = JSON.parse(event.data); + + // Автоматически обновляет все запросы, использующие эти данные + queryNormalizer.setNormalizedData(update); + }; + + return () => ws.close(); + }, [queryNormalizer]); +} +``` + +### Справочник по конфигурации + +#### NormalizerConfig + +```ts +interface DataSourceNormalizerConfig { + /** Включена ли нормализация, по умолчанию false */ + normalize?: boolean; + + // Опции конфигурации @normy/core: + /** Использовать ли структурное разделение для обновлений */ + structuralSharing?: boolean; + /** Пользовательские схемы нормализации */ + schemas?: Record; + /** Пользовательское имя поля ID (по умолчанию: 'id') */ + idAttribute?: string; +} +``` + +#### OptimisticUpdateConfig + +```ts +interface OptimisticUpdateConfig { + /** Включена ли оптимистичная синхронизация, по умолчанию false. Внимание: не будет работать без нормализации */ + enabled?: boolean; + /** Автоматически рассчитывать данные для отката, по умолчанию true */ + autoCalculateRollback?: boolean; + /** Включено ли debug логирование */ + devLogging?: boolean; +} +``` + +### Лучшие практики + +1. **Включайте глобально, отключайте выборочно** - Включите нормализацию на уровне провайдера, отключайте только для конкретных запросов, которым она не нужна +2. **Используйте консистентные структуры сущностей** - Убедитесь, что ваш API возвращает сущности с консистентными полями ID +3. **Используйте оптимистичные обновления** - Для лучшего UX используйте оптимистичные обновления с автоматическим откатом +4. **Мониторьте в разработке** - Включите `devLogging` во время разработки, чтобы понимать поведение нормализации +5. **Комбинируйте с тегами** - Используйте и нормализацию, и теги для комплексного управления кешем + ## Поддержка TypeScript Библиотека построена с TypeScript-first подходом и обеспечивает полный вывод типов: diff --git a/README.md b/README.md index 2e0ec8f..3fef965 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ function App() { } ``` +Or use the simplified `DataSourceProvider` (recommended): + +```tsx +import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; + +function App() { + return ( + + + + ); +} +``` + ### 2. Define Error Types and Wrappers Define a type of error and make your constructors for data sources based on default constructors: @@ -764,6 +778,273 @@ const UserProfile: React.FC<{userId: number}> = ({userId}) => { }; ``` +## Data Normalization + +Data Source provides built-in data normalization using [@normy/core](https://github.com/klis87/normy). Normalization helps manage relational data by storing entities by their IDs and automatically updating all related queries when data changes. + +### Why Normalization? + +Without normalization, the same entity might be duplicated across multiple queries. When you update this entity in one place, other queries remain outdated until they refetch. Normalization solves this by: + +1. **Storing entities once** - Each entity is stored by its ID in a normalized store +2. **Automatic updates** - When an entity changes, all queries using it are automatically updated +3. **Consistency** - Your UI always shows the latest data across all components +4. **Reduced memory** - No duplication of the same data + +### Setup with DataSourceProvider + +The simplest way to enable normalization is to use `DataSourceProvider`: + +```tsx +import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; + +const dataManager = new ClientDataManager({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + }, + }, +}); + +function App() { + return ( + + + + ); +} +``` + +`DataSourceProvider` is a convenient wrapper that combines: + +- `QueryNormalizerProvider` - Handles data normalization +- `QueryClientProvider` - React Query provider +- `DataManagerContext.Provider` - Data Source context + +### Manual Setup with QueryNormalizerProvider + +For more control, you can set up providers manually: + +```tsx +import {QueryClientProvider} from '@tanstack/react-query'; +import { + ClientDataManager, + DataManagerContext, + QueryNormalizerProvider, +} from '@gravity-ui/data-source'; + +function App() { + return ( + + + + + + + + ); +} +``` + +### Configuring Data Sources for Normalization + +You can enable/disable normalization per data source or per query: + +```ts +const userDataSource = makePlainQueryDataSource({ + name: 'user', + fetch: skipContext(fetchUser), + options: { + normalizationConfig: { + normalize: true, // Override global setting for this data source + }, + }, +}); + +// In component - override for specific query +const {data} = useQueryData( + userDataSource, + {userId: 123}, + { + normalizationConfig: { + normalize: false, // Disable normalization for this specific query + }, + }, +); +``` + +### Optimistic Updates + +Optimistic updates allow you to update the UI immediately before the server responds, providing instant feedback: + +```tsx +import {useMutation} from '@tanstack/react-query'; +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function UserProfile() { + const queryNormalizer = useQueryNormalizer(); + + const mutation = useMutation({ + mutationFn: updateUser, + onMutate: async (newUser) => { + // Return optimistic data that will be automatically applied + return { + optimisticData: { + users: { + [newUser.id]: newUser, + }, + }, + }; + }, + // Rollback is calculated automatically if autoCalculateRollback: true + // Otherwise you can provide it manually: + onMutate: async (newUser) => { + const currentUser = queryNormalizer.getObjectById('users', newUser.id); + return { + optimisticData: { + users: {[newUser.id]: newUser}, + }, + rollbackData: { + users: {[newUser.id]: currentUser}, + }, + }; + }, + // Configure per mutation + optimisticUpdateConfig: { + enabled: true, + devLogging: true, + }, + }); +} +``` + +### Working with Normalized Data + +The `QueryNormalizerProvider` provides a `useQueryNormalizer` hook with utility methods: + +```tsx +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function MyComponent() { + const queryNormalizer = useQueryNormalizer(); + + // Get all normalized data + const normalizedData = queryNormalizer.getNormalizedData(); + + // Get specific entity by ID + const user = queryNormalizer.getObjectById('users', '123'); + + // Get data fragment + const fragment = queryNormalizer.getQueryFragment({ + users: { + '123': {id: true, name: true}, + }, + }); + + // Find which queries depend on specific data + const dependentQueries = queryNormalizer.getDependentQueries({ + users: { + '123': {id: '123', name: 'Updated Name'}, + }, + }); + + // Find queries by entity IDs + const queries = queryNormalizer.getDependentQueriesByIds(['users.123', 'posts.456']); + + // Manually update normalized data (e.g., from WebSocket) + queryNormalizer.setNormalizedData({ + users: { + '123': {id: '123', name: 'Real-time Update'}, + }, + }); + + // Clear all normalized data + queryNormalizer.clear(); +} +``` + +### Real-time Updates Example + +Normalization works great with WebSocket or other real-time updates: + +```tsx +import {useEffect} from 'react'; +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function useWebSocketUpdates() { + const queryNormalizer = useQueryNormalizer(); + + useEffect(() => { + const ws = new WebSocket('wss://api.example.com/updates'); + + ws.onmessage = (event) => { + const update = JSON.parse(event.data); + + // Automatically updates all queries that use this data + queryNormalizer.setNormalizedData(update); + }; + + return () => ws.close(); + }, [queryNormalizer]); +} +``` + +### Configuration Reference + +#### NormalizerConfig + +```ts +interface DataSourceNormalizerConfig { + /** Whether normalization is enabled, defaults to false */ + normalize?: boolean; + + // @normy/core configuration options: + /** Whether to use structural sharing for updates */ + structuralSharing?: boolean; + /** Custom normalization schemas */ + schemas?: Record; + /** Custom ID field name (default: 'id') */ + idAttribute?: string; +} +``` + +#### OptimisticUpdateConfig + +```ts +interface OptimisticUpdateConfig { + /** Whether optimistic synchronization is enabled, defaults to false. Note: won't work without normalization */ + enabled?: boolean; + /** Automatically calculate rollback data, defaults to true */ + autoCalculateRollback?: boolean; + /** Whether debug logging is enabled */ + devLogging?: boolean; +} +``` + +### Best Practices + +1. **Enable globally, disable selectively** - Enable normalization at the provider level, disable only for specific queries that don't need it +2. **Use consistent entity structures** - Ensure your API returns entities with consistent ID fields +3. **Leverage optimistic updates** - For better UX, use optimistic updates with automatic rollback +4. **Monitor in development** - Enable `devLogging` during development to understand normalization behavior +5. **Combine with tags** - Use both normalization and tags for comprehensive cache management + ## TypeScript Support The library is built with TypeScript-first approach and provides full type inference: diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index 042f302..8668a25 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -26,8 +26,7 @@ export type InfiniteQueryObserverExtendedOptions< TError, InfiniteData, TQueryKey - > ->; + >; export type InfiniteQueryDataSource = DataSource< QueryDataSourceContext, diff --git a/src/react-query/normalize/QueryNormalizerProvider.tsx b/src/react-query/normalize/QueryNormalizerProvider.tsx new file mode 100644 index 0000000..a68837b --- /dev/null +++ b/src/react-query/normalize/QueryNormalizerProvider.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import {createNormalizer} from '@normy/core'; +import type {NormalizedData} from '@normy/core/types/types'; +import type {QueryClient} from '@tanstack/react-query'; + +import type { + DataSourceNormalizerConfig, + Normalizer, + OptimisticUpdateConfig, +} from '../types/normalizer'; + +import {createQueryNormalizer} from './normalization'; + +const QueryNormalizerContext = React.createContext< + undefined | ReturnType +>(undefined); + +export interface QueryNormalizerProviderProps { + /** React Query client instance */ + queryClient: QueryClient; + children: React.ReactNode; + /** Configuration for the normalizer */ + normalizerConfig?: DataSourceNormalizerConfig; + /** Initial normalized data to populate the store */ + initialNormalizedData?: NormalizedData; + /** Configuration for optimistic updates */ + optimisticUpdateConfig?: OptimisticUpdateConfig; + /** Custom normalizer instance */ + normalizer?: Normalizer; +} + +export const QueryNormalizerProvider: React.FC = ({ + queryClient, + normalizerConfig = {}, + initialNormalizedData, + optimisticUpdateConfig = {}, + normalizer: customNormalizer, + children, +}) => { + const [queryNormalizer] = React.useState(() => { + const normalizer = + customNormalizer ?? createNormalizer(normalizerConfig, initialNormalizedData); + + return createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + }); + + React.useEffect(() => { + queryNormalizer.subscribe(); + + return () => { + queryNormalizer.unsubscribe(); + queryNormalizer.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +}; + +export const useQueryNormalizer = () => { + const queryNormalizer = React.useContext(QueryNormalizerContext); + + if (!queryNormalizer) { + throw new Error('No QueryNormalizer set, use QueryNormalizerProvider to set one'); + } + + return queryNormalizer; +}; diff --git a/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx b/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx new file mode 100644 index 0000000..b237a87 --- /dev/null +++ b/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx @@ -0,0 +1,357 @@ +import React from 'react'; + +import {QueryClient, QueryClientProvider, useMutation, useQuery} from '@tanstack/react-query'; +import {renderHook, waitFor} from '@testing-library/react'; + +import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; +import {QueryNormalizerProvider, useQueryNormalizer} from '../QueryNormalizerProvider'; + +describe('QueryNormalizerProvider', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const createWrapper = ( + normalizerConfig?: DataSourceNormalizerConfig, + optimisticUpdateConfig?: OptimisticUpdateConfig, + ) => { + const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) => ( + + + {children} + + + ); + Wrapper.displayName = 'TestWrapper'; + return Wrapper; + }; + + describe('Provider setup', () => { + it('should provide queryNormalizer through context', () => { + const wrapper = createWrapper({normalize: true}); + + const {result} = renderHook(() => useQueryNormalizer(), {wrapper}); + + expect(result.current).toBeDefined(); + expect(result.current.getNormalizedData).toBeDefined(); + }); + + it('should throw error if used outside Provider', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + renderHook(() => useQueryNormalizer()); + }).toThrow('No QueryNormalizer set, use QueryNormalizerProvider to set one'); + + consoleSpy.mockRestore(); + }); + + it('should create normalizer with global configuration', () => { + const getNormalizationObjectKey = jest.fn((obj) => obj.id); + + const wrapper = createWrapper({ + normalize: true, + getNormalizationObjectKey, + }); + + renderHook(() => useQueryNormalizer(), {wrapper}); + + // Normalizer should be created with passed function + expect(getNormalizationObjectKey).toBeDefined(); + }); + }); + + describe('React Query integration', () => { + it('should normalize data from useQuery', async () => { + const wrapper = createWrapper({normalize: true}); + + const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); + + const {result: queryResult} = renderHook( + () => + useQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + }), + {wrapper}, + ); + + await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); + + const normalized = normalizerResult.current.getNormalizedData(); + + expect(normalized.objects['@@1']).toBeDefined(); + expect(normalized.objects['@@1'].name).toBe('User 1'); + }); + + it('should update data on mutation', async () => { + const wrapper = createWrapper({normalize: true}); + + const {result: queryResult} = renderHook( + () => + useQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + }), + {wrapper}, + ); + + await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); + + // Mutation + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async (name: string) => ({ + id: '1', + name, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate('Updated User'); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + // Query data should be updated + await waitFor(() => { + const data = queryClient.getQueryData(['users']) as any[]; + expect(data[0].name).toBe('Updated User'); + }); + }); + + it('should work with multiple queries', async () => { + const wrapper = createWrapper({normalize: true}); + + // Two queries with the same object + const {result: query1} = renderHook( + () => + useQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + }), + {wrapper}, + ); + + const {result: query2} = renderHook( + () => + useQuery({ + queryKey: ['user', '1'], + queryFn: async () => ({id: '1', name: 'User 1'}), + }), + {wrapper}, + ); + + await waitFor(() => expect(query1.current.isSuccess).toBe(true)); + await waitFor(() => expect(query2.current.isSuccess).toBe(true)); + + // Mutation + const {result: mutation} = renderHook( + () => + useMutation({ + mutationFn: async () => ({ + id: '1', + name: 'Updated User', + }), + }), + {wrapper}, + ); + + mutation.current.mutate(); + + await waitFor(() => expect(mutation.current.isSuccess).toBe(true)); + + // Both queries should be updated + await waitFor(() => { + const users = queryClient.getQueryData(['users']) as any[]; + const user = queryClient.getQueryData(['user', '1']) as any; + + expect(users[0].name).toBe('Updated User'); + expect(user.name).toBe('Updated User'); + }); + }); + }); + + describe('Optimistic updates via Provider', () => { + it('should apply optimistic updates', async () => { + const wrapper = createWrapper( + {normalize: true}, + { + enabled: true, + autoCalculateRollback: true, + }, + ); + + // Initial data + const {result: queryResult} = renderHook( + () => + useQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'Original'}], + }), + {wrapper}, + ); + + await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); + + // Mutation with optimistic data + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return {id: '1', name: 'Final'}; + }, + onMutate: () => ({ + optimisticData: {id: '1', name: 'Optimistic'}, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + // Should have optimistic data immediately + await waitFor(() => { + const data = queryClient.getQueryData(['users']) as any[]; + expect(data[0].name).toBe('Optimistic'); + }); + + // After completion - final data + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + await waitFor(() => { + const data = queryClient.getQueryData(['users']) as any[]; + expect(data[0].name).toBe('Final'); + }); + }); + + it('should rollback changes on error', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + const wrapper = createWrapper( + {normalize: true}, + { + enabled: true, + autoCalculateRollback: true, + devLogging: true, + }, + ); + + // Initial data + const {result: queryResult} = renderHook( + () => + useQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'Original'}], + }), + {wrapper}, + ); + + await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); + + // Mutation that will fail + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + throw new Error('Failed'); + }, + onMutate: () => ({ + optimisticData: {id: '1', name: 'Optimistic'}, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + // Should have optimistic data + await waitFor(() => { + const data = queryClient.getQueryData(['users']) as any[]; + expect(data[0].name).toBe('Optimistic'); + }); + + // After error - rollback + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + await waitFor(() => { + const data = queryClient.getQueryData(['users']) as any[]; + expect(data[0].name).toBe('Original'); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('Cleanup', () => { + it('should unsubscribe on unmount', () => { + const wrapper = createWrapper({normalize: true}); + + const {result, unmount} = renderHook(() => useQueryNormalizer(), {wrapper}); + + expect(result.current).toBeDefined(); + + // Should not throw errors on unmount + expect(() => unmount()).not.toThrow(); + }); + + it('should clear data on unmount', async () => { + const wrapper = createWrapper({normalize: true}); + + const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); + + // Add data + await queryClient.fetchQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + }); + + const normalizedBefore = normalizerResult.current.getNormalizedData(); + expect(Object.keys(normalizedBefore.objects)).toHaveLength(1); + }); + }); + + describe('Default configuration', () => { + it('should work with empty configuration', () => { + const wrapper = createWrapper(); + + const {result} = renderHook(() => useQueryNormalizer(), {wrapper}); + + expect(result.current).toBeDefined(); + }); + + it('normalization should be disabled by default', async () => { + const wrapper = createWrapper(); // normalize not specified + + const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); + + await queryClient.fetchQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + }); + + const normalized = normalizerResult.current.getNormalizedData(); + + // Data should not be normalized + expect(Object.keys(normalized.objects)).toHaveLength(0); + }); + }); +}); diff --git a/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx b/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx new file mode 100644 index 0000000..45b99cb --- /dev/null +++ b/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx @@ -0,0 +1,196 @@ +import {createNormalizer} from '@normy/core'; +import {QueryClient} from '@tanstack/react-query'; + +import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; +import {createQueryNormalizer} from '../normalization'; + +describe('createQueryNormalizer', () => { + let queryClient: QueryClient; + let normalizer: ReturnType; + + const normalizerConfig: DataSourceNormalizerConfig = { + normalize: true, + }; + + const optimisticUpdateConfig: OptimisticUpdateConfig = { + enabled: true, + autoCalculateRollback: true, + }; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + }); + + normalizer = createNormalizer({ + devLogging: false, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('should create queryNormalizer with required methods', () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + expect(queryNormalizer.getNormalizedData).toBeDefined(); + expect(queryNormalizer.setNormalizedData).toBeDefined(); + expect(queryNormalizer.clear).toBeDefined(); + expect(queryNormalizer.getObjectById).toBeDefined(); + expect(queryNormalizer.getQueryFragment).toBeDefined(); + expect(queryNormalizer.getDependentQueries).toBeDefined(); + expect(queryNormalizer.getDependentQueriesByIds).toBeDefined(); + expect(queryNormalizer.subscribe).toBeDefined(); + expect(queryNormalizer.unsubscribe).toBeDefined(); + }); + + it('getNormalizedData should return normalized data', () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + const data = queryNormalizer.getNormalizedData(); + expect(data).toBeDefined(); + expect(data.objects).toBeDefined(); + expect(data.queries).toBeDefined(); + }); + + it('setNormalizedData should update queries', () => { + const queryKey = ['users']; + queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.setNormalizedData({id: '1', name: 'New'}); + + const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + expect(data[0].name).toBe('New'); + }); + + it('clear should clear normalized data', () => { + const queryKey = ['users']; + queryClient.setQueryData(queryKey, [{id: '1', name: 'User'}]); + normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'User'}]); + + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.clear(); + + const data = queryNormalizer.getNormalizedData(); + expect(Object.keys(data.objects)).toHaveLength(0); + }); + + it('getObjectById should return object by ID', () => { + const queryKey = ['users']; + const userData = [{id: '1', name: 'User 1'}]; + + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + // Add to normalizer + normalizer.setQuery(JSON.stringify(queryKey), userData); + + // Get normalized data + const normalized = queryNormalizer.getNormalizedData(); + expect(Object.keys(normalized.objects).length).toBeGreaterThan(0); + + // getObjectById should be defined and available + expect(queryNormalizer.getObjectById).toBeDefined(); + expect(typeof queryNormalizer.getObjectById).toBe('function'); + }); + + it('getDependentQueries should return dependent queries', () => { + const queryKey1 = ['users']; + const queryKey2 = ['user', '1']; + + queryClient.setQueryData(queryKey1, [{id: '1', name: 'User'}]); + queryClient.setQueryData(queryKey2, {id: '1', name: 'User'}); + + normalizer.setQuery(JSON.stringify(queryKey1), [{id: '1', name: 'User'}]); + normalizer.setQuery(JSON.stringify(queryKey2), {id: '1', name: 'User'}); + + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + const dependentQueries = queryNormalizer.getDependentQueries({ + id: '1', + name: 'Updated', + }); + expect(dependentQueries.length).toBeGreaterThan(0); + }); + + it('getDependentQueriesByIds should be available', () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + // getDependentQueriesByIds should be defined and available + expect(queryNormalizer.getDependentQueriesByIds).toBeDefined(); + expect(typeof queryNormalizer.getDependentQueriesByIds).toBe('function'); + + // Call with empty array should not throw + const result = queryNormalizer.getDependentQueriesByIds([]); + expect(Array.isArray(result)).toBe(true); + }); + + it('should correctly handle normalize: false', () => { + const config: DataSourceNormalizerConfig = {normalize: false}; + + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig: config, + optimisticUpdateConfig, + }); + + expect(queryNormalizer).toBeDefined(); + }); + + it('should disable optimistic updates if normalize is disabled', () => { + const config: DataSourceNormalizerConfig = {normalize: false}; + + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig: config, + optimisticUpdateConfig: {enabled: true}, // Try to enable + }); + + // Normalizer is created, but optimistic updates should not work + expect(queryNormalizer).toBeDefined(); + }); +}); diff --git a/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx b/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx new file mode 100644 index 0000000..aba9ab9 --- /dev/null +++ b/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx @@ -0,0 +1,92 @@ +import {createNormalizer} from '@normy/core'; +import {QueryClient} from '@tanstack/react-query'; + +import {updateQueriesFromMutationData} from '../normalization'; + +describe('normalization edge cases', () => { + let queryClient: QueryClient; + let normalizer: ReturnType; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + }); + + normalizer = createNormalizer({ + devLogging: false, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('should work correctly with empty data', () => { + const queryKey = ['empty']; + queryClient.setQueryData(queryKey, []); + normalizer.setQuery(JSON.stringify(queryKey), []); + + const mutationData = {id: '1', name: 'New'}; + expect(() => { + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + }).not.toThrow(); + }); + + it('should work correctly with null data', () => { + const queryKey = ['null']; + queryClient.setQueryData(queryKey, null); + + const mutationData = {id: '1', name: 'New'}; + expect(() => { + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + }).not.toThrow(); + }); + + it('should work correctly with undefined data', () => { + const queryKey = ['undefined']; + queryClient.setQueryData(queryKey, undefined); + + const mutationData = {id: '1', name: 'New'}; + expect(() => { + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + }).not.toThrow(); + }); + + it('should work correctly with arrays of objects', () => { + const queryKey = ['array']; + const data = [ + {id: '1', name: 'Item 1'}, + {id: '2', name: 'Item 2'}, + ]; + + queryClient.setQueryData(queryKey, data); + normalizer.setQuery(JSON.stringify(queryKey), data); + + const mutationData = {id: '1', name: 'Updated Item 1'}; + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + + const updatedData = 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', () => { + const queryKey = ['single']; + const data = {id: '1', name: 'Item'}; + + queryClient.setQueryData(queryKey, data); + normalizer.setQuery(JSON.stringify(queryKey), data); + + const mutationData = {id: '1', name: 'Updated Item'}; + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + + const updatedData = queryClient.getQueryData(queryKey) as {id: string; name: string}; + expect(updatedData.name).toBe('Updated Item'); + }); +}); diff --git a/src/react-query/normalize/__tests__/subscriptions.test.tsx b/src/react-query/normalize/__tests__/subscriptions.test.tsx new file mode 100644 index 0000000..7590f43 --- /dev/null +++ b/src/react-query/normalize/__tests__/subscriptions.test.tsx @@ -0,0 +1,536 @@ +import React from 'react'; + +import {createNormalizer} from '@normy/core'; +import {QueryClient, QueryClientProvider, useMutation} from '@tanstack/react-query'; +import {renderHook, waitFor} from '@testing-library/react'; + +import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; +import {createQueryNormalizer} from '../normalization'; + +describe('subscriptions', () => { + let queryClient: QueryClient; + let normalizer: ReturnType; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + }); + + normalizer = createNormalizer({ + devLogging: false, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + describe('QueryCache subscription', () => { + const normalizerConfig: DataSourceNormalizerConfig = { + normalize: true, + }; + + const optimisticUpdateConfig: OptimisticUpdateConfig = { + enabled: false, + }; + + it('should add query to normalizer when added to QueryCache', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + + // Add query + await queryClient.fetchQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + }); + + const normalized = queryNormalizer.getNormalizedData(); + expect(normalized.objects['@@1']).toBeDefined(); + + queryNormalizer.unsubscribe(); + }); + + it('should update query in normalizer on update', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + // Initial data + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Old'}], + }); + + const normalizedBefore = queryNormalizer.getNormalizedData(); + const objectCountBefore = Object.keys(normalizedBefore.objects).length; + + // Update data + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'New'}], + }); + + // Verify that normalized data was updated + const normalizedAfter = queryNormalizer.getNormalizedData(); + expect(Object.keys(normalizedAfter.objects).length).toBe(objectCountBefore); + expect(normalizedAfter.queries[JSON.stringify(queryKey)]).toBeDefined(); + + queryNormalizer.unsubscribe(); + }); + + it('should remove query from normalizer when removed from QueryCache', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + // Add query + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'User 1'}], + }); + + // Remove query + queryClient.removeQueries({queryKey}); + + // Give time to process event + await new Promise((resolve) => setTimeout(resolve, 10)); + + const normalized = queryNormalizer.getNormalizedData(); + // Query should be removed from queries + expect(normalized.queries[JSON.stringify(queryKey)]).toBeUndefined(); + + queryNormalizer.unsubscribe(); + }); + + it('should support meta configuration for queries', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + + // Check that we can use meta for configuration + // Real check for disabling via meta is already tested in integration tests + const normalized = queryNormalizer.getNormalizedData(); + expect(normalized).toBeDefined(); + expect(normalized.objects).toBeDefined(); + expect(normalized.queries).toBeDefined(); + + queryNormalizer.unsubscribe(); + }); + + it('should unsubscribe correctly', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + queryNormalizer.unsubscribe(); + + // After unsubscribing, adding query should not affect normalizer + await queryClient.fetchQuery({ + queryKey: ['users'], + queryFn: async () => [{id: '1', name: 'User 1'}], + }); + + const normalized = queryNormalizer.getNormalizedData(); + expect(Object.keys(normalized.objects)).toHaveLength(0); + }); + + it('should allow multiple unsubscribe calls', () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + queryNormalizer.unsubscribe(); + + // Repeated call should not throw error + expect(() => queryNormalizer.unsubscribe()).not.toThrow(); + }); + }); + + describe('MutationCache subscription', () => { + const normalizerConfig: DataSourceNormalizerConfig = { + normalize: true, + }; + + const optimisticUpdateConfig: OptimisticUpdateConfig = { + enabled: true, + autoCalculateRollback: true, + }; + + it('should update queries on successful mutation', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + // Initial data + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Old'}], + }); + + // 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'}), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + expect(data[0].name).toBe('New'); + + queryNormalizer.unsubscribe(); + }); + + it('should apply optimistic updates', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + // Initial data + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + 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'}, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + // Check optimistic data + await waitFor(() => { + const data = 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 = queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(dataFinal[0].name).toBe('Final'); + + queryNormalizer.unsubscribe(); + }); + + it('should automatically calculate rollbackData', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig: { + enabled: true, + autoCalculateRollback: true, + }, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + // Initial data + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + 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'}, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + // Data should be rolled back to original + const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + expect(data[0].name).toBe('Original'); + + queryNormalizer.unsubscribe(); + }); + + it('should rollback changes on mutation error', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + // Initial data + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + 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'}, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + expect(data[0].name).toBe('Original'); + + queryNormalizer.unsubscribe(); + }); + + it('should ignore mutations with normalize: false and optimistic: false', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig: {normalize: false}, // Globally disabled + optimisticUpdateConfig: {enabled: false}, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + queryClient.setQueryData(queryKey, [{id: '1', name: 'Original'}]); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + // Mutation should not update data automatically + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => ({id: '1', name: 'New'}), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + expect(data[0].name).toBe('Original'); // Not changed + + queryNormalizer.unsubscribe(); + }); + + it('should support devLogging for optimistic updates', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig: { + enabled: true, + autoCalculateRollback: true, + devLogging: true, + }, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + 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'}, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + 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(); + queryNormalizer.unsubscribe(); + }); + + it('should support manual rollbackData', async () => { + const queryNormalizer = createQueryNormalizer({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig: { + enabled: true, + autoCalculateRollback: false, + }, + }); + + queryNormalizer.subscribe(); + + const queryKey = ['users']; + + await queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + 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'}, + }), + }), + {wrapper}, + ); + + mutationResult.current.mutate(); + + await waitFor(() => expect(mutationResult.current.isError).toBe(true)); + + const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + expect(data[0].name).toBe('Manual Rollback'); + + queryNormalizer.unsubscribe(); + }); + }); +}); diff --git a/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx b/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx new file mode 100644 index 0000000..0185c12 --- /dev/null +++ b/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx @@ -0,0 +1,165 @@ +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'; +import {useQueryNormalizer} from '../QueryNormalizerProvider'; + +describe('Three-Level Configuration Integration', () => { + let queryClient: QueryClient; + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager(); + queryClient = dataManager.queryClient; + }); + + afterEach(() => { + queryClient.clear(); + }); + + describe('Level 1: Provider (global)', () => { + it('should use global configuration from Provider', async () => { + const globalGetKey = jest.fn((obj) => `global:${obj.id}`); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const dataSource = makePlainQueryDataSource({ + name: 'users', + fetch: async () => [{id: '1', name: 'User 1'}], + }); + + const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); + + const {result: queryResult} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); + + await waitFor(() => expect(queryResult.current.status).toBe('success')); + + const normalized = normalizerResult.current.getNormalizedData(); + + expect(normalized.objects['@@global:1']).toBeDefined(); + expect(globalGetKey).toHaveBeenCalled(); + }); + + it('global getArrayType should work', async () => { + const globalGetArrayType = jest.fn(({arrayKey}) => `global:${arrayKey}`); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const dataSource = makePlainQueryDataSource({ + name: 'items', + fetch: async () => ({ + items: [{id: '1', name: 'Item 1'}], + }), + }); + + const {result} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); + + await waitFor(() => expect(result.current.status).toBe('success')); + + // Global function should be called + expect(globalGetArrayType).toHaveBeenCalled(); + }); + }); + + describe('Enable/disable normalization at different levels', () => { + it('Hook can disable normalization even if enabled globally', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const dataSource = makePlainQueryDataSource({ + name: 'users', + fetch: async () => [{id: '1', name: 'User 1'}], + }); + + const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); + + const {result: queryResult} = renderHook( + () => + useQueryData( + dataSource, + {}, + { + normalizationConfig: { + normalize: false, // Disable for this query + }, + }, + ), + {wrapper}, + ); + + await waitFor(() => expect(queryResult.current.status).toBe('success')); + + const normalized = normalizerResult.current.getNormalizedData(); + + // Data should NOT be normalized + expect(Object.keys(normalized.objects)).toHaveLength(0); + }); + + it('DataSource can enable normalization if disabled globally', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const dataSource = makePlainQueryDataSource({ + name: 'users', + fetch: async () => [{id: '1', name: 'User 1'}], + options: { + normalizationConfig: { + normalize: true, // Enable for this DataSource + }, + }, + }); + + const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); + + const {result: queryResult} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); + + await waitFor(() => expect(queryResult.current.status).toBe('success')); + + const normalized = normalizerResult.current.getNormalizedData(); + + // Data SHOULD be normalized + expect(normalized.objects['@@1']).toBeDefined(); + }); + }); +}); diff --git a/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx b/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx new file mode 100644 index 0000000..d200b92 --- /dev/null +++ b/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx @@ -0,0 +1,166 @@ +import {createNormalizer} from '@normy/core'; +import {QueryClient} from '@tanstack/react-query'; + +import {updateQueriesFromMutationData} from '../normalization'; + +describe('updateQueriesFromMutationData', () => { + let queryClient: QueryClient; + let normalizer: ReturnType; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + }); + + normalizer = createNormalizer({ + devLogging: false, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('should update query data based on normalized data', () => { + // Set initial data in query + const queryKey = ['users']; + queryClient.setQueryData(queryKey, [{id: '1', name: 'Old Name'}]); + + // Add query to normalizer + normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old Name'}]); + + // Update data via mutation + const mutationData = {id: '1', name: 'New Name'}; + + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + + // Verify that data was updated + const updatedData = 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', () => { + const queryKey1 = ['users']; + const queryKey2 = ['user', '1']; + + // Set data in both queries + queryClient.setQueryData(queryKey1, [{id: '1', name: 'User 1'}]); + queryClient.setQueryData(queryKey2, {id: '1', name: 'User 1'}); + + // Add to normalizer + normalizer.setQuery(JSON.stringify(queryKey1), [{id: '1', name: 'User 1'}]); + normalizer.setQuery(JSON.stringify(queryKey2), {id: '1', name: 'User 1'}); + + // Update via mutation + const mutationData = {id: '1', name: 'Updated User'}; + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + + // Check both queries + const data1 = queryClient.getQueryData(queryKey1) as Array<{id: string; name: string}>; + const data2 = 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', () => { + const queryKey = ['users']; + const originalUpdatedAt = Date.now(); + + // Set initial data + queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}], { + updatedAt: originalUpdatedAt, + }); + normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + // Update data + updateQueriesFromMutationData({id: '1', name: 'New'}, normalizer, queryClient); + + // Verify that dataUpdatedAt was preserved + const cachedQuery = queryClient.getQueryCache().find({queryKey}); + expect(cachedQuery?.state.dataUpdatedAt).toBe(originalUpdatedAt); + }); + + it('should preserve error state on update', () => { + const queryKey = ['users']; + const error = new Error('Test error'); + + // Set initial data with error + queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + const cachedQuery = queryClient.getQueryCache().find({queryKey}); + cachedQuery?.setState({error, status: 'error'}); + + normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + // Update data + updateQueriesFromMutationData({id: '1', name: 'New'}, normalizer, queryClient); + + // Verify that error and status were preserved + const updatedQuery = queryClient.getQueryCache().find({queryKey}); + expect(updatedQuery?.state.error).toBe(error); + expect(updatedQuery?.state.status).toBe('error'); + }); + + it('should preserve isInvalidated flag on update', () => { + const queryKey = ['users']; + + // Set initial data and invalidate + queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + queryClient.invalidateQueries({queryKey}); + + normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + const cachedQueryBefore = queryClient.getQueryCache().find({queryKey}); + const isInvalidatedBefore = cachedQueryBefore?.state.isInvalidated; + + // Update data + updateQueriesFromMutationData({id: '1', name: 'New'}, normalizer, queryClient); + + // Verify that isInvalidated was preserved + const cachedQueryAfter = queryClient.getQueryCache().find({queryKey}); + expect(cachedQueryAfter?.state.isInvalidated).toBe(isInvalidatedBefore); + }); + + it('should work correctly with nested objects', () => { + const queryKey = ['posts']; + const initialData = [ + { + id: '1', + title: 'Post 1', + author: {id: '10', name: 'Author 1'}, + }, + ]; + + queryClient.setQueryData(queryKey, initialData); + normalizer.setQuery(JSON.stringify(queryKey), initialData); + + // Update author + const mutationData = {id: '10', name: 'Updated Author'}; + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + + // Verify that author was updated + const data = 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', () => { + const mutationData = {id: '1', name: 'New'}; + + // Don't add query to cache, only to normalizer + // This should not throw an error + expect(() => { + updateQueriesFromMutationData(mutationData, normalizer, queryClient); + }).not.toThrow(); + }); +}); diff --git a/src/react-query/normalize/normalization.ts b/src/react-query/normalize/normalization.ts new file mode 100644 index 0000000..fcbcaca --- /dev/null +++ b/src/react-query/normalize/normalization.ts @@ -0,0 +1,200 @@ +import type {Data} from '@normy/core'; +import type {QueryClient, QueryKey} from '@tanstack/react-query'; + +import type { + DataSourceNormalizerConfig, + Normalizer, + OptimisticUpdateConfig, + OptionsNormalizerConfig, +} from '../types/normalizer'; +import {shouldNormalize, shouldOptimisticallyUpdate} from '../utils/normalize'; + +// Function to update queries in QueryClient based on normalized data +export const updateQueriesFromMutationData = ( + mutationData: Data, + normalizer: Normalizer, + queryClient: QueryClient, +) => { + const queriesToUpdate = normalizer.getQueriesToUpdate(mutationData); + + queriesToUpdate.forEach((query) => { + const queryKey = JSON.parse(query.queryKey) as QueryKey; + const cachedQuery = queryClient.getQueryCache().find({queryKey}); + + // Preserve state that should not be reset + const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; + const isInvalidated = cachedQuery?.state.isInvalidated; + const error = cachedQuery?.state.error; + const status = cachedQuery?.state.status; + + queryClient.setQueryData(queryKey, () => query.data, { + updatedAt: dataUpdatedAt, + }); + + cachedQuery?.setState({isInvalidated, error, status}); + }); +}; + +interface CreateQueryNormalizerOptions { + queryClient: QueryClient; + normalizer: Normalizer; + normalizerConfig: DataSourceNormalizerConfig; + optimisticUpdateConfig: OptimisticUpdateConfig; +} + +export const createQueryNormalizer = ({ + queryClient, + normalizer, + normalizerConfig, + optimisticUpdateConfig, +}: CreateQueryNormalizerOptions) => { + const globalNormalize = normalizerConfig.normalize ?? false; + // No point in updating normalized data if normalization is disabled + const globalOptimistic = (globalNormalize && optimisticUpdateConfig.enabled) || 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) => + updateQueriesFromMutationData(data, normalizer, queryClient), + /** 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 { + normalizationConfig?: OptionsNormalizerConfig; + }; + + const queryNormalize = queryOptions?.normalizationConfig?.normalize; + + if (!shouldNormalize(globalNormalize, 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 + | { + normalizationConfig?: OptionsNormalizerConfig; + optimisticUpdateConfig?: OptimisticUpdateConfig; + } + | undefined; + const mutationQueryNormalize = mutationOptions?.normalizationConfig; + const mutationQueryOptimistic = mutationOptions?.optimisticUpdateConfig; + + if ( + !shouldNormalize(globalNormalize, mutationQueryNormalize?.normalize) && + !shouldOptimisticallyUpdate(globalOptimistic, mutationQueryOptimistic?.enabled) + ) { + return; + } + + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.action.data + ) { + updateQueriesFromMutationData( + event.action.data as Data, + normalizer, + queryClient, + ); + } else if (event.type === 'updated' && event.action.type === 'pending') { + const context = event.mutation.state.context as { + optimisticData?: Data; + rollbackData?: Data; + }; + + if (context?.optimisticData) { + // Automatic rollbackData calculation + if ( + !context.rollbackData && + (optimisticUpdateConfig.autoCalculateRollback !== false || + mutationQueryOptimistic?.autoCalculateRollback !== false) + ) { + context.rollbackData = normalizer.getCurrentData( + context.optimisticData, + ); + + if ( + optimisticUpdateConfig.devLogging || + mutationQueryOptimistic?.devLogging + ) { + console.log( + '[OptimisticUpdate] Auto-calculated rollbackData:', + context.rollbackData, + ); + } + } + + updateQueriesFromMutationData( + context.optimisticData, + normalizer, + queryClient, + ); + } + } else if (event.type === 'updated' && event.action.type === 'error') { + const context = event.mutation.state.context as { + rollbackData?: Data; + }; + + if (context?.rollbackData) { + if (optimisticUpdateConfig.devLogging) { + console.log('[OptimisticUpdate] Rolling back changes'); + } + + updateQueriesFromMutationData( + context.rollbackData, + normalizer, + queryClient, + ); + } + } + }); + }, + unsubscribe: () => { + unsubscribeQueryCache?.(); + unsubscribeMutationCache?.(); + unsubscribeQueryCache = null; + unsubscribeMutationCache = null; + }, + }; +}; diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index f04eebe..f7ea91c 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -11,6 +11,10 @@ export interface QueryDataAdditionalOptions< TQueryKey extends QueryKey = QueryKey, > { refetchInterval?: RefetchInterval; + /** Конфигурация нормализации (включение/выключение) */ + normalizationConfig?: OptionsNormalizerConfig; + /** Конфигурация оптимистического обновления данных */ + optimisticUpdateConfig?: OptimisticUpdateConfig; /** * @deprecated The use of the enabled option is deprecated. * It is recommended to use idle as query parameters to control query state. diff --git a/src/react-query/utils/__tests__/normalize.test.ts b/src/react-query/utils/__tests__/normalize.test.ts new file mode 100644 index 0000000..b3cbec2 --- /dev/null +++ b/src/react-query/utils/__tests__/normalize.test.ts @@ -0,0 +1,94 @@ +import {shouldNormalize, shouldOptimisticallyUpdate} from '../normalize'; + +describe('normalize utils', () => { + describe('shouldNormalize', () => { + it('should return providerConfig if queryConfig is undefined', () => { + expect(shouldNormalize(true, undefined)).toBe(true); + expect(shouldNormalize(false, undefined)).toBe(false); + }); + + it('should return queryConfig if defined', () => { + expect(shouldNormalize(true, false)).toBe(false); + expect(shouldNormalize(false, true)).toBe(true); + }); + + it('queryConfig should have priority over providerConfig', () => { + // Provider: true, Query: false → false + expect(shouldNormalize(true, false)).toBe(false); + + // Provider: false, Query: true → true + expect(shouldNormalize(false, true)).toBe(true); + }); + + it('should work with various combinations', () => { + // Both true + expect(shouldNormalize(true, true)).toBe(true); + + // Both false + expect(shouldNormalize(false, false)).toBe(false); + + // Provider true, query false + expect(shouldNormalize(true, false)).toBe(false); + + // Provider false, query true + expect(shouldNormalize(false, true)).toBe(true); + + // Provider true, query undefined + expect(shouldNormalize(true, undefined)).toBe(true); + + // Provider false, query undefined + expect(shouldNormalize(false, undefined)).toBe(false); + }); + }); + + describe('shouldOptimisticallyUpdate', () => { + it('should return providerConfig if mutationConfig is undefined', () => { + expect(shouldOptimisticallyUpdate(true, undefined)).toBe(true); + expect(shouldOptimisticallyUpdate(false, undefined)).toBe(false); + }); + + it('should return mutationConfig if defined', () => { + expect(shouldOptimisticallyUpdate(true, false)).toBe(false); + expect(shouldOptimisticallyUpdate(false, true)).toBe(true); + }); + + it('mutationConfig should have priority over providerConfig', () => { + // Provider: true, Mutation: false → false + expect(shouldOptimisticallyUpdate(true, false)).toBe(false); + + // Provider: false, Mutation: true → true + expect(shouldOptimisticallyUpdate(false, true)).toBe(true); + }); + + it('should work with various combinations', () => { + // Both true + expect(shouldOptimisticallyUpdate(true, true)).toBe(true); + + // Both false + expect(shouldOptimisticallyUpdate(false, false)).toBe(false); + + // Provider true, mutation false + expect(shouldOptimisticallyUpdate(true, false)).toBe(false); + + // Provider false, mutation true + expect(shouldOptimisticallyUpdate(false, true)).toBe(true); + + // Provider true, mutation undefined + expect(shouldOptimisticallyUpdate(true, undefined)).toBe(true); + + // Provider false, mutation undefined + expect(shouldOptimisticallyUpdate(false, undefined)).toBe(false); + }); + }); + + describe('Edge cases', () => { + it('shouldNormalize should work with boolean false (not falsy)', () => { + // false is a valid value, should not fallback to provider + expect(shouldNormalize(true, false)).toBe(false); + }); + + it('shouldOptimisticallyUpdate should work with boolean false', () => { + expect(shouldOptimisticallyUpdate(true, false)).toBe(false); + }); + }); +}); From 4719485a8a83c138d00dda37b2b87645d7c88141 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 21 Oct 2025 10:38:33 +0200 Subject: [PATCH 02/11] fix: types --- src/react-query/impl/infinite/types.ts | 6 ++++-- src/react-query/impl/plain/types.ts | 2 +- src/react-query/types/options.ts | 11 +++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index 8668a25..8703f9f 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -10,7 +10,7 @@ import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; -import type {QueryDataAdditionalOptions} from '../../types/options'; +import type {QueryCustomOptions, QueryDataAdditionalOptions} from '../../types/options'; export type InfiniteQueryObserverExtendedOptions< TQueryFnData = unknown, @@ -26,7 +26,9 @@ export type InfiniteQueryObserverExtendedOptions< TError, InfiniteData, TQueryKey - >; + > +> & + QueryCustomOptions; export type InfiniteQueryDataSource = DataSource< QueryDataSourceContext, diff --git a/src/react-query/impl/plain/types.ts b/src/react-query/impl/plain/types.ts index 381898e..391e9bf 100644 --- a/src/react-query/impl/plain/types.ts +++ b/src/react-query/impl/plain/types.ts @@ -9,7 +9,7 @@ import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; -import type {QueryDataAdditionalOptions} from '../../types/options'; +import type {QueryCustomOptions, QueryDataAdditionalOptions} from '../../types/options'; export type QueryObserverExtendedOptions< TQueryFnData = unknown, diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index f7ea91c..8b9933f 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -11,10 +11,6 @@ export interface QueryDataAdditionalOptions< TQueryKey extends QueryKey = QueryKey, > { refetchInterval?: RefetchInterval; - /** Конфигурация нормализации (включение/выключение) */ - normalizationConfig?: OptionsNormalizerConfig; - /** Конфигурация оптимистического обновления данных */ - optimisticUpdateConfig?: OptimisticUpdateConfig; /** * @deprecated The use of the enabled option is deprecated. * It is recommended to use idle as query parameters to control query state. @@ -27,3 +23,10 @@ export interface QueryDataAdditionalOptions< /** Invalidate data configuration */ invalidate?: boolean; } + +export interface QueryCustomOptions { + /** Normalization configuration (enable/disable) */ + normalizationConfig?: OptionsNormalizerConfig; + /** Optimistic data update configuration */ + optimisticUpdateConfig?: OptimisticUpdateConfig; +} From ffe4adf58ed5490b98ede389c31c889fcd1964ef Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Sat, 22 Nov 2025 16:35:30 +0100 Subject: [PATCH 03/11] feat: add client normalize --- README-ru.md | 169 +++++++++---- README.md | 169 +++++++++---- src/react-query/ClientDataManager.ts | 3 +- .../normalize/QueryNormalizerProvider.tsx | 39 ++- .../QueryNormalizerProvider.test.tsx | 73 ++++-- .../__tests__/createQueryNormalizer.test.tsx | 134 ++++++---- .../__tests__/normalizationEdgeCases.test.tsx | 94 ++++--- .../__tests__/subscriptions.test.tsx | 230 +++++++++++++----- .../__tests__/threeLevelIntegration.test.tsx | 28 ++- .../updateQueriesFromMutationData.test.tsx | 135 ++++++---- src/react-query/normalize/normalization.ts | 55 +---- .../utils/__tests__/normalize.test.ts | 72 +----- 12 files changed, 757 insertions(+), 444 deletions(-) diff --git a/README-ru.md b/README-ru.md index 9a4bca7..51c1fcd 100644 --- a/README-ru.md +++ b/README-ru.md @@ -417,19 +417,59 @@ const MyComponent = withDataManager(({dataManager, ...props}) => { Основной класс для управления данными. ```ts -const dataManager = new ClientDataManager({ - defaultOptions: { - queries: { - staleTime: 300000, // 5 минут - retry: 3, - refetchOnWindowFocus: false, +const dataManager = new ClientDataManager( + { + defaultOptions: { + queries: { + staleTime: 300000, // 5 минут + retry: 3, + refetchOnWindowFocus: false, + }, }, }, -}); + // Опционально: второй параметр для включения нормализации + { + normalizerConfig: { + devLogging: false, + }, + }, +); + +// Доступ к нормализатору, если включен +if (dataManager.normalizer) { + const data = dataManager.normalizer.getNormalizedData(); +} ``` +**Свойства:** + +- `queryClient` - Экземпляр React Query клиента +- `normalizer` - Экземпляр нормализатора (если нормализация включена) + **Методы:** +##### `optimisticUpdate(mutationData)` + +Применяет оптимистичные обновления к нормализованным данным. Все запросы, использующие обновленные сущности, будут автоматически обновлены. + +```ts +dataManager.optimisticUpdate({ + id: '123', + name: 'Обновленное имя', +}); +``` + +##### `automaticInvalidate(data)` + +Инвалидирует запросы на основе нормализованных данных. Полезно для инвалидации кеша, управляемой сервером. + +```ts +dataManager.automaticInvalidate({ + id: '123', + name: 'Новое имя', +}); +``` + ##### `invalidateTag(tag, options?)` Инвалидация всех запросов с определенным тегом. @@ -798,13 +838,22 @@ Data Source предоставляет встроенную нормализац ```tsx import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; -const dataManager = new ClientDataManager({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, +// Создайте DataManager с включенной нормализацией +const dataManager = new ClientDataManager( + { + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + }, }, }, -}); + // Включите нормализацию в ClientDataManager + { + normalizerConfig: { + devLogging: false, + }, + }, +); function App() { return ( @@ -845,10 +894,20 @@ import { QueryNormalizerProvider, } from '@gravity-ui/data-source'; +// Создайте DataManager с нормализацией +const dataManager = new ClientDataManager( + { + defaultOptions: { + queries: {staleTime: 5 * 60 * 1000}, + }, + }, + {normalizerConfig: {devLogging: false}}, +); + function App() { return ( @@ -895,34 +954,26 @@ const {data} = useQueryData( ```tsx import {useMutation} from '@tanstack/react-query'; -import {useQueryNormalizer} from '@gravity-ui/data-source'; +import {useDataManager} from '@gravity-ui/data-source'; function UserProfile() { - const queryNormalizer = useQueryNormalizer(); + const dataManager = useDataManager(); const mutation = useMutation({ mutationFn: updateUser, onMutate: async (newUser) => { // Вернуть оптимистичные данные, которые будут автоматически применены return { - optimisticData: { - users: { - [newUser.id]: newUser, - }, - }, + optimisticData: {id: newUser.id, name: newUser.name}, }; }, // Откат рассчитывается автоматически если autoCalculateRollback: true // Иначе вы можете предоставить его вручную: onMutate: async (newUser) => { - const currentUser = queryNormalizer.getObjectById('users', newUser.id); + const currentUser = dataManager.normalizer?.getObjectById(newUser.id); return { - optimisticData: { - users: {[newUser.id]: newUser}, - }, - rollbackData: { - users: {[newUser.id]: currentUser}, - }, + optimisticData: {id: newUser.id, name: newUser.name}, + rollbackData: currentUser, }; }, // Настройка для конкретной мутации @@ -948,30 +999,27 @@ function MyComponent() { const normalizedData = queryNormalizer.getNormalizedData(); // Получить конкретную сущность по ID - const user = queryNormalizer.getObjectById('users', '123'); + const user = queryNormalizer.getObjectById('123'); // Получить фрагмент данных const fragment = queryNormalizer.getQueryFragment({ - users: { - '123': {id: true, name: true}, - }, + id: '123', + name: true, }); // Найти запросы, зависящие от конкретных данных const dependentQueries = queryNormalizer.getDependentQueries({ - users: { - '123': {id: '123', name: 'Updated Name'}, - }, + id: '123', + name: 'Updated Name', }); // Найти запросы по ID сущностей - const queries = queryNormalizer.getDependentQueriesByIds(['users.123', 'posts.456']); + const queries = queryNormalizer.getDependentQueriesByIds(['123', '456']); // Вручную обновить нормализованные данные (например, из WebSocket) queryNormalizer.setNormalizedData({ - users: { - '123': {id: '123', name: 'Real-time Update'}, - }, + id: '123', + name: 'Real-time Update', }); // Очистить все нормализованные данные @@ -979,16 +1027,34 @@ function MyComponent() { } ``` +Вы также можете использовать `dataManager` напрямую для оптимистичных обновлений: + +```tsx +import {useDataManager} from '@gravity-ui/data-source'; + +function MyComponent() { + const dataManager = useDataManager(); + + const handleUpdate = () => { + // Применить оптимистичное обновление через dataManager + dataManager.optimisticUpdate({ + id: '123', + name: 'Обновленное имя', + }); + }; +} +``` + ### Пример real-time обновлений Нормализация отлично работает с WebSocket или другими real-time обновлениями: ```tsx import {useEffect} from 'react'; -import {useQueryNormalizer} from '@gravity-ui/data-source'; +import {useDataManager} from '@gravity-ui/data-source'; function useWebSocketUpdates() { - const queryNormalizer = useQueryNormalizer(); + const dataManager = useDataManager(); useEffect(() => { const ws = new WebSocket('wss://api.example.com/updates'); @@ -997,11 +1063,30 @@ function useWebSocketUpdates() { const update = JSON.parse(event.data); // Автоматически обновляет все запросы, использующие эти данные - queryNormalizer.setNormalizedData(update); + dataManager.optimisticUpdate(update); }; return () => ws.close(); - }, [queryNormalizer]); + }, [dataManager]); +} +``` + +Вы также можете использовать `useQueryNormalizer` для более продвинутых сценариев: + +```tsx +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function useAdvancedNormalization() { + const queryNormalizer = useQueryNormalizer(); + + // Получить конкретную сущность по ID + const user = queryNormalizer.getObjectById('123'); + + // Найти зависимые запросы перед обновлением + const dependentQueries = queryNormalizer.getDependentQueries({id: '123'}); + + // Обновить данные + queryNormalizer.setNormalizedData({id: '123', name: 'Новое имя'}); } ``` diff --git a/README.md b/README.md index 3fef965..045dcd5 100644 --- a/README.md +++ b/README.md @@ -417,19 +417,59 @@ const MyComponent = withDataManager(({dataManager, ...props}) => { Main class for data management. ```ts -const dataManager = new ClientDataManager({ - defaultOptions: { - queries: { - staleTime: 300000, // 5 minutes - retry: 3, - refetchOnWindowFocus: false, +const dataManager = new ClientDataManager( + { + defaultOptions: { + queries: { + staleTime: 300000, // 5 minutes + retry: 3, + refetchOnWindowFocus: false, + }, }, }, -}); + // Optional: second parameter to enable normalization + { + normalizerConfig: { + devLogging: false, + }, + }, +); + +// Access normalizer if enabled +if (dataManager.normalizer) { + const data = dataManager.normalizer.getNormalizedData(); +} ``` +**Properties:** + +- `queryClient` - React Query client instance +- `normalizer` - Normalizer instance (if normalization is enabled) + **Methods:** +##### `optimisticUpdate(mutationData)` + +Apply optimistic updates to normalized data. All queries using the updated entities will be automatically refreshed. + +```ts +dataManager.optimisticUpdate({ + id: '123', + name: 'Updated Name', +}); +``` + +##### `automaticInvalidate(data)` + +Invalidate queries based on normalized data. Useful for server-driven cache invalidation. + +```ts +dataManager.automaticInvalidate({ + id: '123', + name: 'New Name', +}); +``` + ##### `invalidateTag(tag, options?)` Invalidate all queries with a specific tag. @@ -798,13 +838,22 @@ The simplest way to enable normalization is to use `DataSourceProvider`: ```tsx import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; -const dataManager = new ClientDataManager({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, +// Create DataManager with normalization enabled +const dataManager = new ClientDataManager( + { + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + }, }, }, -}); + // Enable normalization in ClientDataManager + { + normalizerConfig: { + devLogging: false, + }, + }, +); function App() { return ( @@ -845,10 +894,20 @@ import { QueryNormalizerProvider, } from '@gravity-ui/data-source'; +// Create DataManager with normalization +const dataManager = new ClientDataManager( + { + defaultOptions: { + queries: {staleTime: 5 * 60 * 1000}, + }, + }, + {normalizerConfig: {devLogging: false}}, +); + function App() { return ( @@ -895,34 +954,26 @@ Optimistic updates allow you to update the UI immediately before the server resp ```tsx import {useMutation} from '@tanstack/react-query'; -import {useQueryNormalizer} from '@gravity-ui/data-source'; +import {useDataManager} from '@gravity-ui/data-source'; function UserProfile() { - const queryNormalizer = useQueryNormalizer(); + const dataManager = useDataManager(); const mutation = useMutation({ mutationFn: updateUser, onMutate: async (newUser) => { // Return optimistic data that will be automatically applied return { - optimisticData: { - users: { - [newUser.id]: newUser, - }, - }, + optimisticData: {id: newUser.id, name: newUser.name}, }; }, // Rollback is calculated automatically if autoCalculateRollback: true // Otherwise you can provide it manually: onMutate: async (newUser) => { - const currentUser = queryNormalizer.getObjectById('users', newUser.id); + const currentUser = dataManager.normalizer?.getObjectById(newUser.id); return { - optimisticData: { - users: {[newUser.id]: newUser}, - }, - rollbackData: { - users: {[newUser.id]: currentUser}, - }, + optimisticData: {id: newUser.id, name: newUser.name}, + rollbackData: currentUser, }; }, // Configure per mutation @@ -948,30 +999,27 @@ function MyComponent() { const normalizedData = queryNormalizer.getNormalizedData(); // Get specific entity by ID - const user = queryNormalizer.getObjectById('users', '123'); + const user = queryNormalizer.getObjectById('123'); // Get data fragment const fragment = queryNormalizer.getQueryFragment({ - users: { - '123': {id: true, name: true}, - }, + id: '123', + name: true, }); // Find which queries depend on specific data const dependentQueries = queryNormalizer.getDependentQueries({ - users: { - '123': {id: '123', name: 'Updated Name'}, - }, + id: '123', + name: 'Updated Name', }); // Find queries by entity IDs - const queries = queryNormalizer.getDependentQueriesByIds(['users.123', 'posts.456']); + const queries = queryNormalizer.getDependentQueriesByIds(['123', '456']); // Manually update normalized data (e.g., from WebSocket) queryNormalizer.setNormalizedData({ - users: { - '123': {id: '123', name: 'Real-time Update'}, - }, + id: '123', + name: 'Real-time Update', }); // Clear all normalized data @@ -979,16 +1027,34 @@ function MyComponent() { } ``` +You can also use `dataManager` directly for optimistic updates: + +```tsx +import {useDataManager} from '@gravity-ui/data-source'; + +function MyComponent() { + const dataManager = useDataManager(); + + const handleUpdate = () => { + // Apply optimistic update through dataManager + dataManager.optimisticUpdate({ + id: '123', + name: 'Updated Name', + }); + }; +} +``` + ### Real-time Updates Example Normalization works great with WebSocket or other real-time updates: ```tsx import {useEffect} from 'react'; -import {useQueryNormalizer} from '@gravity-ui/data-source'; +import {useDataManager} from '@gravity-ui/data-source'; function useWebSocketUpdates() { - const queryNormalizer = useQueryNormalizer(); + const dataManager = useDataManager(); useEffect(() => { const ws = new WebSocket('wss://api.example.com/updates'); @@ -997,11 +1063,30 @@ function useWebSocketUpdates() { const update = JSON.parse(event.data); // Automatically updates all queries that use this data - queryNormalizer.setNormalizedData(update); + dataManager.optimisticUpdate(update); }; return () => ws.close(); - }, [queryNormalizer]); + }, [dataManager]); +} +``` + +You can also use `useQueryNormalizer` for more advanced scenarios: + +```tsx +import {useQueryNormalizer} from '@gravity-ui/data-source'; + +function useAdvancedNormalization() { + const queryNormalizer = useQueryNormalizer(); + + // Get specific entity by ID + const user = queryNormalizer.getObjectById('123'); + + // Find dependent queries before update + const dependentQueries = queryNormalizer.getDependentQueries({id: '123'}); + + // Update data + queryNormalizer.setNormalizedData({id: '123', name: 'New Name'}); } ``` diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 0805c4f..d928140 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -27,7 +27,7 @@ export class ClientDataManager implements DataManager { readonly normalizer?: Normalizer | undefined; readonly queryNormalizer?: QueryNormalizer | undefined; - constructor(config: ClientDataManagerConfig = {}) { + constructor(config: ClientDataManagerConfig = {}, normalizerConfig?: NormalizerClientConfig) { this.queryClient = new QueryClient({ ...config, defaultOptions: { @@ -62,7 +62,6 @@ export class ClientDataManager implements DataManager { queriesToUpdate.forEach((query) => { const queryKey = JSON.parse(query.queryKey) as QueryKey; - const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; diff --git a/src/react-query/normalize/QueryNormalizerProvider.tsx b/src/react-query/normalize/QueryNormalizerProvider.tsx index a68837b..7cc42d7 100644 --- a/src/react-query/normalize/QueryNormalizerProvider.tsx +++ b/src/react-query/normalize/QueryNormalizerProvider.tsx @@ -1,14 +1,7 @@ import React from 'react'; -import {createNormalizer} from '@normy/core'; -import type {NormalizedData} from '@normy/core/types/types'; -import type {QueryClient} from '@tanstack/react-query'; - -import type { - DataSourceNormalizerConfig, - Normalizer, - OptimisticUpdateConfig, -} from '../types/normalizer'; +import type {ClientDataManager} from '../ClientDataManager'; +import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../types/normalizer'; import {createQueryNormalizer} from './normalization'; @@ -18,39 +11,39 @@ const QueryNormalizerContext = React.createContext< export interface QueryNormalizerProviderProps { /** React Query client instance */ - queryClient: QueryClient; + dataManager: ClientDataManager; children: React.ReactNode; /** Configuration for the normalizer */ normalizerConfig?: DataSourceNormalizerConfig; - /** Initial normalized data to populate the store */ - initialNormalizedData?: NormalizedData; /** Configuration for optimistic updates */ optimisticUpdateConfig?: OptimisticUpdateConfig; - /** Custom normalizer instance */ - normalizer?: Normalizer; } export const QueryNormalizerProvider: React.FC = ({ - queryClient, + dataManager, normalizerConfig = {}, - initialNormalizedData, optimisticUpdateConfig = {}, - normalizer: customNormalizer, children, }) => { const [queryNormalizer] = React.useState(() => { - const normalizer = - customNormalizer ?? createNormalizer(normalizerConfig, initialNormalizedData); + if (!dataManager.normalizer) { + return null; + } return createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); }); React.useEffect(() => { + if (!queryNormalizer) { + return undefined; + } + queryNormalizer.subscribe(); return () => { @@ -60,6 +53,10 @@ export const QueryNormalizerProvider: React.FC = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (!queryNormalizer) { + return {children}; + } + return ( {children} diff --git a/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx b/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx index b237a87..0a7abdc 100644 --- a/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx +++ b/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx @@ -1,25 +1,29 @@ import React from 'react'; -import {QueryClient, QueryClientProvider, useMutation, useQuery} from '@tanstack/react-query'; +import {QueryClientProvider, useMutation, useQuery} from '@tanstack/react-query'; import {renderHook, waitFor} from '@testing-library/react'; +import {ClientDataManager} from '../../ClientDataManager'; import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; import {QueryNormalizerProvider, useQueryNormalizer} from '../QueryNormalizerProvider'; describe('QueryNormalizerProvider', () => { - let queryClient: QueryClient; + let dataManager: ClientDataManager; beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, + dataManager = new ClientDataManager( + { + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, }, - }); + true, + ); }); afterEach(() => { - queryClient.clear(); + dataManager.queryClient.clear(); }); const createWrapper = ( @@ -27,9 +31,9 @@ describe('QueryNormalizerProvider', () => { optimisticUpdateConfig?: OptimisticUpdateConfig, ) => { const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) => ( - + @@ -131,8 +135,10 @@ describe('QueryNormalizerProvider', () => { // Query data should be updated await waitFor(() => { - const data = queryClient.getQueryData(['users']) as any[]; - expect(data[0].name).toBe('Updated User'); + const data = dataManager.queryClient.getQueryData< + Array<{id: string; name: string}> + >(['users']); + expect(data?.[0].name).toBe('Updated User'); }); }); @@ -179,11 +185,16 @@ describe('QueryNormalizerProvider', () => { // Both queries should be updated await waitFor(() => { - const users = queryClient.getQueryData(['users']) as any[]; - const user = queryClient.getQueryData(['user', '1']) as any; - - expect(users[0].name).toBe('Updated User'); - expect(user.name).toBe('Updated User'); + const users = dataManager.queryClient.getQueryData< + Array<{id: string; name: string}> + >(['users']); + const user = dataManager.queryClient.getQueryData<{id: string; name: string}>([ + 'user', + '1', + ]); + + expect(users?.[0].name).toBe('Updated User'); + expect(user?.name).toBe('Updated User'); }); }); }); @@ -229,16 +240,20 @@ describe('QueryNormalizerProvider', () => { // Should have optimistic data immediately await waitFor(() => { - const data = queryClient.getQueryData(['users']) as any[]; - expect(data[0].name).toBe('Optimistic'); + const data = dataManager.queryClient.getQueryData< + Array<{id: string; name: string}> + >(['users']); + expect(data?.[0].name).toBe('Optimistic'); }); // After completion - final data await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); await waitFor(() => { - const data = queryClient.getQueryData(['users']) as any[]; - expect(data[0].name).toBe('Final'); + const data = dataManager.queryClient.getQueryData< + Array<{id: string; name: string}> + >(['users']); + expect(data?.[0].name).toBe('Final'); }); }); @@ -285,16 +300,20 @@ describe('QueryNormalizerProvider', () => { // Should have optimistic data await waitFor(() => { - const data = queryClient.getQueryData(['users']) as any[]; - expect(data[0].name).toBe('Optimistic'); + const data = dataManager.queryClient.getQueryData< + Array<{id: string; name: string}> + >(['users']); + expect(data?.[0].name).toBe('Optimistic'); }); // After error - rollback await waitFor(() => expect(mutationResult.current.isError).toBe(true)); await waitFor(() => { - const data = queryClient.getQueryData(['users']) as any[]; - expect(data[0].name).toBe('Original'); + const data = dataManager.queryClient.getQueryData< + Array<{id: string; name: string}> + >(['users']); + expect(data?.[0].name).toBe('Original'); }); consoleSpy.mockRestore(); @@ -319,7 +338,7 @@ describe('QueryNormalizerProvider', () => { const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); // Add data - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey: ['users'], queryFn: async () => [{id: '1', name: 'User 1'}], }); @@ -343,7 +362,7 @@ describe('QueryNormalizerProvider', () => { const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey: ['users'], queryFn: async () => [{id: '1', name: 'User 1'}], }); diff --git a/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx b/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx index 45b99cb..8b6fd57 100644 --- a/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx +++ b/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx @@ -1,12 +1,9 @@ -import {createNormalizer} from '@normy/core'; -import {QueryClient} from '@tanstack/react-query'; - +import {ClientDataManager} from '../../ClientDataManager'; import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; import {createQueryNormalizer} from '../normalization'; describe('createQueryNormalizer', () => { - let queryClient: QueryClient; - let normalizer: ReturnType; + let dataManager: ClientDataManager; const normalizerConfig: DataSourceNormalizerConfig = { normalize: true, @@ -18,26 +15,34 @@ describe('createQueryNormalizer', () => { }; beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, + dataManager = new ClientDataManager( + { + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, }, - }); - - normalizer = createNormalizer({ - devLogging: false, - }); + { + normalizerConfig: { + devLogging: false, + }, + }, + ); }); afterEach(() => { - queryClient.clear(); + dataManager.queryClient.clear(); }); it('should create queryNormalizer with required methods', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -54,9 +59,14 @@ describe('createQueryNormalizer', () => { }); it('getNormalizedData should return normalized data', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -68,31 +78,44 @@ describe('createQueryNormalizer', () => { }); it('setNormalizedData should update queries', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['users']; - queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); - normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); queryNormalizer.setNormalizedData({id: '1', name: 'New'}); - const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; expect(data[0].name).toBe('New'); }); it('clear should clear normalized data', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['users']; - queryClient.setQueryData(queryKey, [{id: '1', name: 'User'}]); - normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'User'}]); + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'User'}]); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'User'}]); const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -104,18 +127,23 @@ describe('createQueryNormalizer', () => { }); it('getObjectById should return object by ID', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['users']; const userData = [{id: '1', name: 'User 1'}]; const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); // Add to normalizer - normalizer.setQuery(JSON.stringify(queryKey), userData); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), userData); // Get normalized data const normalized = queryNormalizer.getNormalizedData(); @@ -127,18 +155,23 @@ describe('createQueryNormalizer', () => { }); it('getDependentQueries should return dependent queries', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey1 = ['users']; const queryKey2 = ['user', '1']; - queryClient.setQueryData(queryKey1, [{id: '1', name: 'User'}]); - queryClient.setQueryData(queryKey2, {id: '1', name: 'User'}); + dataManager.queryClient.setQueryData(queryKey1, [{id: '1', name: 'User'}]); + dataManager.queryClient.setQueryData(queryKey2, {id: '1', name: 'User'}); - normalizer.setQuery(JSON.stringify(queryKey1), [{id: '1', name: 'User'}]); - normalizer.setQuery(JSON.stringify(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 queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -151,9 +184,14 @@ describe('createQueryNormalizer', () => { }); it('getDependentQueriesByIds should be available', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -168,11 +206,16 @@ describe('createQueryNormalizer', () => { }); it('should correctly handle normalize: false', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const config: DataSourceNormalizerConfig = {normalize: false}; const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig: config, optimisticUpdateConfig, }); @@ -181,11 +224,16 @@ describe('createQueryNormalizer', () => { }); it('should disable optimistic updates if normalize is disabled', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const config: DataSourceNormalizerConfig = {normalize: false}; const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig: config, optimisticUpdateConfig: {enabled: true}, // Try to enable }); diff --git a/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx b/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx index aba9ab9..38bd0e9 100644 --- a/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx +++ b/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx @@ -1,74 +1,91 @@ -import {createNormalizer} from '@normy/core'; -import {QueryClient} from '@tanstack/react-query'; +import type {Data} from '@normy/core'; -import {updateQueriesFromMutationData} from '../normalization'; +import {ClientDataManager} from '../../ClientDataManager'; describe('normalization edge cases', () => { - let queryClient: QueryClient; - let normalizer: ReturnType; + let dataManager: ClientDataManager; beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, + dataManager = new ClientDataManager( + { + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, }, - }); - - normalizer = createNormalizer({ - devLogging: false, - }); + { + normalizerConfig: { + devLogging: false, + }, + }, + ); }); afterEach(() => { - queryClient.clear(); + dataManager.queryClient.clear(); }); it('should work correctly with empty data', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['empty']; - queryClient.setQueryData(queryKey, []); - normalizer.setQuery(JSON.stringify(queryKey), []); + dataManager.queryClient.setQueryData(queryKey, []); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), []); - const mutationData = {id: '1', name: 'New'}; + const mutationData: Data = {id: '1', name: 'New'}; expect(() => { - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + dataManager.optimisticUpdate(mutationData); }).not.toThrow(); }); it('should work correctly with null data', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['null']; - queryClient.setQueryData(queryKey, null); + dataManager.queryClient.setQueryData(queryKey, null); - const mutationData = {id: '1', name: 'New'}; + const mutationData: Data = {id: '1', name: 'New'}; expect(() => { - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + dataManager.optimisticUpdate(mutationData); }).not.toThrow(); }); it('should work correctly with undefined data', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['undefined']; - queryClient.setQueryData(queryKey, undefined); + dataManager.queryClient.setQueryData(queryKey, undefined); - const mutationData = {id: '1', name: 'New'}; + const mutationData: Data = {id: '1', name: 'New'}; expect(() => { - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + dataManager.optimisticUpdate(mutationData); }).not.toThrow(); }); it('should work correctly with arrays of objects', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['array']; const data = [ {id: '1', name: 'Item 1'}, {id: '2', name: 'Item 2'}, ]; - queryClient.setQueryData(queryKey, data); - normalizer.setQuery(JSON.stringify(queryKey), data); + dataManager.queryClient.setQueryData(queryKey, data); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), data); - const mutationData = {id: '1', name: 'Updated Item 1'}; - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + const mutationData: Data = {id: '1', name: 'Updated Item 1'}; + dataManager.optimisticUpdate(mutationData); - const updatedData = queryClient.getQueryData(queryKey) as Array<{ + const updatedData = dataManager.queryClient.getQueryData(queryKey) as Array<{ id: string; name: string; }>; @@ -77,16 +94,23 @@ describe('normalization edge cases', () => { }); it('should work correctly with single objects', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['single']; const data = {id: '1', name: 'Item'}; - queryClient.setQueryData(queryKey, data); - normalizer.setQuery(JSON.stringify(queryKey), data); + dataManager.queryClient.setQueryData(queryKey, data); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), data); - const mutationData = {id: '1', name: 'Updated Item'}; - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + const mutationData: Data = {id: '1', name: 'Updated Item'}; + dataManager.optimisticUpdate(mutationData); - const updatedData = queryClient.getQueryData(queryKey) as {id: string; name: string}; + const updatedData = dataManager.queryClient.getQueryData(queryKey) as { + id: string; + name: string; + }; expect(updatedData.name).toBe('Updated Item'); }); }); diff --git a/src/react-query/normalize/__tests__/subscriptions.test.tsx b/src/react-query/normalize/__tests__/subscriptions.test.tsx index 7590f43..248d3c3 100644 --- a/src/react-query/normalize/__tests__/subscriptions.test.tsx +++ b/src/react-query/normalize/__tests__/subscriptions.test.tsx @@ -1,31 +1,33 @@ import React from 'react'; -import {createNormalizer} from '@normy/core'; -import {QueryClient, QueryClientProvider, useMutation} from '@tanstack/react-query'; +import {QueryClientProvider, useMutation} from '@tanstack/react-query'; import {renderHook, waitFor} from '@testing-library/react'; +import {ClientDataManager} from '../../ClientDataManager'; import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; import {createQueryNormalizer} from '../normalization'; describe('subscriptions', () => { - let queryClient: QueryClient; - let normalizer: ReturnType; + let dataManager: ClientDataManager; beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, + dataManager = new ClientDataManager( + { + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, }, - }); - - normalizer = createNormalizer({ - devLogging: false, - }); + { + normalizerConfig: { + devLogging: false, + }, + }, + ); }); afterEach(() => { - queryClient.clear(); + dataManager.queryClient.clear(); }); describe('QueryCache subscription', () => { @@ -38,9 +40,14 @@ describe('subscriptions', () => { }; it('should add query to normalizer when added to QueryCache', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -48,7 +55,7 @@ describe('subscriptions', () => { queryNormalizer.subscribe(); // Add query - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey: ['users'], queryFn: async () => [{id: '1', name: 'User 1'}], }); @@ -60,9 +67,14 @@ describe('subscriptions', () => { }); it('should update query in normalizer on update', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -72,7 +84,7 @@ describe('subscriptions', () => { const queryKey = ['users']; // Initial data - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Old'}], }); @@ -81,7 +93,7 @@ describe('subscriptions', () => { const objectCountBefore = Object.keys(normalizedBefore.objects).length; // Update data - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'New'}], }); @@ -95,9 +107,14 @@ describe('subscriptions', () => { }); it('should remove query from normalizer when removed from QueryCache', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -107,13 +124,13 @@ describe('subscriptions', () => { const queryKey = ['users']; // Add query - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'User 1'}], }); // Remove query - queryClient.removeQueries({queryKey}); + dataManager.queryClient.removeQueries({queryKey}); // Give time to process event await new Promise((resolve) => setTimeout(resolve, 10)); @@ -126,9 +143,14 @@ describe('subscriptions', () => { }); it('should support meta configuration for queries', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -146,9 +168,14 @@ describe('subscriptions', () => { }); it('should unsubscribe correctly', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -157,7 +184,7 @@ describe('subscriptions', () => { queryNormalizer.unsubscribe(); // After unsubscribing, adding query should not affect normalizer - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey: ['users'], queryFn: async () => [{id: '1', name: 'User 1'}], }); @@ -167,9 +194,14 @@ describe('subscriptions', () => { }); it('should allow multiple unsubscribe calls', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -193,9 +225,14 @@ describe('subscriptions', () => { }; it('should update queries on successful mutation', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -205,14 +242,16 @@ describe('subscriptions', () => { const queryKey = ['users']; // Initial data - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Old'}], }); // Create wrapper for hooks const wrapper = ({children}: {children: React.ReactNode}) => ( - {children} + + {children} + ); // Mutation via useMutation @@ -228,16 +267,24 @@ describe('subscriptions', () => { await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; expect(data[0].name).toBe('New'); queryNormalizer.unsubscribe(); }); it('should apply optimistic updates', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -247,13 +294,15 @@ describe('subscriptions', () => { const queryKey = ['users']; // Initial data - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], }); const wrapper = ({children}: {children: React.ReactNode}) => ( - {children} + + {children} + ); // Mutation with optimistic data @@ -275,7 +324,7 @@ describe('subscriptions', () => { // Check optimistic data await waitFor(() => { - const data = queryClient.getQueryData(queryKey) as Array<{ + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ id: string; name: string; }>; @@ -285,7 +334,7 @@ describe('subscriptions', () => { // Wait for mutation to complete await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - const dataFinal = queryClient.getQueryData(queryKey) as Array<{ + const dataFinal = dataManager.queryClient.getQueryData(queryKey) as Array<{ id: string; name: string; }>; @@ -295,9 +344,14 @@ describe('subscriptions', () => { }); it('should automatically calculate rollbackData', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig: { enabled: true, @@ -310,13 +364,15 @@ describe('subscriptions', () => { const queryKey = ['users']; // Initial data - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], }); const wrapper = ({children}: {children: React.ReactNode}) => ( - {children} + + {children} + ); // Mutation with optimistic data that will fail @@ -339,16 +395,24 @@ describe('subscriptions', () => { await waitFor(() => expect(mutationResult.current.isError).toBe(true)); // Data should be rolled back to original - const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; expect(data[0].name).toBe('Original'); queryNormalizer.unsubscribe(); }); it('should rollback changes on mutation error', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig, }); @@ -358,13 +422,15 @@ describe('subscriptions', () => { const queryKey = ['users']; // Initial data - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], }); const wrapper = ({children}: {children: React.ReactNode}) => ( - {children} + + {children} + ); // Mutation with error @@ -386,16 +452,24 @@ describe('subscriptions', () => { await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; expect(data[0].name).toBe('Original'); queryNormalizer.unsubscribe(); }); it('should ignore mutations with normalize: false and optimistic: false', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig: {normalize: false}, // Globally disabled optimisticUpdateConfig: {enabled: false}, }); @@ -403,10 +477,12 @@ describe('subscriptions', () => { queryNormalizer.subscribe(); const queryKey = ['users']; - queryClient.setQueryData(queryKey, [{id: '1', name: 'Original'}]); + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Original'}]); const wrapper = ({children}: {children: React.ReactNode}) => ( - {children} + + {children} + ); // Mutation should not update data automatically @@ -422,18 +498,26 @@ describe('subscriptions', () => { await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; expect(data[0].name).toBe('Original'); // Not changed queryNormalizer.unsubscribe(); }); it('should support devLogging for optimistic updates', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig: { enabled: true, @@ -446,13 +530,15 @@ describe('subscriptions', () => { const queryKey = ['users']; - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], }); const wrapper = ({children}: {children: React.ReactNode}) => ( - {children} + + {children} + ); const {result: mutationResult} = renderHook( @@ -485,9 +571,14 @@ describe('subscriptions', () => { }); it('should support manual rollbackData', async () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryNormalizer = createQueryNormalizer({ - queryClient, - normalizer, + queryClient: dataManager.queryClient, + normalizer: dataManager.normalizer, + optimisticUpdate: (data) => dataManager.optimisticUpdate(data), normalizerConfig, optimisticUpdateConfig: { enabled: true, @@ -499,13 +590,15 @@ describe('subscriptions', () => { const queryKey = ['users']; - await queryClient.fetchQuery({ + await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], }); const wrapper = ({children}: {children: React.ReactNode}) => ( - {children} + + {children} + ); const {result: mutationResult} = renderHook( @@ -527,7 +620,10 @@ describe('subscriptions', () => { await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - const data = queryClient.getQueryData(queryKey) as Array<{id: string; name: string}>; + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; expect(data[0].name).toBe('Manual Rollback'); queryNormalizer.unsubscribe(); diff --git a/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx b/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx index 0185c12..048372b 100644 --- a/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx +++ b/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx @@ -14,7 +14,7 @@ describe('Three-Level Configuration Integration', () => { let dataManager: ClientDataManager; beforeEach(() => { - dataManager = new ClientDataManager(); + dataManager = new ClientDataManager({}, true); queryClient = dataManager.queryClient; }); @@ -26,12 +26,21 @@ describe('Three-Level Configuration Integration', () => { it('should use global configuration from Provider', async () => { const globalGetKey = jest.fn((obj) => `global:${obj.id}`); + // Create dataManager with custom normalizer config + const customDataManager = new ClientDataManager( + {}, + { + normalizerConfig: { + getNormalizationObjectKey: globalGetKey, + }, + }, + ); + const wrapper = ({children}: {children: React.ReactNode}) => ( {children} @@ -58,12 +67,21 @@ describe('Three-Level Configuration Integration', () => { it('global getArrayType should work', async () => { const globalGetArrayType = jest.fn(({arrayKey}) => `global:${arrayKey}`); + // Create dataManager with custom normalizer config + const customDataManager = new ClientDataManager( + {}, + { + normalizerConfig: { + getArrayType: globalGetArrayType, + }, + }, + ); + const wrapper = ({children}: {children: React.ReactNode}) => ( {children} diff --git a/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx b/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx index d200b92..f2fe202 100644 --- a/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx +++ b/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx @@ -1,44 +1,49 @@ -import {createNormalizer} from '@normy/core'; -import {QueryClient} from '@tanstack/react-query'; +import type {Data} from '@normy/core'; -import {updateQueriesFromMutationData} from '../normalization'; +import {ClientDataManager} from '../../ClientDataManager'; describe('updateQueriesFromMutationData', () => { - let queryClient: QueryClient; - let normalizer: ReturnType; + let dataManager: ClientDataManager; beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, + dataManager = new ClientDataManager( + { + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, }, - }); - - normalizer = createNormalizer({ - devLogging: false, - }); + { + normalizerConfig: { + devLogging: false, + }, + }, + ); }); afterEach(() => { - queryClient.clear(); + dataManager.queryClient.clear(); }); it('should update query data based on normalized data', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + // Set initial data in query const queryKey = ['users']; - queryClient.setQueryData(queryKey, [{id: '1', name: 'Old Name'}]); + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old Name'}]); // Add query to normalizer - normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old Name'}]); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old Name'}]); // Update data via mutation - const mutationData = {id: '1', name: 'New Name'}; + const mutationData: Data = {id: '1', name: 'New Name'}; - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + dataManager.optimisticUpdate(mutationData); // Verify that data was updated - const updatedData = queryClient.getQueryData(queryKey) as Array<{ + const updatedData = dataManager.queryClient.getQueryData(queryKey) as Array<{ id: string; name: string; }>; @@ -47,88 +52,114 @@ describe('updateQueriesFromMutationData', () => { }); it('should update multiple queries with the same object', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey1 = ['users']; const queryKey2 = ['user', '1']; // Set data in both queries - queryClient.setQueryData(queryKey1, [{id: '1', name: 'User 1'}]); - queryClient.setQueryData(queryKey2, {id: '1', name: 'User 1'}); + dataManager.queryClient.setQueryData(queryKey1, [{id: '1', name: 'User 1'}]); + dataManager.queryClient.setQueryData(queryKey2, {id: '1', name: 'User 1'}); // Add to normalizer - normalizer.setQuery(JSON.stringify(queryKey1), [{id: '1', name: 'User 1'}]); - normalizer.setQuery(JSON.stringify(queryKey2), {id: '1', name: 'User 1'}); + 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 = {id: '1', name: 'Updated User'}; - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + const mutationData: Data = {id: '1', name: 'Updated User'}; + dataManager.optimisticUpdate(mutationData); // Check both queries - const data1 = queryClient.getQueryData(queryKey1) as Array<{id: string; name: string}>; - const data2 = queryClient.getQueryData(queryKey2) as {id: string; name: string}; + 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', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['users']; const originalUpdatedAt = Date.now(); // Set initial data - queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}], { + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}], { updatedAt: originalUpdatedAt, }); - normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); // Update data - updateQueriesFromMutationData({id: '1', name: 'New'}, normalizer, queryClient); + const mutationData: Data = {id: '1', name: 'New'}; + dataManager.optimisticUpdate(mutationData); // Verify that dataUpdatedAt was preserved - const cachedQuery = queryClient.getQueryCache().find({queryKey}); + const cachedQuery = dataManager.queryClient.getQueryCache().find({queryKey}); expect(cachedQuery?.state.dataUpdatedAt).toBe(originalUpdatedAt); }); it('should preserve error state on update', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['users']; const error = new Error('Test error'); // Set initial data with error - queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); - const cachedQuery = queryClient.getQueryCache().find({queryKey}); + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + const cachedQuery = dataManager.queryClient.getQueryCache().find({queryKey}); cachedQuery?.setState({error, status: 'error'}); - normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); // Update data - updateQueriesFromMutationData({id: '1', name: 'New'}, normalizer, queryClient); + const mutationData: Data = {id: '1', name: 'New'}; + dataManager.optimisticUpdate(mutationData); // Verify that error and status were preserved - const updatedQuery = queryClient.getQueryCache().find({queryKey}); + 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', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['users']; // Set initial data and invalidate - queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); - queryClient.invalidateQueries({queryKey}); + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + dataManager.queryClient.invalidateQueries({queryKey}); - normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); - const cachedQueryBefore = queryClient.getQueryCache().find({queryKey}); + const cachedQueryBefore = dataManager.queryClient.getQueryCache().find({queryKey}); const isInvalidatedBefore = cachedQueryBefore?.state.isInvalidated; // Update data - updateQueriesFromMutationData({id: '1', name: 'New'}, normalizer, queryClient); + const mutationData: Data = {id: '1', name: 'New'}; + dataManager.optimisticUpdate(mutationData); // Verify that isInvalidated was preserved - const cachedQueryAfter = queryClient.getQueryCache().find({queryKey}); + const cachedQueryAfter = dataManager.queryClient.getQueryCache().find({queryKey}); expect(cachedQueryAfter?.state.isInvalidated).toBe(isInvalidatedBefore); }); it('should work correctly with nested objects', () => { + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + const queryKey = ['posts']; const initialData = [ { @@ -138,15 +169,15 @@ describe('updateQueriesFromMutationData', () => { }, ]; - queryClient.setQueryData(queryKey, initialData); - normalizer.setQuery(JSON.stringify(queryKey), initialData); + dataManager.queryClient.setQueryData(queryKey, initialData); + dataManager.normalizer.setQuery(JSON.stringify(queryKey), initialData); // Update author - const mutationData = {id: '10', name: 'Updated Author'}; - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + const mutationData: Data = {id: '10', name: 'Updated Author'}; + dataManager.optimisticUpdate(mutationData); // Verify that author was updated - const data = queryClient.getQueryData(queryKey) as Array<{ + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ id: string; title: string; author: {id: string; name: string}; @@ -155,12 +186,16 @@ describe('updateQueriesFromMutationData', () => { }); it('should not throw if query is not in cache', () => { - const mutationData = {id: '1', name: 'New'}; + if (!dataManager.normalizer) { + throw new Error('Normalizer should be initialized'); + } + + const mutationData: Data = {id: '1', name: 'New'}; // Don't add query to cache, only to normalizer // This should not throw an error expect(() => { - updateQueriesFromMutationData(mutationData, normalizer, queryClient); + dataManager.optimisticUpdate(mutationData); }).not.toThrow(); }); }); diff --git a/src/react-query/normalize/normalization.ts b/src/react-query/normalize/normalization.ts index fcbcaca..1f20908 100644 --- a/src/react-query/normalize/normalization.ts +++ b/src/react-query/normalize/normalization.ts @@ -1,43 +1,18 @@ import type {Data} from '@normy/core'; import type {QueryClient, QueryKey} from '@tanstack/react-query'; +import type {Normalizer} from '../../core'; import type { DataSourceNormalizerConfig, - Normalizer, OptimisticUpdateConfig, OptionsNormalizerConfig, } from '../types/normalizer'; -import {shouldNormalize, shouldOptimisticallyUpdate} from '../utils/normalize'; - -// Function to update queries in QueryClient based on normalized data -export const updateQueriesFromMutationData = ( - mutationData: Data, - normalizer: Normalizer, - queryClient: QueryClient, -) => { - const queriesToUpdate = normalizer.getQueriesToUpdate(mutationData); - - queriesToUpdate.forEach((query) => { - const queryKey = JSON.parse(query.queryKey) as QueryKey; - const cachedQuery = queryClient.getQueryCache().find({queryKey}); - - // Preserve state that should not be reset - const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; - const isInvalidated = cachedQuery?.state.isInvalidated; - const error = cachedQuery?.state.error; - const status = cachedQuery?.state.status; - - queryClient.setQueryData(queryKey, () => query.data, { - updatedAt: dataUpdatedAt, - }); - - cachedQuery?.setState({isInvalidated, error, status}); - }); -}; +import {shouldNormalize, shouldUpdateOptimistically} from '../utils/normalize'; interface CreateQueryNormalizerOptions { queryClient: QueryClient; normalizer: Normalizer; + optimisticUpdate: (mutationData: Data) => void; normalizerConfig: DataSourceNormalizerConfig; optimisticUpdateConfig: OptimisticUpdateConfig; } @@ -45,6 +20,7 @@ interface CreateQueryNormalizerOptions { export const createQueryNormalizer = ({ queryClient, normalizer, + optimisticUpdate, normalizerConfig, optimisticUpdateConfig, }: CreateQueryNormalizerOptions) => { @@ -59,8 +35,7 @@ export const createQueryNormalizer = ({ /** Get normalized data */ getNormalizedData: normalizer.getNormalizedData, /** Set normalized data (for manual updates, WebSocket, etc.) */ - setNormalizedData: (data: Data) => - updateQueriesFromMutationData(data, normalizer, queryClient), + setNormalizedData: (data: Data) => optimisticUpdate(data), /** Clear all normalized data */ clear: normalizer.clearNormalizedData, /** Get object by ID */ @@ -122,7 +97,7 @@ export const createQueryNormalizer = ({ if ( !shouldNormalize(globalNormalize, mutationQueryNormalize?.normalize) && - !shouldOptimisticallyUpdate(globalOptimistic, mutationQueryOptimistic?.enabled) + !shouldUpdateOptimistically(globalOptimistic, mutationQueryOptimistic?.enabled) ) { return; } @@ -132,11 +107,7 @@ export const createQueryNormalizer = ({ event.action.type === 'success' && event.action.data ) { - updateQueriesFromMutationData( - event.action.data as Data, - normalizer, - queryClient, - ); + optimisticUpdate(event.action.data as Data); } else if (event.type === 'updated' && event.action.type === 'pending') { const context = event.mutation.state.context as { optimisticData?: Data; @@ -165,11 +136,7 @@ export const createQueryNormalizer = ({ } } - updateQueriesFromMutationData( - context.optimisticData, - normalizer, - queryClient, - ); + optimisticUpdate(context.optimisticData); } } else if (event.type === 'updated' && event.action.type === 'error') { const context = event.mutation.state.context as { @@ -181,11 +148,7 @@ export const createQueryNormalizer = ({ console.log('[OptimisticUpdate] Rolling back changes'); } - updateQueriesFromMutationData( - context.rollbackData, - normalizer, - queryClient, - ); + optimisticUpdate(context.rollbackData); } } }); diff --git a/src/react-query/utils/__tests__/normalize.test.ts b/src/react-query/utils/__tests__/normalize.test.ts index b3cbec2..a037c6e 100644 --- a/src/react-query/utils/__tests__/normalize.test.ts +++ b/src/react-query/utils/__tests__/normalize.test.ts @@ -1,4 +1,4 @@ -import {shouldNormalize, shouldOptimisticallyUpdate} from '../normalize'; +import {shouldNormalize, shouldUpdateOptimistically} from '../normalize'; describe('normalize utils', () => { describe('shouldNormalize', () => { @@ -11,73 +11,17 @@ describe('normalize utils', () => { expect(shouldNormalize(true, false)).toBe(false); expect(shouldNormalize(false, true)).toBe(true); }); - - it('queryConfig should have priority over providerConfig', () => { - // Provider: true, Query: false → false - expect(shouldNormalize(true, false)).toBe(false); - - // Provider: false, Query: true → true - expect(shouldNormalize(false, true)).toBe(true); - }); - - it('should work with various combinations', () => { - // Both true - expect(shouldNormalize(true, true)).toBe(true); - - // Both false - expect(shouldNormalize(false, false)).toBe(false); - - // Provider true, query false - expect(shouldNormalize(true, false)).toBe(false); - - // Provider false, query true - expect(shouldNormalize(false, true)).toBe(true); - - // Provider true, query undefined - expect(shouldNormalize(true, undefined)).toBe(true); - - // Provider false, query undefined - expect(shouldNormalize(false, undefined)).toBe(false); - }); }); - describe('shouldOptimisticallyUpdate', () => { + describe('shouldUpdateOptimistically', () => { it('should return providerConfig if mutationConfig is undefined', () => { - expect(shouldOptimisticallyUpdate(true, undefined)).toBe(true); - expect(shouldOptimisticallyUpdate(false, undefined)).toBe(false); + expect(shouldUpdateOptimistically(true, undefined)).toBe(true); + expect(shouldUpdateOptimistically(false, undefined)).toBe(false); }); it('should return mutationConfig if defined', () => { - expect(shouldOptimisticallyUpdate(true, false)).toBe(false); - expect(shouldOptimisticallyUpdate(false, true)).toBe(true); - }); - - it('mutationConfig should have priority over providerConfig', () => { - // Provider: true, Mutation: false → false - expect(shouldOptimisticallyUpdate(true, false)).toBe(false); - - // Provider: false, Mutation: true → true - expect(shouldOptimisticallyUpdate(false, true)).toBe(true); - }); - - it('should work with various combinations', () => { - // Both true - expect(shouldOptimisticallyUpdate(true, true)).toBe(true); - - // Both false - expect(shouldOptimisticallyUpdate(false, false)).toBe(false); - - // Provider true, mutation false - expect(shouldOptimisticallyUpdate(true, false)).toBe(false); - - // Provider false, mutation true - expect(shouldOptimisticallyUpdate(false, true)).toBe(true); - - // Provider true, mutation undefined - expect(shouldOptimisticallyUpdate(true, undefined)).toBe(true); - - // Provider false, mutation undefined - expect(shouldOptimisticallyUpdate(false, undefined)).toBe(false); + expect(shouldUpdateOptimistically(true, false)).toBe(false); + expect(shouldUpdateOptimistically(false, true)).toBe(true); }); }); @@ -87,8 +31,8 @@ describe('normalize utils', () => { expect(shouldNormalize(true, false)).toBe(false); }); - it('shouldOptimisticallyUpdate should work with boolean false', () => { - expect(shouldOptimisticallyUpdate(true, false)).toBe(false); + it('shouldUpdateOptimistically should work with boolean false', () => { + expect(shouldUpdateOptimistically(true, false)).toBe(false); }); }); }); From 36b5c232a9deb451b06ff5a9b3c540eb057179e9 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 2 Dec 2025 21:09:33 +0100 Subject: [PATCH 04/11] fix: improve client manager --- README-ru.md | 380 +---------- README.md | 380 +---------- src/core/types/Normalizer.ts | 21 + src/react-query/ClientDataManager.ts | 4 +- .../normalize/QueryNormalizerProvider.tsx | 75 --- .../QueryNormalizerProvider.test.tsx | 376 ----------- .../__tests__/createQueryNormalizer.test.tsx | 244 ------- .../__tests__/normalizationEdgeCases.test.tsx | 116 ---- .../__tests__/subscriptions.test.tsx | 632 ------------------ .../__tests__/threeLevelIntegration.test.tsx | 183 ----- .../updateQueriesFromMutationData.test.tsx | 201 ------ src/react-query/normalize/normalization.ts | 163 ----- src/react-query/types/options.ts | 4 +- .../utils/__tests__/normalize.test.ts | 38 -- 14 files changed, 40 insertions(+), 2777 deletions(-) delete mode 100644 src/react-query/normalize/QueryNormalizerProvider.tsx delete mode 100644 src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx delete mode 100644 src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx delete mode 100644 src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx delete mode 100644 src/react-query/normalize/__tests__/subscriptions.test.tsx delete mode 100644 src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx delete mode 100644 src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx delete mode 100644 src/react-query/normalize/normalization.ts delete mode 100644 src/react-query/utils/__tests__/normalize.test.ts diff --git a/README-ru.md b/README-ru.md index 51c1fcd..0eba432 100644 --- a/README-ru.md +++ b/README-ru.md @@ -39,20 +39,6 @@ function App() { } ``` -Или используйте упрощенный `DataSourceProvider` (рекомендуется): - -```tsx -import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; - -function App() { - return ( - - - - ); -} -``` - ### 2. Определение типов ошибок и оберток Определите тип ошибки и создайте свои конструкторы для источников данных на основе стандартных конструкторов: @@ -417,58 +403,18 @@ const MyComponent = withDataManager(({dataManager, ...props}) => { Основной класс для управления данными. ```ts -const dataManager = new ClientDataManager( - { - defaultOptions: { - queries: { - staleTime: 300000, // 5 минут - retry: 3, - refetchOnWindowFocus: false, - }, - }, - }, - // Опционально: второй параметр для включения нормализации - { - normalizerConfig: { - devLogging: false, +const dataManager = new ClientDataManager({ + defaultOptions: { + queries: { + staleTime: 300000, // 5 минут + retry: 3, + refetchOnWindowFocus: false, }, }, -); - -// Доступ к нормализатору, если включен -if (dataManager.normalizer) { - const data = dataManager.normalizer.getNormalizedData(); -} -``` - -**Свойства:** - -- `queryClient` - Экземпляр React Query клиента -- `normalizer` - Экземпляр нормализатора (если нормализация включена) - -**Методы:** - -##### `optimisticUpdate(mutationData)` - -Применяет оптимистичные обновления к нормализованным данным. Все запросы, использующие обновленные сущности, будут автоматически обновлены. - -```ts -dataManager.optimisticUpdate({ - id: '123', - name: 'Обновленное имя', }); ``` -##### `automaticInvalidate(data)` - -Инвалидирует запросы на основе нормализованных данных. Полезно для инвалидации кеша, управляемой сервером. - -```ts -dataManager.automaticInvalidate({ - id: '123', - name: 'Новое имя', -}); -``` +**Методы:** ##### `invalidateTag(tag, options?)` @@ -818,318 +764,6 @@ const UserProfile: React.FC<{userId: number}> = ({userId}) => { }; ``` -## Нормализация данных - -Data Source предоставляет встроенную нормализацию данных с использованием [@normy/core](https://github.com/klis87/normy). Нормализация помогает управлять реляционными данными, храня сущности по их ID и автоматически обновляя все связанные запросы при изменении данных. - -### Зачем нужна нормализация? - -Без нормализации одна и та же сущность может дублироваться в нескольких запросах. Когда вы обновляете эту сущность в одном месте, другие запросы остаются устаревшими до повторной загрузки. Нормализация решает эту проблему: - -1. **Хранение сущностей один раз** - Каждая сущность хранится по её ID в нормализованном хранилище -2. **Автоматические обновления** - При изменении сущности все запросы, использующие её, автоматически обновляются -3. **Консистентность** - Ваш UI всегда отображает актуальные данные во всех компонентах -4. **Экономия памяти** - Нет дублирования одних и тех же данных - -### Настройка с DataSourceProvider - -Самый простой способ включить нормализацию - использовать `DataSourceProvider`: - -```tsx -import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; - -// Создайте DataManager с включенной нормализацией -const dataManager = new ClientDataManager( - { - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - }, - }, - }, - // Включите нормализацию в ClientDataManager - { - normalizerConfig: { - devLogging: false, - }, - }, -); - -function App() { - return ( - - - - ); -} -``` - -`DataSourceProvider` - это удобная обертка, которая объединяет: - -- `QueryNormalizerProvider` - Обрабатывает нормализацию данных -- `QueryClientProvider` - Провайдер React Query -- `DataManagerContext.Provider` - Контекст Data Source - -### Ручная настройка с QueryNormalizerProvider - -Для большего контроля можно настроить провайдеры вручную: - -```tsx -import {QueryClientProvider} from '@tanstack/react-query'; -import { - ClientDataManager, - DataManagerContext, - QueryNormalizerProvider, -} from '@gravity-ui/data-source'; - -// Создайте DataManager с нормализацией -const dataManager = new ClientDataManager( - { - defaultOptions: { - queries: {staleTime: 5 * 60 * 1000}, - }, - }, - {normalizerConfig: {devLogging: false}}, -); - -function App() { - return ( - - - - - - - - ); -} -``` - -### Настройка источников данных для нормализации - -Вы можете включать/выключать нормализацию для конкретного источника данных или запроса: - -```ts -const userDataSource = makePlainQueryDataSource({ - name: 'user', - fetch: skipContext(fetchUser), - options: { - normalizationConfig: { - normalize: true, // Переопределить глобальную настройку для этого источника - }, - }, -}); - -// В компоненте - переопределить для конкретного запроса -const {data} = useQueryData( - userDataSource, - {userId: 123}, - { - normalizationConfig: { - normalize: false, // Отключить нормализацию для этого конкретного запроса - }, - }, -); -``` - -### Оптимистичные обновления - -Оптимистичные обновления позволяют обновить UI немедленно до ответа сервера, обеспечивая мгновенную обратную связь: - -```tsx -import {useMutation} from '@tanstack/react-query'; -import {useDataManager} from '@gravity-ui/data-source'; - -function UserProfile() { - const dataManager = useDataManager(); - - const mutation = useMutation({ - mutationFn: updateUser, - onMutate: async (newUser) => { - // Вернуть оптимистичные данные, которые будут автоматически применены - return { - optimisticData: {id: newUser.id, name: newUser.name}, - }; - }, - // Откат рассчитывается автоматически если autoCalculateRollback: true - // Иначе вы можете предоставить его вручную: - onMutate: async (newUser) => { - const currentUser = dataManager.normalizer?.getObjectById(newUser.id); - return { - optimisticData: {id: newUser.id, name: newUser.name}, - rollbackData: currentUser, - }; - }, - // Настройка для конкретной мутации - optimisticUpdateConfig: { - enabled: true, - devLogging: true, - }, - }); -} -``` - -### Работа с нормализованными данными - -`QueryNormalizerProvider` предоставляет хук `useQueryNormalizer` с вспомогательными методами: - -```tsx -import {useQueryNormalizer} from '@gravity-ui/data-source'; - -function MyComponent() { - const queryNormalizer = useQueryNormalizer(); - - // Получить все нормализованные данные - const normalizedData = queryNormalizer.getNormalizedData(); - - // Получить конкретную сущность по ID - const user = queryNormalizer.getObjectById('123'); - - // Получить фрагмент данных - const fragment = queryNormalizer.getQueryFragment({ - id: '123', - name: true, - }); - - // Найти запросы, зависящие от конкретных данных - const dependentQueries = queryNormalizer.getDependentQueries({ - id: '123', - name: 'Updated Name', - }); - - // Найти запросы по ID сущностей - const queries = queryNormalizer.getDependentQueriesByIds(['123', '456']); - - // Вручную обновить нормализованные данные (например, из WebSocket) - queryNormalizer.setNormalizedData({ - id: '123', - name: 'Real-time Update', - }); - - // Очистить все нормализованные данные - queryNormalizer.clear(); -} -``` - -Вы также можете использовать `dataManager` напрямую для оптимистичных обновлений: - -```tsx -import {useDataManager} from '@gravity-ui/data-source'; - -function MyComponent() { - const dataManager = useDataManager(); - - const handleUpdate = () => { - // Применить оптимистичное обновление через dataManager - dataManager.optimisticUpdate({ - id: '123', - name: 'Обновленное имя', - }); - }; -} -``` - -### Пример real-time обновлений - -Нормализация отлично работает с WebSocket или другими real-time обновлениями: - -```tsx -import {useEffect} from 'react'; -import {useDataManager} from '@gravity-ui/data-source'; - -function useWebSocketUpdates() { - const dataManager = useDataManager(); - - useEffect(() => { - const ws = new WebSocket('wss://api.example.com/updates'); - - ws.onmessage = (event) => { - const update = JSON.parse(event.data); - - // Автоматически обновляет все запросы, использующие эти данные - dataManager.optimisticUpdate(update); - }; - - return () => ws.close(); - }, [dataManager]); -} -``` - -Вы также можете использовать `useQueryNormalizer` для более продвинутых сценариев: - -```tsx -import {useQueryNormalizer} from '@gravity-ui/data-source'; - -function useAdvancedNormalization() { - const queryNormalizer = useQueryNormalizer(); - - // Получить конкретную сущность по ID - const user = queryNormalizer.getObjectById('123'); - - // Найти зависимые запросы перед обновлением - const dependentQueries = queryNormalizer.getDependentQueries({id: '123'}); - - // Обновить данные - queryNormalizer.setNormalizedData({id: '123', name: 'Новое имя'}); -} -``` - -### Справочник по конфигурации - -#### NormalizerConfig - -```ts -interface DataSourceNormalizerConfig { - /** Включена ли нормализация, по умолчанию false */ - normalize?: boolean; - - // Опции конфигурации @normy/core: - /** Использовать ли структурное разделение для обновлений */ - structuralSharing?: boolean; - /** Пользовательские схемы нормализации */ - schemas?: Record; - /** Пользовательское имя поля ID (по умолчанию: 'id') */ - idAttribute?: string; -} -``` - -#### OptimisticUpdateConfig - -```ts -interface OptimisticUpdateConfig { - /** Включена ли оптимистичная синхронизация, по умолчанию false. Внимание: не будет работать без нормализации */ - enabled?: boolean; - /** Автоматически рассчитывать данные для отката, по умолчанию true */ - autoCalculateRollback?: boolean; - /** Включено ли debug логирование */ - devLogging?: boolean; -} -``` - -### Лучшие практики - -1. **Включайте глобально, отключайте выборочно** - Включите нормализацию на уровне провайдера, отключайте только для конкретных запросов, которым она не нужна -2. **Используйте консистентные структуры сущностей** - Убедитесь, что ваш API возвращает сущности с консистентными полями ID -3. **Используйте оптимистичные обновления** - Для лучшего UX используйте оптимистичные обновления с автоматическим откатом -4. **Мониторьте в разработке** - Включите `devLogging` во время разработки, чтобы понимать поведение нормализации -5. **Комбинируйте с тегами** - Используйте и нормализацию, и теги для комплексного управления кешем - ## Поддержка TypeScript Библиотека построена с TypeScript-first подходом и обеспечивает полный вывод типов: diff --git a/README.md b/README.md index 045dcd5..2e0ec8f 100644 --- a/README.md +++ b/README.md @@ -39,20 +39,6 @@ function App() { } ``` -Or use the simplified `DataSourceProvider` (recommended): - -```tsx -import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; - -function App() { - return ( - - - - ); -} -``` - ### 2. Define Error Types and Wrappers Define a type of error and make your constructors for data sources based on default constructors: @@ -417,58 +403,18 @@ const MyComponent = withDataManager(({dataManager, ...props}) => { Main class for data management. ```ts -const dataManager = new ClientDataManager( - { - defaultOptions: { - queries: { - staleTime: 300000, // 5 minutes - retry: 3, - refetchOnWindowFocus: false, - }, - }, - }, - // Optional: second parameter to enable normalization - { - normalizerConfig: { - devLogging: false, +const dataManager = new ClientDataManager({ + defaultOptions: { + queries: { + staleTime: 300000, // 5 minutes + retry: 3, + refetchOnWindowFocus: false, }, }, -); - -// Access normalizer if enabled -if (dataManager.normalizer) { - const data = dataManager.normalizer.getNormalizedData(); -} -``` - -**Properties:** - -- `queryClient` - React Query client instance -- `normalizer` - Normalizer instance (if normalization is enabled) - -**Methods:** - -##### `optimisticUpdate(mutationData)` - -Apply optimistic updates to normalized data. All queries using the updated entities will be automatically refreshed. - -```ts -dataManager.optimisticUpdate({ - id: '123', - name: 'Updated Name', }); ``` -##### `automaticInvalidate(data)` - -Invalidate queries based on normalized data. Useful for server-driven cache invalidation. - -```ts -dataManager.automaticInvalidate({ - id: '123', - name: 'New Name', -}); -``` +**Methods:** ##### `invalidateTag(tag, options?)` @@ -818,318 +764,6 @@ const UserProfile: React.FC<{userId: number}> = ({userId}) => { }; ``` -## Data Normalization - -Data Source provides built-in data normalization using [@normy/core](https://github.com/klis87/normy). Normalization helps manage relational data by storing entities by their IDs and automatically updating all related queries when data changes. - -### Why Normalization? - -Without normalization, the same entity might be duplicated across multiple queries. When you update this entity in one place, other queries remain outdated until they refetch. Normalization solves this by: - -1. **Storing entities once** - Each entity is stored by its ID in a normalized store -2. **Automatic updates** - When an entity changes, all queries using it are automatically updated -3. **Consistency** - Your UI always shows the latest data across all components -4. **Reduced memory** - No duplication of the same data - -### Setup with DataSourceProvider - -The simplest way to enable normalization is to use `DataSourceProvider`: - -```tsx -import {ClientDataManager, DataSourceProvider} from '@gravity-ui/data-source'; - -// Create DataManager with normalization enabled -const dataManager = new ClientDataManager( - { - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - }, - }, - }, - // Enable normalization in ClientDataManager - { - normalizerConfig: { - devLogging: false, - }, - }, -); - -function App() { - return ( - - - - ); -} -``` - -`DataSourceProvider` is a convenient wrapper that combines: - -- `QueryNormalizerProvider` - Handles data normalization -- `QueryClientProvider` - React Query provider -- `DataManagerContext.Provider` - Data Source context - -### Manual Setup with QueryNormalizerProvider - -For more control, you can set up providers manually: - -```tsx -import {QueryClientProvider} from '@tanstack/react-query'; -import { - ClientDataManager, - DataManagerContext, - QueryNormalizerProvider, -} from '@gravity-ui/data-source'; - -// Create DataManager with normalization -const dataManager = new ClientDataManager( - { - defaultOptions: { - queries: {staleTime: 5 * 60 * 1000}, - }, - }, - {normalizerConfig: {devLogging: false}}, -); - -function App() { - return ( - - - - - - - - ); -} -``` - -### Configuring Data Sources for Normalization - -You can enable/disable normalization per data source or per query: - -```ts -const userDataSource = makePlainQueryDataSource({ - name: 'user', - fetch: skipContext(fetchUser), - options: { - normalizationConfig: { - normalize: true, // Override global setting for this data source - }, - }, -}); - -// In component - override for specific query -const {data} = useQueryData( - userDataSource, - {userId: 123}, - { - normalizationConfig: { - normalize: false, // Disable normalization for this specific query - }, - }, -); -``` - -### Optimistic Updates - -Optimistic updates allow you to update the UI immediately before the server responds, providing instant feedback: - -```tsx -import {useMutation} from '@tanstack/react-query'; -import {useDataManager} from '@gravity-ui/data-source'; - -function UserProfile() { - const dataManager = useDataManager(); - - const mutation = useMutation({ - mutationFn: updateUser, - onMutate: async (newUser) => { - // Return optimistic data that will be automatically applied - return { - optimisticData: {id: newUser.id, name: newUser.name}, - }; - }, - // Rollback is calculated automatically if autoCalculateRollback: true - // Otherwise you can provide it manually: - onMutate: async (newUser) => { - const currentUser = dataManager.normalizer?.getObjectById(newUser.id); - return { - optimisticData: {id: newUser.id, name: newUser.name}, - rollbackData: currentUser, - }; - }, - // Configure per mutation - optimisticUpdateConfig: { - enabled: true, - devLogging: true, - }, - }); -} -``` - -### Working with Normalized Data - -The `QueryNormalizerProvider` provides a `useQueryNormalizer` hook with utility methods: - -```tsx -import {useQueryNormalizer} from '@gravity-ui/data-source'; - -function MyComponent() { - const queryNormalizer = useQueryNormalizer(); - - // Get all normalized data - const normalizedData = queryNormalizer.getNormalizedData(); - - // Get specific entity by ID - const user = queryNormalizer.getObjectById('123'); - - // Get data fragment - const fragment = queryNormalizer.getQueryFragment({ - id: '123', - name: true, - }); - - // Find which queries depend on specific data - const dependentQueries = queryNormalizer.getDependentQueries({ - id: '123', - name: 'Updated Name', - }); - - // Find queries by entity IDs - const queries = queryNormalizer.getDependentQueriesByIds(['123', '456']); - - // Manually update normalized data (e.g., from WebSocket) - queryNormalizer.setNormalizedData({ - id: '123', - name: 'Real-time Update', - }); - - // Clear all normalized data - queryNormalizer.clear(); -} -``` - -You can also use `dataManager` directly for optimistic updates: - -```tsx -import {useDataManager} from '@gravity-ui/data-source'; - -function MyComponent() { - const dataManager = useDataManager(); - - const handleUpdate = () => { - // Apply optimistic update through dataManager - dataManager.optimisticUpdate({ - id: '123', - name: 'Updated Name', - }); - }; -} -``` - -### Real-time Updates Example - -Normalization works great with WebSocket or other real-time updates: - -```tsx -import {useEffect} from 'react'; -import {useDataManager} from '@gravity-ui/data-source'; - -function useWebSocketUpdates() { - const dataManager = useDataManager(); - - useEffect(() => { - const ws = new WebSocket('wss://api.example.com/updates'); - - ws.onmessage = (event) => { - const update = JSON.parse(event.data); - - // Automatically updates all queries that use this data - dataManager.optimisticUpdate(update); - }; - - return () => ws.close(); - }, [dataManager]); -} -``` - -You can also use `useQueryNormalizer` for more advanced scenarios: - -```tsx -import {useQueryNormalizer} from '@gravity-ui/data-source'; - -function useAdvancedNormalization() { - const queryNormalizer = useQueryNormalizer(); - - // Get specific entity by ID - const user = queryNormalizer.getObjectById('123'); - - // Find dependent queries before update - const dependentQueries = queryNormalizer.getDependentQueries({id: '123'}); - - // Update data - queryNormalizer.setNormalizedData({id: '123', name: 'New Name'}); -} -``` - -### Configuration Reference - -#### NormalizerConfig - -```ts -interface DataSourceNormalizerConfig { - /** Whether normalization is enabled, defaults to false */ - normalize?: boolean; - - // @normy/core configuration options: - /** Whether to use structural sharing for updates */ - structuralSharing?: boolean; - /** Custom normalization schemas */ - schemas?: Record; - /** Custom ID field name (default: 'id') */ - idAttribute?: string; -} -``` - -#### OptimisticUpdateConfig - -```ts -interface OptimisticUpdateConfig { - /** Whether optimistic synchronization is enabled, defaults to false. Note: won't work without normalization */ - enabled?: boolean; - /** Automatically calculate rollback data, defaults to true */ - autoCalculateRollback?: boolean; - /** Whether debug logging is enabled */ - devLogging?: boolean; -} -``` - -### Best Practices - -1. **Enable globally, disable selectively** - Enable normalization at the provider level, disable only for specific queries that don't need it -2. **Use consistent entity structures** - Ensure your API returns entities with consistent ID fields -3. **Leverage optimistic updates** - For better UX, use optimistic updates with automatic rollback -4. **Monitor in development** - Enable `devLogging` during development to understand normalization behavior -5. **Combine with tags** - Use both normalization and tags for comprehensive cache management - ## TypeScript Support The library is built with TypeScript-first approach and provides full type inference: diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts index 092d37f..f477744 100644 --- a/src/core/types/Normalizer.ts +++ b/src/core/types/Normalizer.ts @@ -30,3 +30,24 @@ export interface Normalizer { getCurrentData: (newData: T) => T | undefined; log: (...messages: unknown[]) => void; } + +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/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index d928140..0e2cbbb 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -14,6 +14,7 @@ import { hasTag, } from '../core'; import type {InvalidateOptions, InvalidateRepeatOptions} from '../core/types/DataManagerOptions'; +import type {QueryNormalizer} from '../core/types/Normalizer'; import type {QueryNormalizer} from './types/normalizer'; import {createQueryNormalizer} from './utils/normalize'; @@ -27,7 +28,7 @@ export class ClientDataManager implements DataManager { readonly normalizer?: Normalizer | undefined; readonly queryNormalizer?: QueryNormalizer | undefined; - constructor(config: ClientDataManagerConfig = {}, normalizerConfig?: NormalizerClientConfig) { + constructor(config: ClientDataManagerConfig = {}) { this.queryClient = new QueryClient({ ...config, defaultOptions: { @@ -62,6 +63,7 @@ export class ClientDataManager implements DataManager { queriesToUpdate.forEach((query) => { const queryKey = JSON.parse(query.queryKey) as QueryKey; + const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; diff --git a/src/react-query/normalize/QueryNormalizerProvider.tsx b/src/react-query/normalize/QueryNormalizerProvider.tsx deleted file mode 100644 index 7cc42d7..0000000 --- a/src/react-query/normalize/QueryNormalizerProvider.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; - -import type {ClientDataManager} from '../ClientDataManager'; -import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../types/normalizer'; - -import {createQueryNormalizer} from './normalization'; - -const QueryNormalizerContext = React.createContext< - undefined | ReturnType ->(undefined); - -export interface QueryNormalizerProviderProps { - /** React Query client instance */ - dataManager: ClientDataManager; - children: React.ReactNode; - /** Configuration for the normalizer */ - normalizerConfig?: DataSourceNormalizerConfig; - /** Configuration for optimistic updates */ - optimisticUpdateConfig?: OptimisticUpdateConfig; -} - -export const QueryNormalizerProvider: React.FC = ({ - dataManager, - normalizerConfig = {}, - optimisticUpdateConfig = {}, - children, -}) => { - const [queryNormalizer] = React.useState(() => { - if (!dataManager.normalizer) { - return null; - } - - return createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - }); - - React.useEffect(() => { - if (!queryNormalizer) { - return undefined; - } - - queryNormalizer.subscribe(); - - return () => { - queryNormalizer.unsubscribe(); - queryNormalizer.clear(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (!queryNormalizer) { - return {children}; - } - - return ( - - {children} - - ); -}; - -export const useQueryNormalizer = () => { - const queryNormalizer = React.useContext(QueryNormalizerContext); - - if (!queryNormalizer) { - throw new Error('No QueryNormalizer set, use QueryNormalizerProvider to set one'); - } - - return queryNormalizer; -}; diff --git a/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx b/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx deleted file mode 100644 index 0a7abdc..0000000 --- a/src/react-query/normalize/__tests__/QueryNormalizerProvider.test.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import React from 'react'; - -import {QueryClientProvider, useMutation, useQuery} from '@tanstack/react-query'; -import {renderHook, waitFor} from '@testing-library/react'; - -import {ClientDataManager} from '../../ClientDataManager'; -import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; -import {QueryNormalizerProvider, useQueryNormalizer} from '../QueryNormalizerProvider'; - -describe('QueryNormalizerProvider', () => { - let dataManager: ClientDataManager; - - beforeEach(() => { - dataManager = new ClientDataManager( - { - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, - }, - }, - true, - ); - }); - - afterEach(() => { - dataManager.queryClient.clear(); - }); - - const createWrapper = ( - normalizerConfig?: DataSourceNormalizerConfig, - optimisticUpdateConfig?: OptimisticUpdateConfig, - ) => { - const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) => ( - - - {children} - - - ); - Wrapper.displayName = 'TestWrapper'; - return Wrapper; - }; - - describe('Provider setup', () => { - it('should provide queryNormalizer through context', () => { - const wrapper = createWrapper({normalize: true}); - - const {result} = renderHook(() => useQueryNormalizer(), {wrapper}); - - expect(result.current).toBeDefined(); - expect(result.current.getNormalizedData).toBeDefined(); - }); - - it('should throw error if used outside Provider', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - expect(() => { - renderHook(() => useQueryNormalizer()); - }).toThrow('No QueryNormalizer set, use QueryNormalizerProvider to set one'); - - consoleSpy.mockRestore(); - }); - - it('should create normalizer with global configuration', () => { - const getNormalizationObjectKey = jest.fn((obj) => obj.id); - - const wrapper = createWrapper({ - normalize: true, - getNormalizationObjectKey, - }); - - renderHook(() => useQueryNormalizer(), {wrapper}); - - // Normalizer should be created with passed function - expect(getNormalizationObjectKey).toBeDefined(); - }); - }); - - describe('React Query integration', () => { - it('should normalize data from useQuery', async () => { - const wrapper = createWrapper({normalize: true}); - - const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); - - const {result: queryResult} = renderHook( - () => - useQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'User 1'}], - }), - {wrapper}, - ); - - await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); - - const normalized = normalizerResult.current.getNormalizedData(); - - expect(normalized.objects['@@1']).toBeDefined(); - expect(normalized.objects['@@1'].name).toBe('User 1'); - }); - - it('should update data on mutation', async () => { - const wrapper = createWrapper({normalize: true}); - - const {result: queryResult} = renderHook( - () => - useQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'User 1'}], - }), - {wrapper}, - ); - - await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); - - // Mutation - const {result: mutationResult} = renderHook( - () => - useMutation({ - mutationFn: async (name: string) => ({ - id: '1', - name, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate('Updated User'); - - await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - - // Query data should be updated - await waitFor(() => { - const data = dataManager.queryClient.getQueryData< - Array<{id: string; name: string}> - >(['users']); - expect(data?.[0].name).toBe('Updated User'); - }); - }); - - it('should work with multiple queries', async () => { - const wrapper = createWrapper({normalize: true}); - - // Two queries with the same object - const {result: query1} = renderHook( - () => - useQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'User 1'}], - }), - {wrapper}, - ); - - const {result: query2} = renderHook( - () => - useQuery({ - queryKey: ['user', '1'], - queryFn: async () => ({id: '1', name: 'User 1'}), - }), - {wrapper}, - ); - - await waitFor(() => expect(query1.current.isSuccess).toBe(true)); - await waitFor(() => expect(query2.current.isSuccess).toBe(true)); - - // Mutation - const {result: mutation} = renderHook( - () => - useMutation({ - mutationFn: async () => ({ - id: '1', - name: 'Updated User', - }), - }), - {wrapper}, - ); - - mutation.current.mutate(); - - await waitFor(() => expect(mutation.current.isSuccess).toBe(true)); - - // Both queries should be updated - await waitFor(() => { - const users = dataManager.queryClient.getQueryData< - Array<{id: string; name: string}> - >(['users']); - const user = dataManager.queryClient.getQueryData<{id: string; name: string}>([ - 'user', - '1', - ]); - - expect(users?.[0].name).toBe('Updated User'); - expect(user?.name).toBe('Updated User'); - }); - }); - }); - - describe('Optimistic updates via Provider', () => { - it('should apply optimistic updates', async () => { - const wrapper = createWrapper( - {normalize: true}, - { - enabled: true, - autoCalculateRollback: true, - }, - ); - - // Initial data - const {result: queryResult} = renderHook( - () => - useQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'Original'}], - }), - {wrapper}, - ); - - await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); - - // Mutation with optimistic data - const {result: mutationResult} = renderHook( - () => - useMutation({ - mutationFn: async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - return {id: '1', name: 'Final'}; - }, - onMutate: () => ({ - optimisticData: {id: '1', name: 'Optimistic'}, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - // Should have optimistic data immediately - await waitFor(() => { - const data = dataManager.queryClient.getQueryData< - Array<{id: string; name: string}> - >(['users']); - expect(data?.[0].name).toBe('Optimistic'); - }); - - // After completion - final data - await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - - await waitFor(() => { - const data = dataManager.queryClient.getQueryData< - Array<{id: string; name: string}> - >(['users']); - expect(data?.[0].name).toBe('Final'); - }); - }); - - it('should rollback changes on error', async () => { - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - - const wrapper = createWrapper( - {normalize: true}, - { - enabled: true, - autoCalculateRollback: true, - devLogging: true, - }, - ); - - // Initial data - const {result: queryResult} = renderHook( - () => - useQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'Original'}], - }), - {wrapper}, - ); - - await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)); - - // Mutation that will fail - const {result: mutationResult} = renderHook( - () => - useMutation({ - mutationFn: async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - throw new Error('Failed'); - }, - onMutate: () => ({ - optimisticData: {id: '1', name: 'Optimistic'}, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - // Should have optimistic data - await waitFor(() => { - const data = dataManager.queryClient.getQueryData< - Array<{id: string; name: string}> - >(['users']); - expect(data?.[0].name).toBe('Optimistic'); - }); - - // After error - rollback - await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - - await waitFor(() => { - const data = dataManager.queryClient.getQueryData< - Array<{id: string; name: string}> - >(['users']); - expect(data?.[0].name).toBe('Original'); - }); - - consoleSpy.mockRestore(); - }); - }); - - describe('Cleanup', () => { - it('should unsubscribe on unmount', () => { - const wrapper = createWrapper({normalize: true}); - - const {result, unmount} = renderHook(() => useQueryNormalizer(), {wrapper}); - - expect(result.current).toBeDefined(); - - // Should not throw errors on unmount - expect(() => unmount()).not.toThrow(); - }); - - it('should clear data on unmount', async () => { - const wrapper = createWrapper({normalize: true}); - - const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); - - // Add data - await dataManager.queryClient.fetchQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'User 1'}], - }); - - const normalizedBefore = normalizerResult.current.getNormalizedData(); - expect(Object.keys(normalizedBefore.objects)).toHaveLength(1); - }); - }); - - describe('Default configuration', () => { - it('should work with empty configuration', () => { - const wrapper = createWrapper(); - - const {result} = renderHook(() => useQueryNormalizer(), {wrapper}); - - expect(result.current).toBeDefined(); - }); - - it('normalization should be disabled by default', async () => { - const wrapper = createWrapper(); // normalize not specified - - const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); - - await dataManager.queryClient.fetchQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'User 1'}], - }); - - const normalized = normalizerResult.current.getNormalizedData(); - - // Data should not be normalized - expect(Object.keys(normalized.objects)).toHaveLength(0); - }); - }); -}); diff --git a/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx b/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx deleted file mode 100644 index 8b6fd57..0000000 --- a/src/react-query/normalize/__tests__/createQueryNormalizer.test.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import {ClientDataManager} from '../../ClientDataManager'; -import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; -import {createQueryNormalizer} from '../normalization'; - -describe('createQueryNormalizer', () => { - let dataManager: ClientDataManager; - - const normalizerConfig: DataSourceNormalizerConfig = { - normalize: true, - }; - - const optimisticUpdateConfig: OptimisticUpdateConfig = { - enabled: true, - autoCalculateRollback: true, - }; - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - expect(queryNormalizer.getNormalizedData).toBeDefined(); - expect(queryNormalizer.setNormalizedData).toBeDefined(); - expect(queryNormalizer.clear).toBeDefined(); - expect(queryNormalizer.getObjectById).toBeDefined(); - expect(queryNormalizer.getQueryFragment).toBeDefined(); - expect(queryNormalizer.getDependentQueries).toBeDefined(); - expect(queryNormalizer.getDependentQueriesByIds).toBeDefined(); - expect(queryNormalizer.subscribe).toBeDefined(); - expect(queryNormalizer.unsubscribe).toBeDefined(); - }); - - it('getNormalizedData should return normalized data', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - const data = queryNormalizer.getNormalizedData(); - expect(data).toBeDefined(); - expect(data.objects).toBeDefined(); - expect(data.queries).toBeDefined(); - }); - - it('setNormalizedData should update queries', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryKey = ['users']; - dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryKey = ['users']; - dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'User'}]); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'User'}]); - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.clear(); - - const data = queryNormalizer.getNormalizedData(); - expect(Object.keys(data.objects)).toHaveLength(0); - }); - - it('getObjectById should return object by ID', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryKey = ['users']; - const userData = [{id: '1', name: 'User 1'}]; - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - // Add to normalizer - dataManager.normalizer.setQuery(JSON.stringify(queryKey), userData); - - // Get normalized data - const normalized = queryNormalizer.getNormalizedData(); - expect(Object.keys(normalized.objects).length).toBeGreaterThan(0); - - // getObjectById should be defined and available - expect(queryNormalizer.getObjectById).toBeDefined(); - expect(typeof queryNormalizer.getObjectById).toBe('function'); - }); - - it('getDependentQueries should return dependent queries', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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 queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - const dependentQueries = queryNormalizer.getDependentQueries({ - id: '1', - name: 'Updated', - }); - expect(dependentQueries.length).toBeGreaterThan(0); - }); - - it('getDependentQueriesByIds should be available', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - // getDependentQueriesByIds should be defined and available - expect(queryNormalizer.getDependentQueriesByIds).toBeDefined(); - expect(typeof queryNormalizer.getDependentQueriesByIds).toBe('function'); - - // Call with empty array should not throw - const result = queryNormalizer.getDependentQueriesByIds([]); - expect(Array.isArray(result)).toBe(true); - }); - - it('should correctly handle normalize: false', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const config: DataSourceNormalizerConfig = {normalize: false}; - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig: config, - optimisticUpdateConfig, - }); - - expect(queryNormalizer).toBeDefined(); - }); - - it('should disable optimistic updates if normalize is disabled', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const config: DataSourceNormalizerConfig = {normalize: false}; - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig: config, - optimisticUpdateConfig: {enabled: true}, // Try to enable - }); - - // Normalizer is created, but optimistic updates should not work - expect(queryNormalizer).toBeDefined(); - }); -}); diff --git a/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx b/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx deleted file mode 100644 index 38bd0e9..0000000 --- a/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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/normalize/__tests__/subscriptions.test.tsx b/src/react-query/normalize/__tests__/subscriptions.test.tsx deleted file mode 100644 index 248d3c3..0000000 --- a/src/react-query/normalize/__tests__/subscriptions.test.tsx +++ /dev/null @@ -1,632 +0,0 @@ -import React from 'react'; - -import {QueryClientProvider, useMutation} from '@tanstack/react-query'; -import {renderHook, waitFor} from '@testing-library/react'; - -import {ClientDataManager} from '../../ClientDataManager'; -import type {DataSourceNormalizerConfig, OptimisticUpdateConfig} from '../../types/normalizer'; -import {createQueryNormalizer} from '../normalization'; - -describe('subscriptions', () => { - let dataManager: ClientDataManager; - - beforeEach(() => { - dataManager = new ClientDataManager( - { - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, - }, - }, - { - normalizerConfig: { - devLogging: false, - }, - }, - ); - }); - - afterEach(() => { - dataManager.queryClient.clear(); - }); - - describe('QueryCache subscription', () => { - const normalizerConfig: DataSourceNormalizerConfig = { - normalize: true, - }; - - const optimisticUpdateConfig: OptimisticUpdateConfig = { - enabled: false, - }; - - it('should add query to normalizer when added to QueryCache', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - - // Add query - await dataManager.queryClient.fetchQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'User 1'}], - }); - - const normalized = queryNormalizer.getNormalizedData(); - expect(normalized.objects['@@1']).toBeDefined(); - - queryNormalizer.unsubscribe(); - }); - - it('should update query in normalizer on update', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - // Initial data - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'Old'}], - }); - - const normalizedBefore = queryNormalizer.getNormalizedData(); - const objectCountBefore = Object.keys(normalizedBefore.objects).length; - - // Update data - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'New'}], - }); - - // Verify that normalized data was updated - const normalizedAfter = queryNormalizer.getNormalizedData(); - expect(Object.keys(normalizedAfter.objects).length).toBe(objectCountBefore); - expect(normalizedAfter.queries[JSON.stringify(queryKey)]).toBeDefined(); - - queryNormalizer.unsubscribe(); - }); - - it('should remove query from normalizer when removed from QueryCache', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - // Add query - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'User 1'}], - }); - - // Remove query - dataManager.queryClient.removeQueries({queryKey}); - - // Give time to process event - await new Promise((resolve) => setTimeout(resolve, 10)); - - const normalized = queryNormalizer.getNormalizedData(); - // Query should be removed from queries - expect(normalized.queries[JSON.stringify(queryKey)]).toBeUndefined(); - - queryNormalizer.unsubscribe(); - }); - - it('should support meta configuration for queries', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - - // Check that we can use meta for configuration - // Real check for disabling via meta is already tested in integration tests - const normalized = queryNormalizer.getNormalizedData(); - expect(normalized).toBeDefined(); - expect(normalized.objects).toBeDefined(); - expect(normalized.queries).toBeDefined(); - - queryNormalizer.unsubscribe(); - }); - - it('should unsubscribe correctly', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - queryNormalizer.unsubscribe(); - - // After unsubscribing, adding query should not affect normalizer - await dataManager.queryClient.fetchQuery({ - queryKey: ['users'], - queryFn: async () => [{id: '1', name: 'User 1'}], - }); - - const normalized = queryNormalizer.getNormalizedData(); - expect(Object.keys(normalized.objects)).toHaveLength(0); - }); - - it('should allow multiple unsubscribe calls', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - queryNormalizer.unsubscribe(); - - // Repeated call should not throw error - expect(() => queryNormalizer.unsubscribe()).not.toThrow(); - }); - }); - - describe('MutationCache subscription', () => { - const normalizerConfig: DataSourceNormalizerConfig = { - normalize: true, - }; - - const optimisticUpdateConfig: OptimisticUpdateConfig = { - enabled: true, - autoCalculateRollback: true, - }; - - it('should update queries on successful mutation', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - // Initial data - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'Old'}], - }); - - // 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'}), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - expect(data[0].name).toBe('New'); - - queryNormalizer.unsubscribe(); - }); - - it('should apply optimistic updates', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - // Initial data - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'Original'}], - }); - - 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'}, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - // Check optimistic data - await waitFor(() => { - const data = dataManager.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 = dataManager.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - expect(dataFinal[0].name).toBe('Final'); - - queryNormalizer.unsubscribe(); - }); - - it('should automatically calculate rollbackData', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig: { - enabled: true, - autoCalculateRollback: true, - }, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - // Initial data - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'Original'}], - }); - - 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'}, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - - // Data should be rolled back to original - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - expect(data[0].name).toBe('Original'); - - queryNormalizer.unsubscribe(); - }); - - it('should rollback changes on mutation error', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - // Initial data - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'Original'}], - }); - - 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'}, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - expect(data[0].name).toBe('Original'); - - queryNormalizer.unsubscribe(); - }); - - it('should ignore mutations with normalize: false and optimistic: false', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig: {normalize: false}, // Globally disabled - optimisticUpdateConfig: {enabled: false}, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Original'}]); - - const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - - ); - - // Mutation should not update data automatically - const {result: mutationResult} = renderHook( - () => - useMutation({ - mutationFn: async () => ({id: '1', name: 'New'}), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - expect(data[0].name).toBe('Original'); // Not changed - - queryNormalizer.unsubscribe(); - }); - - it('should support devLogging for optimistic updates', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig: { - enabled: true, - autoCalculateRollback: true, - devLogging: true, - }, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'Original'}], - }); - - 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'}, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - 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(); - queryNormalizer.unsubscribe(); - }); - - it('should support manual rollbackData', async () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - const queryNormalizer = createQueryNormalizer({ - queryClient: dataManager.queryClient, - normalizer: dataManager.normalizer, - optimisticUpdate: (data) => dataManager.optimisticUpdate(data), - normalizerConfig, - optimisticUpdateConfig: { - enabled: true, - autoCalculateRollback: false, - }, - }); - - queryNormalizer.subscribe(); - - const queryKey = ['users']; - - await dataManager.queryClient.fetchQuery({ - queryKey, - queryFn: async () => [{id: '1', name: 'Original'}], - }); - - 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'}, - }), - }), - {wrapper}, - ); - - mutationResult.current.mutate(); - - await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - expect(data[0].name).toBe('Manual Rollback'); - - queryNormalizer.unsubscribe(); - }); - }); -}); diff --git a/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx b/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx deleted file mode 100644 index 048372b..0000000 --- a/src/react-query/normalize/__tests__/threeLevelIntegration.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -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'; -import {useQueryNormalizer} from '../QueryNormalizerProvider'; - -describe('Three-Level Configuration Integration', () => { - let queryClient: QueryClient; - let dataManager: ClientDataManager; - - beforeEach(() => { - dataManager = new ClientDataManager({}, true); - queryClient = dataManager.queryClient; - }); - - afterEach(() => { - queryClient.clear(); - }); - - describe('Level 1: Provider (global)', () => { - it('should use global configuration from Provider', async () => { - const globalGetKey = jest.fn((obj) => `global:${obj.id}`); - - // Create dataManager with custom normalizer config - const customDataManager = new ClientDataManager( - {}, - { - normalizerConfig: { - getNormalizationObjectKey: globalGetKey, - }, - }, - ); - - const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - - ); - - const dataSource = makePlainQueryDataSource({ - name: 'users', - fetch: async () => [{id: '1', name: 'User 1'}], - }); - - const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); - - const {result: queryResult} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); - - await waitFor(() => expect(queryResult.current.status).toBe('success')); - - const normalized = normalizerResult.current.getNormalizedData(); - - expect(normalized.objects['@@global:1']).toBeDefined(); - expect(globalGetKey).toHaveBeenCalled(); - }); - - it('global getArrayType should work', async () => { - const globalGetArrayType = jest.fn(({arrayKey}) => `global:${arrayKey}`); - - // Create dataManager with custom normalizer config - const customDataManager = new ClientDataManager( - {}, - { - normalizerConfig: { - getArrayType: globalGetArrayType, - }, - }, - ); - - const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - - ); - - const dataSource = makePlainQueryDataSource({ - name: 'items', - fetch: async () => ({ - items: [{id: '1', name: 'Item 1'}], - }), - }); - - const {result} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); - - await waitFor(() => expect(result.current.status).toBe('success')); - - // Global function should be called - expect(globalGetArrayType).toHaveBeenCalled(); - }); - }); - - describe('Enable/disable normalization at different levels', () => { - it('Hook can disable normalization even if enabled globally', async () => { - const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - - ); - - const dataSource = makePlainQueryDataSource({ - name: 'users', - fetch: async () => [{id: '1', name: 'User 1'}], - }); - - const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); - - const {result: queryResult} = renderHook( - () => - useQueryData( - dataSource, - {}, - { - normalizationConfig: { - normalize: false, // Disable for this query - }, - }, - ), - {wrapper}, - ); - - await waitFor(() => expect(queryResult.current.status).toBe('success')); - - const normalized = normalizerResult.current.getNormalizedData(); - - // Data should NOT be normalized - expect(Object.keys(normalized.objects)).toHaveLength(0); - }); - - it('DataSource can enable normalization if disabled globally', async () => { - const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - - ); - - const dataSource = makePlainQueryDataSource({ - name: 'users', - fetch: async () => [{id: '1', name: 'User 1'}], - options: { - normalizationConfig: { - normalize: true, // Enable for this DataSource - }, - }, - }); - - const {result: normalizerResult} = renderHook(() => useQueryNormalizer(), {wrapper}); - - const {result: queryResult} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); - - await waitFor(() => expect(queryResult.current.status).toBe('success')); - - const normalized = normalizerResult.current.getNormalizedData(); - - // Data SHOULD be normalized - expect(normalized.objects['@@1']).toBeDefined(); - }); - }); -}); diff --git a/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx b/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx deleted file mode 100644 index f2fe202..0000000 --- a/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx +++ /dev/null @@ -1,201 +0,0 @@ -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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - // 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } - - 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/normalize/normalization.ts b/src/react-query/normalize/normalization.ts deleted file mode 100644 index 1f20908..0000000 --- a/src/react-query/normalize/normalization.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type {Data} from '@normy/core'; -import type {QueryClient, QueryKey} from '@tanstack/react-query'; - -import type {Normalizer} from '../../core'; -import type { - DataSourceNormalizerConfig, - OptimisticUpdateConfig, - OptionsNormalizerConfig, -} from '../types/normalizer'; -import {shouldNormalize, shouldUpdateOptimistically} from '../utils/normalize'; - -interface CreateQueryNormalizerOptions { - queryClient: QueryClient; - normalizer: Normalizer; - optimisticUpdate: (mutationData: Data) => void; - normalizerConfig: DataSourceNormalizerConfig; - optimisticUpdateConfig: OptimisticUpdateConfig; -} - -export const createQueryNormalizer = ({ - queryClient, - normalizer, - optimisticUpdate, - normalizerConfig, - optimisticUpdateConfig, -}: CreateQueryNormalizerOptions) => { - const globalNormalize = normalizerConfig.normalize ?? false; - // No point in updating normalized data if normalization is disabled - const globalOptimistic = (globalNormalize && optimisticUpdateConfig.enabled) || 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 { - normalizationConfig?: OptionsNormalizerConfig; - }; - - const queryNormalize = queryOptions?.normalizationConfig?.normalize; - - if (!shouldNormalize(globalNormalize, 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 - | { - normalizationConfig?: OptionsNormalizerConfig; - optimisticUpdateConfig?: OptimisticUpdateConfig; - } - | undefined; - const mutationQueryNormalize = mutationOptions?.normalizationConfig; - const mutationQueryOptimistic = mutationOptions?.optimisticUpdateConfig; - - if ( - !shouldNormalize(globalNormalize, mutationQueryNormalize?.normalize) && - !shouldUpdateOptimistically(globalOptimistic, mutationQueryOptimistic?.enabled) - ) { - return; - } - - 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) { - // Automatic rollbackData calculation - if ( - !context.rollbackData && - (optimisticUpdateConfig.autoCalculateRollback !== false || - mutationQueryOptimistic?.autoCalculateRollback !== false) - ) { - context.rollbackData = normalizer.getCurrentData( - context.optimisticData, - ); - - if ( - optimisticUpdateConfig.devLogging || - mutationQueryOptimistic?.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 (optimisticUpdateConfig.devLogging) { - console.log('[OptimisticUpdate] Rolling back changes'); - } - - optimisticUpdate(context.rollbackData); - } - } - }); - }, - unsubscribe: () => { - unsubscribeQueryCache?.(); - unsubscribeMutationCache?.(); - unsubscribeQueryCache = null; - unsubscribeMutationCache = null; - }, - }; -}; diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index 8b9933f..d1416e7 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -26,7 +26,7 @@ export interface QueryDataAdditionalOptions< export interface QueryCustomOptions { /** Normalization configuration (enable/disable) */ - normalizationConfig?: OptionsNormalizerConfig; + normalize?: boolean; /** Optimistic data update configuration */ - optimisticUpdateConfig?: OptimisticUpdateConfig; + optimistic?: boolean | OptimisticConfig; } diff --git a/src/react-query/utils/__tests__/normalize.test.ts b/src/react-query/utils/__tests__/normalize.test.ts deleted file mode 100644 index a037c6e..0000000 --- a/src/react-query/utils/__tests__/normalize.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {shouldNormalize, shouldUpdateOptimistically} from '../normalize'; - -describe('normalize utils', () => { - describe('shouldNormalize', () => { - it('should return providerConfig if queryConfig is undefined', () => { - expect(shouldNormalize(true, undefined)).toBe(true); - expect(shouldNormalize(false, undefined)).toBe(false); - }); - - it('should return queryConfig if defined', () => { - expect(shouldNormalize(true, false)).toBe(false); - expect(shouldNormalize(false, true)).toBe(true); - }); - }); - - describe('shouldUpdateOptimistically', () => { - it('should return providerConfig if mutationConfig is undefined', () => { - expect(shouldUpdateOptimistically(true, undefined)).toBe(true); - expect(shouldUpdateOptimistically(false, undefined)).toBe(false); - }); - - it('should return mutationConfig if defined', () => { - expect(shouldUpdateOptimistically(true, false)).toBe(false); - expect(shouldUpdateOptimistically(false, true)).toBe(true); - }); - }); - - describe('Edge cases', () => { - it('shouldNormalize should work with boolean false (not falsy)', () => { - // false is a valid value, should not fallback to provider - expect(shouldNormalize(true, false)).toBe(false); - }); - - it('shouldUpdateOptimistically should work with boolean false', () => { - expect(shouldUpdateOptimistically(true, false)).toBe(false); - }); - }); -}); From 57114a5fcddb15ed086a854418a4c69122673fbf Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 2 Dec 2025 21:15:33 +0100 Subject: [PATCH 05/11] fix: fix --- src/core/types/Normalizer.ts | 21 --------------------- src/react-query/ClientDataManager.ts | 1 - src/react-query/types/normalizer.ts | 21 +++++++++++++++++++++ 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts index f477744..092d37f 100644 --- a/src/core/types/Normalizer.ts +++ b/src/core/types/Normalizer.ts @@ -30,24 +30,3 @@ export interface Normalizer { getCurrentData: (newData: T) => T | undefined; log: (...messages: unknown[]) => void; } - -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/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 0e2cbbb..0805c4f 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -14,7 +14,6 @@ import { hasTag, } from '../core'; import type {InvalidateOptions, InvalidateRepeatOptions} from '../core/types/DataManagerOptions'; -import type {QueryNormalizer} from '../core/types/Normalizer'; import type {QueryNormalizer} from './types/normalizer'; import {createQueryNormalizer} from './utils/normalize'; diff --git a/src/react-query/types/normalizer.ts b/src/react-query/types/normalizer.ts index f2ee744..88eab1c 100644 --- a/src/react-query/types/normalizer.ts +++ b/src/react-query/types/normalizer.ts @@ -20,3 +20,24 @@ export interface QueryNormalizer { /** Unsubscribe from QueryCache changes */ unsubscribe: () => void; } + +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; +} From e837838d6865b6a2cc4a7fc66bcad07f6e696e54 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Wed, 3 Dec 2025 10:57:58 +0100 Subject: [PATCH 06/11] fix: add invalidate --- src/react-query/impl/infinite/types.ts | 5 ++--- src/react-query/impl/plain/types.ts | 2 +- src/react-query/types/options.ts | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index 8703f9f..042f302 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -10,7 +10,7 @@ import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; -import type {QueryCustomOptions, QueryDataAdditionalOptions} from '../../types/options'; +import type {QueryDataAdditionalOptions} from '../../types/options'; export type InfiniteQueryObserverExtendedOptions< TQueryFnData = unknown, @@ -27,8 +27,7 @@ export type InfiniteQueryObserverExtendedOptions< InfiniteData, TQueryKey > -> & - QueryCustomOptions; +>; export type InfiniteQueryDataSource = DataSource< QueryDataSourceContext, diff --git a/src/react-query/impl/plain/types.ts b/src/react-query/impl/plain/types.ts index 391e9bf..381898e 100644 --- a/src/react-query/impl/plain/types.ts +++ b/src/react-query/impl/plain/types.ts @@ -9,7 +9,7 @@ import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; -import type {QueryCustomOptions, QueryDataAdditionalOptions} from '../../types/options'; +import type {QueryDataAdditionalOptions} from '../../types/options'; export type QueryObserverExtendedOptions< TQueryFnData = unknown, diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index d1416e7..964552d 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -29,4 +29,6 @@ export interface QueryCustomOptions { normalize?: boolean; /** Optimistic data update configuration */ optimistic?: boolean | OptimisticConfig; + /** Invalidate data configuration */ + invalidate?: boolean; } From bda710fef3b10465e3d8a3e7d65d0bf5bd158ba4 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Wed, 3 Dec 2025 16:29:45 +0100 Subject: [PATCH 07/11] fix: add new function update --- src/react-query/ClientDataManager.ts | 35 ++++++++++++++++++++++++++ src/react-query/impl/infinite/utils.ts | 6 +++++ src/react-query/types/query-meta.d.ts | 12 +++++++++ 3 files changed, 53 insertions(+) create mode 100644 src/react-query/types/query-meta.d.ts diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 0805c4f..718fa9a 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -26,8 +26,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: { @@ -91,6 +94,38 @@ export class ClientDataManager implements DataManager { }); } + 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); + + queriesToUpdate.forEach((query) => { + const queryKey = JSON.parse(query.queryKey) as QueryKey; + + const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); + + const {optimistic, invalidate} = cachedQuery?.meta ?? {}; + + if (optimistic === true) { + this.optimisticUpdate(data); + } else if (optimistic === undefined && globalOptimistic === true) { + this.optimisticUpdate(data); + } + + if (invalidate === true) { + this.invalidateData(data); + } else if (invalidate === undefined && globalInvalidate === true) { + this.invalidateData(data); + } + }); + } + invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions) { return this.invalidateQueries( { 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/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; + }; + } +} From ad18327a319448f37a73a48290c786ea5dd2f8ea Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Wed, 3 Dec 2025 16:40:24 +0100 Subject: [PATCH 08/11] fix: rebase conflict --- src/react-query/types/normalizer.ts | 21 --------------------- src/react-query/types/options.ts | 9 --------- 2 files changed, 30 deletions(-) diff --git a/src/react-query/types/normalizer.ts b/src/react-query/types/normalizer.ts index 88eab1c..f2ee744 100644 --- a/src/react-query/types/normalizer.ts +++ b/src/react-query/types/normalizer.ts @@ -20,24 +20,3 @@ export interface QueryNormalizer { /** Unsubscribe from QueryCache changes */ unsubscribe: () => void; } - -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 964552d..f04eebe 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -23,12 +23,3 @@ export interface QueryDataAdditionalOptions< /** Invalidate data configuration */ invalidate?: boolean; } - -export interface QueryCustomOptions { - /** Normalization configuration (enable/disable) */ - normalize?: boolean; - /** Optimistic data update configuration */ - optimistic?: boolean | OptimisticConfig; - /** Invalidate data configuration */ - invalidate?: boolean; -} From 9923309c357d2566928ac62a213afd152b813c39 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Wed, 3 Dec 2025 16:42:23 +0100 Subject: [PATCH 09/11] fix: fix --- src/react-query/ClientDataManager.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 718fa9a..9112aab 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -103,6 +103,7 @@ export class ClientDataManager implements DataManager { typeof this.normalizerConfig === 'object' ? this.normalizerConfig : {optimistic: false, invalidate: false}; + const queriesToUpdate = this.normalizer.getQueriesToUpdate(data); queriesToUpdate.forEach((query) => { @@ -112,15 +113,11 @@ export class ClientDataManager implements DataManager { const {optimistic, invalidate} = cachedQuery?.meta ?? {}; - if (optimistic === true) { - this.optimisticUpdate(data); - } else if (optimistic === undefined && globalOptimistic === true) { + if (optimistic === true || (optimistic === undefined && globalOptimistic === true)) { this.optimisticUpdate(data); } - if (invalidate === true) { - this.invalidateData(data); - } else if (invalidate === undefined && globalInvalidate === true) { + if (invalidate === true || (invalidate === undefined && globalInvalidate === true)) { this.invalidateData(data); } }); From e70e2741fa9c6b151b9320e9242402a5af86f52f Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Mon, 8 Dec 2025 14:25:02 +0100 Subject: [PATCH 10/11] fix: add check mutaton objects keys --- src/react-query/ClientDataManager.ts | 99 +++- .../__tests__/threeLevelIntegration.test.tsx | 274 +++++++++ .../checkMutationCompleteness.test.ts | 559 ++++++++++++++++++ .../utils/checkMutationObjectsKeys.ts | 105 ++++ src/react-query/utils/parseQueryKey.ts | 5 + 5 files changed, 1021 insertions(+), 21 deletions(-) create mode 100644 src/react-query/utils/__tests__/checkMutationCompleteness.test.ts create mode 100644 src/react-query/utils/checkMutationObjectsKeys.ts create mode 100644 src/react-query/utils/parseQueryKey.ts diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 9112aab..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; @@ -56,41 +58,43 @@ export class ClientDataManager implements DataManager { ); } - optimisticUpdate(mutationData: Data) { + optimisticUpdate(mutationData: Data, queryKey?: QueryKey, queryData?: Data) { if (!this.normalizer) { return; } - const queriesToUpdate = this.normalizer.getQueriesToUpdate(mutationData); - - queriesToUpdate.forEach((query) => { - const queryKey = JSON.parse(query.queryKey) as QueryKey; + if (queryKey && queryData) { + this.optimisticUpdateQuery(queryKey, queryData); - const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); + return; + } - const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; - const isInvalidated = cachedQuery?.state.isInvalidated; - const error = cachedQuery?.state.error; - const status = cachedQuery?.state.status; + const queriesToUpdate = this.normalizer.getQueriesToUpdate(mutationData); - this.queryClient.setQueryData(queryKey, () => query.data, { - updatedAt: dataUpdatedAt, - }); + queriesToUpdate.forEach((query) => { + const parsedQueryKey = parseQueryKey(query.queryKey); - cachedQuery?.setState({isInvalidated, error, status}); + this.optimisticUpdateQuery(parsedQueryKey, query.data); }); } - invalidateData(data: Data): void { + invalidateData(data: Data, queryKey?: QueryKey): void { if (!this.normalizer) { return; } + if (queryKey) { + this.invalidateQuery(queryKey); + + return; + } + const queriesToUpdate = this.normalizer.getQueriesToUpdate(data); queriesToUpdate.forEach((query) => { - const queryKey = JSON.parse(query.queryKey) as QueryKey; - this.queryClient.invalidateQueries({queryKey}); + const parsedQueryKey = parseQueryKey(query.queryKey); + + this.invalidateQuery(parsedQueryKey); }); } @@ -106,19 +110,45 @@ export class ClientDataManager implements DataManager { 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; + const parsedQueryKey = parseQueryKey(query.queryKey); - const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); + const cachedQuery = this.queryClient.getQueryCache().find({queryKey: parsedQueryKey}); const {optimistic, invalidate} = cachedQuery?.meta ?? {}; if (optimistic === true || (optimistic === undefined && globalOptimistic === true)) { - this.optimisticUpdate(data); + this.optimisticUpdate(data, parsedQueryKey, query.data); } if (invalidate === true || (invalidate === undefined && globalInvalidate === true)) { - this.invalidateData(data); + this.invalidateData(data, parsedQueryKey); } }); } @@ -233,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..f3b6528 100644 --- a/src/react-query/__tests__/threeLevelIntegration.test.tsx +++ b/src/react-query/__tests__/threeLevelIntegration.test.tsx @@ -281,4 +281,278 @@ describe('Normalization Configuration Integration', () => { dmWithBoth.queryClient.clear(); }); }); + + describe('ClientDataManager.update()', () => { + it('should apply optimistic update when configured and data has same keys', async () => { + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + }, + }); + + dm.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + const initialData = [{id: '1', name: 'User 1', email: 'user@test.com'}]; + + dm.queryClient.setQueryData(queryKey, initialData); + dm.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Update with same keys - should apply optimistic update + dm.update({id: '1', name: 'Updated', email: 'updated@test.com'}); + + const data = dm.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + email: string; + }>; + + expect(data[0].name).toBe('Updated'); + expect(data[0].email).toBe('updated@test.com'); + + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + + it('should apply optimistic update when mutation has more keys (only existing keys updated)', async () => { + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: 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); + + // Update with more keys - normy will only update existing keys + dm.update({id: '1', name: 'Updated', email: 'new@test.com', age: 30}); + + const data = dm.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + + // Only existing keys are updated, new keys are not added by normy + expect(data[0].name).toBe('Updated'); + expect(data[0].id).toBe('1'); + + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + + 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 not modify data when normalizer is not configured', () => { + const dm = new ClientDataManager({}); + + // Should not throw + dm.update({id: '1', name: 'Test'}); + + expect(dm.normalizer).toBeUndefined(); + }); + + 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(); + }); + + it('should NOT trigger refetch when mutation has same keys as existing data', async () => { + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: 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 + const cache = dm.queryClient.getQueryCache().find({queryKey}); + cache?.setState({status: 'success', fetchStatus: 'idle', isInvalidated: false}); + + // Update with same keys - should NOT trigger refetch via checkMutationObjectsKeys + dm.update({id: '1', name: 'Updated'}); + + const data = dm.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + + // Data should be optimistically updated + expect(data[0].name).toBe('Updated'); + + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + + it('should NOT trigger refetch when mutation has more keys than existing data', async () => { + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + }, + }); + + dm.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + // Existing data has 2 keys + const initialData = [{id: '1', name: 'User 1'}]; + + dm.queryClient.setQueryData(queryKey, initialData); + dm.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Update with more keys (3 keys including new email) + // This should NOT trigger refetch - more keys = more complete data + dm.update({id: '1', name: 'Updated', email: 'new@test.com'}); + + const data = dm.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + + // Data should be updated (only existing keys) + expect(data[0].name).toBe('Updated'); + + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + + it('should handle update when object is not in normalized store', () => { + const dm = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + }, + }); + + dm.queryNormalizer!.subscribe(); + + // No queries set up - object is not in store + // This should not throw and should not trigger any updates + dm.update({id: 'non-existent', name: 'Test'}); + + // No error should occur + expect(true).toBe(true); + + dm.queryNormalizer!.unsubscribe(); + dm.queryClient.clear(); + }); + }); }); diff --git a/src/react-query/utils/__tests__/checkMutationCompleteness.test.ts b/src/react-query/utils/__tests__/checkMutationCompleteness.test.ts new file mode 100644 index 0000000..3ec3a2f --- /dev/null +++ b/src/react-query/utils/__tests__/checkMutationCompleteness.test.ts @@ -0,0 +1,559 @@ +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: [], + }); + }); + + it('should return needsRefetch: false for Date object', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys(new Date(), 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 more keys than normalized data', () => { + it('should return needsRefetch: false - more keys means more complete data', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test'}, + }); + + const result = checkMutationObjectsKeys( + {id: '1', name: 'updated', newField: 'value'}, + normalizer, + ); + + // Changed: more keys = more complete, no refetch needed + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + }); + + 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'); + }); + + it('should return needsRefetch: false when same number of keys but different', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', oldField: 'old'}, + }); + + // Same number of keys, mutation is not "fewer" + const result = checkMutationObjectsKeys({id: '1', newField: 'new'}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + }); + + 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 only report objects with fewer keys', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'first', extra: 'data'}, + '@@2': {id: '2', name: 'second'}, + }); + + const result = checkMutationObjectsKeys( + [ + {id: '1', name: 'updated'}, // missing 'extra' + {id: '2', name: 'updated'}, // same keys + ], + normalizer, + ); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(1); + expect(result.details[0].id).toBe('1'); + }); + + it('should handle mixed array with objects and primitives', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test', extra: 'field'}, + }); + + const result = checkMutationObjectsKeys( + [null, {id: '1', name: 'updated'}, 'string', 123], + normalizer, + ); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(1); + }); + + 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'); + }); + + it('should return empty when custom key returns undefined', () => { + const normalizer = createMockNormalizer({}); + + const result = checkMutationObjectsKeys({someField: 'value'}, normalizer, { + getNormalizationObjectKey: () => undefined, + }); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + }); + + 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 deeply nested structure (only first level objects)', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'parent', child: {nested: 'data'}}, + }); + + // Nested objects without id are not extracted + const result = checkMutationObjectsKeys( + { + id: '1', + name: 'parent', + child: {nested: 'updated'}, + }, + normalizer, + ); + + expect(result.needsRefetch).toBe(false); + }); + + 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: [], + }); + }); + + it('should handle object with only id matching normalized with extra keys', () => { + const normalizer = createMockNormalizer({ + '@@1': { + id: '1', + field1: 'a', + field2: 'b', + field3: 'c', + field4: 'd', + field5: 'e', + }, + }); + + const result = checkMutationObjectsKeys({id: '1'}, normalizer); + + expect(result.needsRefetch).toBe(true); + expect(result.details[0].missingKeys).toHaveLength(5); + }); + }); + + describe('when object has no id field', () => { + it('should skip object without id in mutation', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test', extra: 'field'}, + }); + + const result = checkMutationObjectsKeys({name: 'no-id-object'}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should skip objects without id in array', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test', extra: 'field'}, + }); + + const result = checkMutationObjectsKeys( + [{name: 'no-id-1'}, {name: 'no-id-2'}, {title: 'another-no-id'}], + normalizer, + ); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should process only objects with id in mixed array', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test', extra: 'field'}, + '@@2': {id: '2', name: 'test2', extra: 'field2'}, + }); + + const result = checkMutationObjectsKeys( + [ + {name: 'no-id'}, + {id: '1', name: 'has-id'}, // missing 'extra' + {title: 'also-no-id'}, + {id: '2', name: 'has-id-2', extra: 'field2'}, // same keys + ], + normalizer, + ); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(1); + expect(result.details[0].id).toBe('1'); + }); + + it('should handle nested object without id inside object with id', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'parent', nested: {value: 'data'}}, + }); + + const result = checkMutationObjectsKeys( + { + id: '1', + name: 'parent', + nested: {value: 'updated', newKey: 'added'}, // nested has no id + }, + normalizer, + ); + + // Only first level keys are compared, nested object is treated as value + expect(result.needsRefetch).toBe(false); + }); + + it('should handle object with id: null', () => { + const normalizer = createMockNormalizer({ + '@@1': {id: '1', name: 'test'}, + }); + + const result = checkMutationObjectsKeys({id: null, name: 'test'}, normalizer); + + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should handle object with id: empty string', () => { + const normalizer = createMockNormalizer({ + '@@': {id: '', name: 'empty-id', extra: 'field'}, + }); + + const result = checkMutationObjectsKeys({id: '', name: 'updated'}, normalizer); + + // Empty string is falsy, so object is skipped + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should handle complex structure - top level object without id is not traversed', () => { + const normalizer = createMockNormalizer({ + '@@user-1': {id: 'user-1', name: 'John', email: 'john@test.com'}, + '@@post-1': {id: 'post-1', title: 'Post', content: 'Content'}, + }); + + const result = checkMutationObjectsKeys( + { + // This top-level object has no id, so its properties are NOT traversed + user: {id: 'user-1', name: 'John'}, + posts: [{id: 'post-1', title: 'Updated'}, {category: 'tech'}], + metadata: {timestamp: Date.now()}, + }, + normalizer, + ); + + // extractNormalizableObjects does NOT traverse object properties + // Since the top-level object has no id, nothing is extracted + expect(result).toEqual({ + needsRefetch: false, + details: [], + }); + }); + + it('should extract from array at top level', () => { + const normalizer = createMockNormalizer({ + '@@post-1': {id: 'post-1', title: 'Post', content: 'Content'}, + '@@post-2': {id: 'post-2', title: 'Post 2', content: 'Content 2'}, + }); + + // Array at top level - items ARE extracted + const result = checkMutationObjectsKeys( + [ + {id: 'post-1', title: 'Updated'}, // missing content + {id: 'post-2', title: 'Updated 2'}, // missing content + {category: 'tech'}, // no id - skipped + ], + normalizer, + ); + + expect(result.needsRefetch).toBe(true); + expect(result.details).toHaveLength(2); + expect(result.details[0].id).toBe('post-1'); + expect(result.details[1].id).toBe('post-2'); + }); + + it('should NOT extract objects from object properties (only from arrays)', () => { + const normalizer = createMockNormalizer({ + '@@nested-1': {id: 'nested-1', name: 'test', extra: 'field'}, + }); + + // Object nested as property value is NOT extracted + const result = checkMutationObjectsKeys( + { + wrapper: { + nested: {id: 'nested-1', name: 'updated'}, // this is NOT extracted + }, + }, + normalizer, + ); + + // The nested object is not extracted because extractNormalizableObjects + // only traverses arrays, not object properties + 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; +}; From a69e88bae1842e277f30445479ae04c76da468b0 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Mon, 8 Dec 2025 15:00:49 +0100 Subject: [PATCH 11/11] fix: fix test and add plain meta --- .../__tests__/threeLevelIntegration.test.tsx | 162 ----- src/react-query/impl/plain/utils.ts | 6 + .../checkMutationCompleteness.test.ts | 559 ------------------ .../checkMutationObjectsKeys.test.ts | 267 +++++++++ 4 files changed, 273 insertions(+), 721 deletions(-) delete mode 100644 src/react-query/utils/__tests__/checkMutationCompleteness.test.ts create mode 100644 src/react-query/utils/__tests__/checkMutationObjectsKeys.test.ts diff --git a/src/react-query/__tests__/threeLevelIntegration.test.tsx b/src/react-query/__tests__/threeLevelIntegration.test.tsx index f3b6528..2acbd0d 100644 --- a/src/react-query/__tests__/threeLevelIntegration.test.tsx +++ b/src/react-query/__tests__/threeLevelIntegration.test.tsx @@ -283,70 +283,6 @@ describe('Normalization Configuration Integration', () => { }); describe('ClientDataManager.update()', () => { - it('should apply optimistic update when configured and data has same keys', async () => { - const dm = new ClientDataManager({ - normalizerConfig: { - devLogging: false, - optimistic: true, - }, - }); - - dm.queryNormalizer!.subscribe(); - - const queryKey = ['users']; - const initialData = [{id: '1', name: 'User 1', email: 'user@test.com'}]; - - dm.queryClient.setQueryData(queryKey, initialData); - dm.normalizer!.setQuery(JSON.stringify(queryKey), initialData); - - // Update with same keys - should apply optimistic update - dm.update({id: '1', name: 'Updated', email: 'updated@test.com'}); - - const data = dm.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - email: string; - }>; - - expect(data[0].name).toBe('Updated'); - expect(data[0].email).toBe('updated@test.com'); - - dm.queryNormalizer!.unsubscribe(); - dm.queryClient.clear(); - }); - - it('should apply optimistic update when mutation has more keys (only existing keys updated)', async () => { - const dm = new ClientDataManager({ - normalizerConfig: { - devLogging: false, - optimistic: 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); - - // Update with more keys - normy will only update existing keys - dm.update({id: '1', name: 'Updated', email: 'new@test.com', age: 30}); - - const data = dm.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - - // Only existing keys are updated, new keys are not added by normy - expect(data[0].name).toBe('Updated'); - expect(data[0].id).toBe('1'); - - dm.queryNormalizer!.unsubscribe(); - dm.queryClient.clear(); - }); - it('should work with array of objects for optimistic update', async () => { const dm = new ClientDataManager({ normalizerConfig: { @@ -415,15 +351,6 @@ describe('Normalization Configuration Integration', () => { dm.queryClient.clear(); }); - it('should not modify data when normalizer is not configured', () => { - const dm = new ClientDataManager({}); - - // Should not throw - dm.update({id: '1', name: 'Test'}); - - expect(dm.normalizer).toBeUndefined(); - }); - 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) @@ -465,94 +392,5 @@ describe('Normalization Configuration Integration', () => { dm.queryNormalizer!.unsubscribe(); dm.queryClient.clear(); }); - - it('should NOT trigger refetch when mutation has same keys as existing data', async () => { - const dm = new ClientDataManager({ - normalizerConfig: { - devLogging: false, - optimistic: 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 - const cache = dm.queryClient.getQueryCache().find({queryKey}); - cache?.setState({status: 'success', fetchStatus: 'idle', isInvalidated: false}); - - // Update with same keys - should NOT trigger refetch via checkMutationObjectsKeys - dm.update({id: '1', name: 'Updated'}); - - const data = dm.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - - // Data should be optimistically updated - expect(data[0].name).toBe('Updated'); - - dm.queryNormalizer!.unsubscribe(); - dm.queryClient.clear(); - }); - - it('should NOT trigger refetch when mutation has more keys than existing data', async () => { - const dm = new ClientDataManager({ - normalizerConfig: { - devLogging: false, - optimistic: true, - }, - }); - - dm.queryNormalizer!.subscribe(); - - const queryKey = ['users']; - // Existing data has 2 keys - const initialData = [{id: '1', name: 'User 1'}]; - - dm.queryClient.setQueryData(queryKey, initialData); - dm.normalizer!.setQuery(JSON.stringify(queryKey), initialData); - - // Update with more keys (3 keys including new email) - // This should NOT trigger refetch - more keys = more complete data - dm.update({id: '1', name: 'Updated', email: 'new@test.com'}); - - const data = dm.queryClient.getQueryData(queryKey) as Array<{ - id: string; - name: string; - }>; - - // Data should be updated (only existing keys) - expect(data[0].name).toBe('Updated'); - - dm.queryNormalizer!.unsubscribe(); - dm.queryClient.clear(); - }); - - it('should handle update when object is not in normalized store', () => { - const dm = new ClientDataManager({ - normalizerConfig: { - devLogging: false, - optimistic: true, - }, - }); - - dm.queryNormalizer!.subscribe(); - - // No queries set up - object is not in store - // This should not throw and should not trigger any updates - dm.update({id: 'non-existent', name: 'Test'}); - - // No error should occur - expect(true).toBe(true); - - dm.queryNormalizer!.unsubscribe(); - dm.queryClient.clear(); - }); }); }); 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/utils/__tests__/checkMutationCompleteness.test.ts b/src/react-query/utils/__tests__/checkMutationCompleteness.test.ts deleted file mode 100644 index 3ec3a2f..0000000 --- a/src/react-query/utils/__tests__/checkMutationCompleteness.test.ts +++ /dev/null @@ -1,559 +0,0 @@ -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: [], - }); - }); - - it('should return needsRefetch: false for Date object', () => { - const normalizer = createMockNormalizer({}); - - const result = checkMutationObjectsKeys(new Date(), 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 more keys than normalized data', () => { - it('should return needsRefetch: false - more keys means more complete data', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'test'}, - }); - - const result = checkMutationObjectsKeys( - {id: '1', name: 'updated', newField: 'value'}, - normalizer, - ); - - // Changed: more keys = more complete, no refetch needed - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - }); - - 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'); - }); - - it('should return needsRefetch: false when same number of keys but different', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', oldField: 'old'}, - }); - - // Same number of keys, mutation is not "fewer" - const result = checkMutationObjectsKeys({id: '1', newField: 'new'}, normalizer); - - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - }); - - 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 only report objects with fewer keys', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'first', extra: 'data'}, - '@@2': {id: '2', name: 'second'}, - }); - - const result = checkMutationObjectsKeys( - [ - {id: '1', name: 'updated'}, // missing 'extra' - {id: '2', name: 'updated'}, // same keys - ], - normalizer, - ); - - expect(result.needsRefetch).toBe(true); - expect(result.details).toHaveLength(1); - expect(result.details[0].id).toBe('1'); - }); - - it('should handle mixed array with objects and primitives', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'test', extra: 'field'}, - }); - - const result = checkMutationObjectsKeys( - [null, {id: '1', name: 'updated'}, 'string', 123], - normalizer, - ); - - expect(result.needsRefetch).toBe(true); - expect(result.details).toHaveLength(1); - }); - - 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'); - }); - - it('should return empty when custom key returns undefined', () => { - const normalizer = createMockNormalizer({}); - - const result = checkMutationObjectsKeys({someField: 'value'}, normalizer, { - getNormalizationObjectKey: () => undefined, - }); - - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - }); - - 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 deeply nested structure (only first level objects)', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'parent', child: {nested: 'data'}}, - }); - - // Nested objects without id are not extracted - const result = checkMutationObjectsKeys( - { - id: '1', - name: 'parent', - child: {nested: 'updated'}, - }, - normalizer, - ); - - expect(result.needsRefetch).toBe(false); - }); - - 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: [], - }); - }); - - it('should handle object with only id matching normalized with extra keys', () => { - const normalizer = createMockNormalizer({ - '@@1': { - id: '1', - field1: 'a', - field2: 'b', - field3: 'c', - field4: 'd', - field5: 'e', - }, - }); - - const result = checkMutationObjectsKeys({id: '1'}, normalizer); - - expect(result.needsRefetch).toBe(true); - expect(result.details[0].missingKeys).toHaveLength(5); - }); - }); - - describe('when object has no id field', () => { - it('should skip object without id in mutation', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'test', extra: 'field'}, - }); - - const result = checkMutationObjectsKeys({name: 'no-id-object'}, normalizer); - - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - - it('should skip objects without id in array', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'test', extra: 'field'}, - }); - - const result = checkMutationObjectsKeys( - [{name: 'no-id-1'}, {name: 'no-id-2'}, {title: 'another-no-id'}], - normalizer, - ); - - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - - it('should process only objects with id in mixed array', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'test', extra: 'field'}, - '@@2': {id: '2', name: 'test2', extra: 'field2'}, - }); - - const result = checkMutationObjectsKeys( - [ - {name: 'no-id'}, - {id: '1', name: 'has-id'}, // missing 'extra' - {title: 'also-no-id'}, - {id: '2', name: 'has-id-2', extra: 'field2'}, // same keys - ], - normalizer, - ); - - expect(result.needsRefetch).toBe(true); - expect(result.details).toHaveLength(1); - expect(result.details[0].id).toBe('1'); - }); - - it('should handle nested object without id inside object with id', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'parent', nested: {value: 'data'}}, - }); - - const result = checkMutationObjectsKeys( - { - id: '1', - name: 'parent', - nested: {value: 'updated', newKey: 'added'}, // nested has no id - }, - normalizer, - ); - - // Only first level keys are compared, nested object is treated as value - expect(result.needsRefetch).toBe(false); - }); - - it('should handle object with id: null', () => { - const normalizer = createMockNormalizer({ - '@@1': {id: '1', name: 'test'}, - }); - - const result = checkMutationObjectsKeys({id: null, name: 'test'}, normalizer); - - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - - it('should handle object with id: empty string', () => { - const normalizer = createMockNormalizer({ - '@@': {id: '', name: 'empty-id', extra: 'field'}, - }); - - const result = checkMutationObjectsKeys({id: '', name: 'updated'}, normalizer); - - // Empty string is falsy, so object is skipped - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - - it('should handle complex structure - top level object without id is not traversed', () => { - const normalizer = createMockNormalizer({ - '@@user-1': {id: 'user-1', name: 'John', email: 'john@test.com'}, - '@@post-1': {id: 'post-1', title: 'Post', content: 'Content'}, - }); - - const result = checkMutationObjectsKeys( - { - // This top-level object has no id, so its properties are NOT traversed - user: {id: 'user-1', name: 'John'}, - posts: [{id: 'post-1', title: 'Updated'}, {category: 'tech'}], - metadata: {timestamp: Date.now()}, - }, - normalizer, - ); - - // extractNormalizableObjects does NOT traverse object properties - // Since the top-level object has no id, nothing is extracted - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - - it('should extract from array at top level', () => { - const normalizer = createMockNormalizer({ - '@@post-1': {id: 'post-1', title: 'Post', content: 'Content'}, - '@@post-2': {id: 'post-2', title: 'Post 2', content: 'Content 2'}, - }); - - // Array at top level - items ARE extracted - const result = checkMutationObjectsKeys( - [ - {id: 'post-1', title: 'Updated'}, // missing content - {id: 'post-2', title: 'Updated 2'}, // missing content - {category: 'tech'}, // no id - skipped - ], - normalizer, - ); - - expect(result.needsRefetch).toBe(true); - expect(result.details).toHaveLength(2); - expect(result.details[0].id).toBe('post-1'); - expect(result.details[1].id).toBe('post-2'); - }); - - it('should NOT extract objects from object properties (only from arrays)', () => { - const normalizer = createMockNormalizer({ - '@@nested-1': {id: 'nested-1', name: 'test', extra: 'field'}, - }); - - // Object nested as property value is NOT extracted - const result = checkMutationObjectsKeys( - { - wrapper: { - nested: {id: 'nested-1', name: 'updated'}, // this is NOT extracted - }, - }, - normalizer, - ); - - // The nested object is not extracted because extractNormalizableObjects - // only traverses arrays, not object properties - expect(result).toEqual({ - needsRefetch: false, - details: [], - }); - }); - }); -}); 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: [], + }); + }); + }); +});