From 61e20dd74a0f021155a5c5762d2ceab4147511b3 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 21 Oct 2025 10:02:57 +0200 Subject: [PATCH 1/8] feat: add normalize --- README-ru.md | 281 +++++++++ README.md | 281 +++++++++ package-lock.json | 34 +- package.json | 1 + src/react-query/DataSourceProvider.tsx | 30 + src/react-query/impl/infinite/types.ts | 6 +- src/react-query/impl/plain/types.ts | 6 +- src/react-query/index.ts | 11 + .../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/normalizer.ts | 20 + src/react-query/types/options.ts | 5 + .../utils/__tests__/normalize.test.ts | 94 +++ src/react-query/utils/normalize.ts | 23 + 20 files changed, 2560 insertions(+), 22 deletions(-) create mode 100644 src/react-query/DataSourceProvider.tsx 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/types/normalizer.ts create mode 100644 src/react-query/utils/__tests__/normalize.test.ts create mode 100644 src/react-query/utils/normalize.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/package-lock.json b/package-lock.json index 2a4b891..3c02271 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { + "@normy/core": "^0.14.0", "utility-types": "^3.11.0" }, "devDependencies": { @@ -2995,7 +2996,6 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -6176,6 +6176,15 @@ "node": ">=12.4.0" } }, + "node_modules/@normy/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@normy/core/-/core-0.14.0.tgz", + "integrity": "sha512-3I0S62BNEIlvSL10TutHsLvTxguRoXEjs6b/udSUnxlKFVgrAIdW4F4tJDiZsJQrWRi6A6V3Eo5Dzji3zyn40Q==", + "dependencies": { + "@babel/runtime": "^7.23.5", + "deepmerge": "4.3.1" + } + }, "node_modules/@okikio/sharedworker": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@okikio/sharedworker/-/sharedworker-1.1.0.tgz", @@ -8764,17 +8773,6 @@ "@swc/counter": "^0.1.3" } }, - "node_modules/@tanstack/query-core": { - "version": "5.71.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.71.1.tgz", - "integrity": "sha512-4+ZswCHOfJX+ikhXNoocamTUmJcHtB+Ljjz/oJkC7/eKB5IrzEwR4vEwZUENiPi+wISucJHR5TUbuuJ26w3kdQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/react-query": { "version": "5.71.1", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.71.1.tgz", @@ -8792,6 +8790,16 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query/node_modules/@tanstack/query-core": { + "version": "5.71.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.71.1.tgz", + "integrity": "sha512-4+ZswCHOfJX+ikhXNoocamTUmJcHtB+Ljjz/oJkC7/eKB5IrzEwR4vEwZUENiPi+wISucJHR5TUbuuJ26w3kdQ==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -12745,7 +12753,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -22261,7 +22268,6 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, "license": "MIT" }, "node_modules/regenerator-transform": { diff --git a/package.json b/package.json index e957da6..383cabb 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@normy/core": "^0.14.0", "utility-types": "^3.11.0" }, "devDependencies": { diff --git a/src/react-query/DataSourceProvider.tsx b/src/react-query/DataSourceProvider.tsx new file mode 100644 index 0000000..2b299dd --- /dev/null +++ b/src/react-query/DataSourceProvider.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import {QueryClientProvider} from '@tanstack/react-query'; + +import {DataManagerContext} from '../react/DataManagerContext'; + +import type {ClientDataManager} from './ClientDataManager'; +import type {QueryNormalizerProviderProps} from './normalize/QueryNormalizerProvider'; +import {QueryNormalizerProvider} from './normalize/QueryNormalizerProvider'; + +export interface DataSourceProviderProps extends Omit { + /** Pass ClientDataManager to use its queryClient in QueryNormalizerProvider */ + dataManager: ClientDataManager; +} + +export const DataSourceProvider: React.FC = ({ + children, + dataManager, + ...restProps +}) => { + return ( + + + + {children} + + + + ); +}; diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index a639d7f..2e4e750 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -19,15 +19,13 @@ export type InfiniteQueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, -> = Overwrite< - InfiniteQueryObserverOptions, +> = InfiniteQueryObserverOptions & QueryDataAdditionalOptions< TQueryFnData, TError, InfiniteData, TQueryKey - > ->; + >; export type InfiniteQueryDataSource = DataSource< QueryDataSourceContext, diff --git a/src/react-query/impl/plain/types.ts b/src/react-query/impl/plain/types.ts index c171273..9c6ae0b 100644 --- a/src/react-query/impl/plain/types.ts +++ b/src/react-query/impl/plain/types.ts @@ -18,10 +18,8 @@ export type QueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = never, -> = Overwrite< - QueryObserverOptions, - QueryDataAdditionalOptions ->; +> = QueryObserverOptions & + QueryDataAdditionalOptions; export type PlainQueryDataSource = DataSource< QueryDataSourceContext, diff --git a/src/react-query/index.ts b/src/react-query/index.ts index 306faf1..0841b8c 100644 --- a/src/react-query/index.ts +++ b/src/react-query/index.ts @@ -21,3 +21,14 @@ export {getProgressiveRefetch} from './utils/getProgressiveRefetch'; export type {ClientDataManagerConfig} from './ClientDataManager'; export {ClientDataManager} from './ClientDataManager'; + +export {DataSourceProvider} from './DataSourceProvider'; + +export {updateQueriesFromMutationData} from './normalize/normalization'; +export {QueryNormalizerProvider, useQueryNormalizer} from './normalize/QueryNormalizerProvider'; +export type { + OptimisticUpdateConfig, + DataSourceNormalizerConfig, + OptionsNormalizerConfig, + Normalizer, +} from './types/normalizer'; 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/normalizer.ts b/src/react-query/types/normalizer.ts new file mode 100644 index 0000000..9279f91 --- /dev/null +++ b/src/react-query/types/normalizer.ts @@ -0,0 +1,20 @@ +import type {NormalizerConfig, createNormalizer} from '@normy/core'; + +export 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; +} + +export interface OptionsNormalizerConfig { + /** Whether normalization is enabled, defaults to false */ + normalize?: boolean; +} + +export type DataSourceNormalizerConfig = NormalizerConfig & OptionsNormalizerConfig; + +/** Type for normalizer instance returned by createNormalizer */ +export type Normalizer = ReturnType; diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index 140f0b6..e5b537f 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -1,5 +1,6 @@ import type {DefaultError, QueryKey} from '@tanstack/react-query'; +import type {OptimisticUpdateConfig, OptionsNormalizerConfig} from './normalizer'; import type {RefetchInterval} from './refetch-interval'; export interface QueryDataAdditionalOptions< @@ -9,6 +10,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); + }); + }); +}); diff --git a/src/react-query/utils/normalize.ts b/src/react-query/utils/normalize.ts new file mode 100644 index 0000000..73d44e5 --- /dev/null +++ b/src/react-query/utils/normalize.ts @@ -0,0 +1,23 @@ +/** Checks if data should be normalized */ +export const shouldNormalize = ( + providerConfig: boolean, + queryConfig: boolean | undefined, +): boolean => { + if (queryConfig !== undefined) { + return queryConfig; + } + + // Use setting from Provider + return providerConfig; +}; + +/** Checks if data should be optimistically updated */ +export const shouldOptimisticallyUpdate = ( + providerConfig: boolean, + mutationConfig: boolean | undefined, +): boolean => { + if (mutationConfig !== undefined) { + return mutationConfig; + } + return providerConfig; +}; From 20d7473a8ac9e9596cec0e59cba916429866d1fa Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 21 Oct 2025 10:38:33 +0200 Subject: [PATCH 2/8] fix: types --- src/react-query/impl/infinite/types.ts | 9 ++++++--- src/react-query/impl/plain/types.ts | 9 ++++++--- src/react-query/types/options.ts | 11 +++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index 2e4e750..faedd4a 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -10,7 +10,7 @@ import type {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, @@ -19,13 +19,16 @@ export type InfiniteQueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, -> = InfiniteQueryObserverOptions & +> = Overwrite< + InfiniteQueryObserverOptions, QueryDataAdditionalOptions< TQueryFnData, 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 9c6ae0b..d32ab4b 100644 --- a/src/react-query/impl/plain/types.ts +++ b/src/react-query/impl/plain/types.ts @@ -9,7 +9,7 @@ import type {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, @@ -18,8 +18,11 @@ export type QueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = never, -> = QueryObserverOptions & - QueryDataAdditionalOptions; +> = Overwrite< + QueryObserverOptions, + QueryDataAdditionalOptions +> & + QueryCustomOptions; export type PlainQueryDataSource = DataSource< QueryDataSourceContext, diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index e5b537f..348817d 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -10,13 +10,16 @@ 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. */ enabled?: boolean; } + +export interface QueryCustomOptions { + /** Normalization configuration (enable/disable) */ + normalizationConfig?: OptionsNormalizerConfig; + /** Optimistic data update configuration */ + optimisticUpdateConfig?: OptimisticUpdateConfig; +} From 93ffa1afb0216a6e2d449fe75eb59d280cae3cf6 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Sat, 22 Nov 2025 16:35:30 +0100 Subject: [PATCH 3/8] feat: add client normalize --- README-ru.md | 169 +++++++++---- README.md | 169 +++++++++---- src/core/index.ts | 1 + src/core/types/DataManager.ts | 9 + src/core/types/Normalizer.ts | 26 ++ src/react-query/ClientDataManager.ts | 85 ++++++- src/react-query/DataSourceProvider.tsx | 8 +- .../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 +---- src/react-query/types/normalizer.ts | 5 +- .../utils/__tests__/normalize.test.ts | 72 +----- src/react-query/utils/normalize.ts | 2 +- src/react/DataManagerProvider.tsx | 19 ++ .../__tests__/DataManagerContext.test.tsx | 19 +- src/react/index.ts | 2 + 21 files changed, 912 insertions(+), 462 deletions(-) create mode 100644 src/core/types/Normalizer.ts create mode 100644 src/react/DataManagerProvider.tsx 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/core/index.ts b/src/core/index.ts index 1ce648f..23976e4 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -18,6 +18,7 @@ export type { export type {DataManager} from './types/DataManager'; export type {DataLoaderStatus} from './types/DataLoaderStatus'; export type {InvalidateRepeatOptions, InvalidateOptions} from './types/DataManagerOptions'; +export type {Normalizer, NormalizerConfig, NormalizerClientConfig} from './types/Normalizer'; export {idle} from './constants'; diff --git a/src/core/types/DataManager.ts b/src/core/types/DataManager.ts index 3228cbe..9ff3f7e 100644 --- a/src/core/types/DataManager.ts +++ b/src/core/types/DataManager.ts @@ -1,7 +1,16 @@ +import type {Data} from '@normy/core'; + import type {InvalidateOptions} from './DataManagerOptions'; import type {AnyDataSource, DataSourceParams, DataSourceTag} from './DataSource'; +import type {Normalizer} from './Normalizer'; export interface DataManager { + normalizer?: Normalizer; + + optimisticUpdate(mutationData: Data): void; + + automaticInvalidate(data: Data): void; + invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions): Promise; invalidateTags(tags: DataSourceTag[], invalidateOptions?: InvalidateOptions): Promise; diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts new file mode 100644 index 0000000..e5a7fcd --- /dev/null +++ b/src/core/types/Normalizer.ts @@ -0,0 +1,26 @@ +import type {NormalizerConfig as NormalizeConfigBase} from '@normy/core'; +import type {Data, NormalizedData} from '@normy/core/types/types'; + +export interface NormalizerConfig { + normalizerConfig?: NormalizeConfigBase; + initialNormalizedData?: NormalizedData; +} + +export type NormalizerClientConfig = boolean | NormalizerConfig | undefined; + +export interface Normalizer { + getNormalizedData: () => NormalizedData; + clearNormalizedData: () => void; + setQuery: (queryKey: string, queryData: Data) => void; + removeQuery: (queryKey: string) => void; + getQueriesToUpdate: (mutationData: Data) => { + queryKey: string; + data: Data; + }[]; + getObjectById: (id: string, exampleObject?: T) => T | undefined; + getQueryFragment: (fragment: Data, exampleObject?: T) => T | undefined; + getDependentQueries: (mutationData: Data) => readonly string[]; + getDependentQueriesByIds: (ids: ReadonlyArray) => readonly string[]; + getCurrentData: (newData: T) => T | undefined; + log: (...messages: unknown[]) => void; +} diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index 3cae85b..d1bf780 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -1,4 +1,6 @@ -import type {InvalidateQueryFilters, QueryClientConfig} from '@tanstack/react-query'; +import type {Data} from '@normy/core'; +import {createNormalizer} from '@normy/core'; +import type {InvalidateQueryFilters, QueryClientConfig, QueryKey} from '@tanstack/react-query'; import {QueryClient} from '@tanstack/react-query'; import { @@ -6,6 +8,9 @@ import { type DataManager, type DataSourceParams, type DataSourceTag, + type Normalizer, + type NormalizerClientConfig, + type NormalizerConfig, composeFullKey, hasTag, } from '../core'; @@ -15,8 +20,9 @@ export type ClientDataManagerConfig = QueryClientConfig; export class ClientDataManager implements DataManager { readonly queryClient: QueryClient; + readonly normalizer?: Normalizer | undefined; - constructor(config: ClientDataManagerConfig = {}) { + constructor(config: ClientDataManagerConfig = {}, normalizerConfig?: NormalizerClientConfig) { this.queryClient = new QueryClient({ ...config, defaultOptions: { @@ -31,6 +37,46 @@ export class ClientDataManager implements DataManager { }, }, }); + + this.normalizer = this.initializeNormalize(normalizerConfig); + } + + optimisticUpdate(mutationData: Data) { + if (!this.normalizer) { + return; + } + + const queriesToUpdate = this.normalizer.getQueriesToUpdate(mutationData); + + queriesToUpdate.forEach((query) => { + const queryKey = JSON.parse(query.queryKey) as QueryKey; + const cachedQuery = this.queryClient.getQueryCache().find({queryKey}); + + const dataUpdatedAt = cachedQuery?.state.dataUpdatedAt; + const isInvalidated = cachedQuery?.state.isInvalidated; + const error = cachedQuery?.state.error; + const status = cachedQuery?.state.status; + + this.queryClient.setQueryData(queryKey, () => query.data, { + updatedAt: dataUpdatedAt, + }); + + cachedQuery?.setState({isInvalidated, error, status}); + }); + } + + automaticInvalidate(data: Data): void { + if (!this.normalizer) { + return; + } + + const queriesToUpdate = this.normalizer.getQueriesToUpdate(data); + + queriesToUpdate.forEach((query) => { + const queryKey = JSON.parse(query.queryKey) as QueryKey; + this.queryClient.invalidateQueries({queryKey}); + this.normalizer?.removeQuery(query.queryKey); + }); } invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions) { @@ -129,4 +175,39 @@ export class ClientDataManager implements DataManager { setTimeout(invalidate, repeat.interval * i); } } + + private initializeNormalize(config?: NormalizerClientConfig): Normalizer | undefined { + if (config === false || config === undefined) { + return undefined; + } + + if (config === true) { + return this.createNormalize({}); + } + + return this.createNormalize(config); + } + + private createNormalize(config: NormalizerConfig): Normalizer { + const normalizer = createNormalizer(config.normalizerConfig, config.initialNormalizedData); + + return { + getNormalizedData: () => normalizer.getNormalizedData(), + clearNormalizedData: () => normalizer.clearNormalizedData(), + setQuery: (queryKey: string, queryData: Data) => + normalizer.setQuery(queryKey, queryData), + removeQuery: (queryKey: string) => normalizer.removeQuery(queryKey), + getQueriesToUpdate: (mutationData: Data) => normalizer.getQueriesToUpdate(mutationData), + getObjectById: (id: string, exampleObject?: T) => + normalizer.getObjectById(id, exampleObject), + getQueryFragment: (fragment: Data, exampleObject?: T) => + normalizer.getQueryFragment(fragment, exampleObject), + getDependentQueries: (mutationData: Data) => + normalizer.getDependentQueries(mutationData), + getDependentQueriesByIds: (ids: ReadonlyArray) => + normalizer.getDependentQueriesByIds(ids), + getCurrentData: (newData: T) => normalizer.getCurrentData(newData), + log: (...messages: unknown[]) => normalizer.log(...messages), + }; + } } diff --git a/src/react-query/DataSourceProvider.tsx b/src/react-query/DataSourceProvider.tsx index 2b299dd..3c030b6 100644 --- a/src/react-query/DataSourceProvider.tsx +++ b/src/react-query/DataSourceProvider.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {QueryClientProvider} from '@tanstack/react-query'; -import {DataManagerContext} from '../react/DataManagerContext'; +import {DataManagerProvider} from '../react/DataManagerProvider'; import type {ClientDataManager} from './ClientDataManager'; import type {QueryNormalizerProviderProps} from './normalize/QueryNormalizerProvider'; @@ -19,11 +19,9 @@ export const DataSourceProvider: React.FC = ({ ...restProps }) => { return ( - + - - {children} - + {children} ); 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/types/normalizer.ts b/src/react-query/types/normalizer.ts index 9279f91..9499d1a 100644 --- a/src/react-query/types/normalizer.ts +++ b/src/react-query/types/normalizer.ts @@ -1,4 +1,4 @@ -import type {NormalizerConfig, createNormalizer} from '@normy/core'; +import type {NormalizerConfig} from '@normy/core'; export interface OptimisticUpdateConfig { /** Whether optimistic synchronization is enabled, defaults to false. Note: won't work without normalization */ @@ -15,6 +15,3 @@ export interface OptionsNormalizerConfig { } export type DataSourceNormalizerConfig = NormalizerConfig & OptionsNormalizerConfig; - -/** Type for normalizer instance returned by createNormalizer */ -export type Normalizer = ReturnType; 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); }); }); }); diff --git a/src/react-query/utils/normalize.ts b/src/react-query/utils/normalize.ts index 73d44e5..d0c5b24 100644 --- a/src/react-query/utils/normalize.ts +++ b/src/react-query/utils/normalize.ts @@ -12,7 +12,7 @@ export const shouldNormalize = ( }; /** Checks if data should be optimistically updated */ -export const shouldOptimisticallyUpdate = ( +export const shouldUpdateOptimistically = ( providerConfig: boolean, mutationConfig: boolean | undefined, ): boolean => { diff --git a/src/react/DataManagerProvider.tsx b/src/react/DataManagerProvider.tsx new file mode 100644 index 0000000..237dc4c --- /dev/null +++ b/src/react/DataManagerProvider.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import type {DataManager} from '../core'; + +import {DataManagerContext} from './DataManagerContext'; + +export interface DataManagerProviderProps { + children: React.ReactNode; + dataManager: DataManager; +} + +export const DataManagerProvider: React.FC = ({ + children, + dataManager, +}) => { + return ( + {children} + ); +}; diff --git a/src/react/__tests__/DataManagerContext.test.tsx b/src/react/__tests__/DataManagerContext.test.tsx index 6425b4c..bbc1a2b 100644 --- a/src/react/__tests__/DataManagerContext.test.tsx +++ b/src/react/__tests__/DataManagerContext.test.tsx @@ -8,6 +8,9 @@ import {DataManagerContext, useDataManager} from '../DataManagerContext'; describe('useDataManager', () => { it('should return dataManager from context', () => { const mockDataManager: DataManager = { + normalizer: undefined, + optimisticUpdate: jest.fn(), + automaticInvalidate: jest.fn(), invalidateTag: jest.fn(), invalidateTags: jest.fn(), invalidateSource: jest.fn(), @@ -29,14 +32,14 @@ describe('useDataManager', () => { }); it('should throw an error when dataManager is not provided', () => { - try { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { renderHook(() => useDataManager()); - } catch (error) { - expect(error).toBeInstanceOf(Error); - // Just to be sure that the error is from the right place - expect((error as Error).message).toBe( - 'DataManager is not provided by context. Use DataManagerContext.Provider to do it', - ); - } + }).toThrow( + 'DataManager is not provided by context. Use DataManagerContext.Provider to do it', + ); + + consoleSpy.mockRestore(); }); }); diff --git a/src/react/index.ts b/src/react/index.ts index d900d30..69ffba2 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,4 +1,6 @@ export {DataManagerContext, useDataManager} from './DataManagerContext'; +export {DataManagerProvider} from './DataManagerProvider'; +export type {DataManagerProviderProps} from './DataManagerProvider'; export type {WithDataManagerProps} from './withDataManager'; export {withDataManager} from './withDataManager'; From 4f013a6d74e4b8458637c91f77ab6c3b4a88c396 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Sat, 22 Nov 2025 16:41:03 +0100 Subject: [PATCH 4/8] fix: typecheck --- src/react-query/index.ts | 2 -- src/react/__tests__/withDataManager.test.tsx | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/react-query/index.ts b/src/react-query/index.ts index 0841b8c..da48a3d 100644 --- a/src/react-query/index.ts +++ b/src/react-query/index.ts @@ -24,11 +24,9 @@ export {ClientDataManager} from './ClientDataManager'; export {DataSourceProvider} from './DataSourceProvider'; -export {updateQueriesFromMutationData} from './normalize/normalization'; export {QueryNormalizerProvider, useQueryNormalizer} from './normalize/QueryNormalizerProvider'; export type { OptimisticUpdateConfig, DataSourceNormalizerConfig, OptionsNormalizerConfig, - Normalizer, } from './types/normalizer'; diff --git a/src/react/__tests__/withDataManager.test.tsx b/src/react/__tests__/withDataManager.test.tsx index d30c2fb..30d8fc5 100644 --- a/src/react/__tests__/withDataManager.test.tsx +++ b/src/react/__tests__/withDataManager.test.tsx @@ -17,6 +17,9 @@ jest.mock('../DataManagerContext', () => { describe('withDataManager', () => { const mockDataManager: DataManager = { + normalizer: undefined, + optimisticUpdate: jest.fn(), + automaticInvalidate: jest.fn(), invalidateTag: jest.fn(), invalidateTags: jest.fn(), invalidateSource: jest.fn(), From a8477d51e3f7e5591ee5b0d3dd2314264da071f3 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 2 Dec 2025 21:09:33 +0100 Subject: [PATCH 5/8] fix: improve client manager --- README-ru.md | 380 +------------- README.md | 380 +------------- src/core/index.ts | 2 +- src/core/types/DataManager.ts | 2 +- src/core/types/Normalizer.ts | 31 +- src/react-query/ClientDataManager.ts | 58 +-- src/react-query/DataSourceProvider.tsx | 34 +- .../__tests__/createQueryNormalizer.test.tsx | 172 +++++++ .../__tests__/normalizationEdgeCases.test.tsx | 46 +- .../__tests__/subscriptions.test.tsx | 465 ++++++++---------- .../__tests__/threeLevelIntegration.test.tsx | 253 ++++++++++ .../updateQueriesFromMutationData.test.tsx | 62 +-- src/react-query/index.ts | 7 +- .../normalize/QueryNormalizerProvider.tsx | 75 --- .../QueryNormalizerProvider.test.tsx | 376 -------------- .../__tests__/createQueryNormalizer.test.tsx | 244 --------- .../__tests__/threeLevelIntegration.test.tsx | 183 ------- src/react-query/normalize/normalization.ts | 163 ------ src/react-query/types/normalizer.ts | 13 +- src/react-query/types/options.ts | 6 +- .../utils/__tests__/normalize.test.ts | 38 -- src/react-query/utils/normalize.ts | 186 ++++++- .../__tests__/DataManagerContext.test.tsx | 2 +- src/react/__tests__/withDataManager.test.tsx | 2 +- 24 files changed, 928 insertions(+), 2252 deletions(-) create mode 100644 src/react-query/__tests__/createQueryNormalizer.test.tsx rename src/react-query/{normalize => }/__tests__/normalizationEdgeCases.test.tsx (67%) rename src/react-query/{normalize => }/__tests__/subscriptions.test.tsx (50%) create mode 100644 src/react-query/__tests__/threeLevelIntegration.test.tsx rename src/react-query/{normalize => }/__tests__/updateQueriesFromMutationData.test.tsx (74%) 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__/threeLevelIntegration.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/index.ts b/src/core/index.ts index 23976e4..5591714 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -18,7 +18,7 @@ export type { export type {DataManager} from './types/DataManager'; export type {DataLoaderStatus} from './types/DataLoaderStatus'; export type {InvalidateRepeatOptions, InvalidateOptions} from './types/DataManagerOptions'; -export type {Normalizer, NormalizerConfig, NormalizerClientConfig} from './types/Normalizer'; +export type {Normalizer, NormalizerConfig} from './types/Normalizer'; export {idle} from './constants'; diff --git a/src/core/types/DataManager.ts b/src/core/types/DataManager.ts index 9ff3f7e..61604a0 100644 --- a/src/core/types/DataManager.ts +++ b/src/core/types/DataManager.ts @@ -9,7 +9,7 @@ export interface DataManager { optimisticUpdate(mutationData: Data): void; - automaticInvalidate(data: Data): void; + invalidateData(data: Data): void; invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions): Promise; diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts index e5a7fcd..d7370ba 100644 --- a/src/core/types/Normalizer.ts +++ b/src/core/types/Normalizer.ts @@ -1,12 +1,12 @@ import type {NormalizerConfig as NormalizeConfigBase} from '@normy/core'; import type {Data, NormalizedData} from '@normy/core/types/types'; -export interface NormalizerConfig { - normalizerConfig?: NormalizeConfigBase; - initialNormalizedData?: NormalizedData; -} +import type {OptimisticConfig} from '../../react-query/types/normalizer'; -export type NormalizerClientConfig = boolean | NormalizerConfig | undefined; +export interface NormalizerConfig extends NormalizeConfigBase { + initialData?: NormalizedData; + optimistic?: boolean | OptimisticConfig; +} export interface Normalizer { getNormalizedData: () => NormalizedData; @@ -24,3 +24,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 d1bf780..811719e 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -9,20 +9,25 @@ import { type DataSourceParams, type DataSourceTag, type Normalizer, - type NormalizerClientConfig, type NormalizerConfig, composeFullKey, hasTag, } from '../core'; import type {InvalidateOptions, InvalidateRepeatOptions} from '../core/types/DataManagerOptions'; +import type {QueryNormalizer} from '../core/types/Normalizer'; -export type ClientDataManagerConfig = QueryClientConfig; +import {createQueryNormalizer} from './utils/normalize'; + +export interface ClientDataManagerConfig extends QueryClientConfig { + normalizerConfig?: NormalizerConfig | boolean; +} export class ClientDataManager implements DataManager { readonly queryClient: QueryClient; readonly normalizer?: Normalizer | undefined; + readonly queryNormalizer?: QueryNormalizer | undefined; - constructor(config: ClientDataManagerConfig = {}, normalizerConfig?: NormalizerClientConfig) { + constructor(config: ClientDataManagerConfig = {}) { this.queryClient = new QueryClient({ ...config, defaultOptions: { @@ -38,7 +43,13 @@ export class ClientDataManager implements DataManager { }, }); - this.normalizer = this.initializeNormalize(normalizerConfig); + this.normalizer = this.initializeNormalize(config.normalizerConfig); + this.queryNormalizer = createQueryNormalizer( + this.normalizer, + this.queryClient, + config.normalizerConfig, + (data) => this.optimisticUpdate(data), + ); } optimisticUpdate(mutationData: Data) { @@ -50,6 +61,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; @@ -65,7 +77,7 @@ export class ClientDataManager implements DataManager { }); } - automaticInvalidate(data: Data): void { + invalidateData(data: Data): void { if (!this.normalizer) { return; } @@ -75,7 +87,6 @@ export class ClientDataManager implements DataManager { queriesToUpdate.forEach((query) => { const queryKey = JSON.parse(query.queryKey) as QueryKey; this.queryClient.invalidateQueries({queryKey}); - this.normalizer?.removeQuery(query.queryKey); }); } @@ -176,7 +187,7 @@ export class ClientDataManager implements DataManager { } } - private initializeNormalize(config?: NormalizerClientConfig): Normalizer | undefined { + private initializeNormalize(config?: NormalizerConfig | boolean): Normalizer | undefined { if (config === false || config === undefined) { return undefined; } @@ -188,26 +199,17 @@ export class ClientDataManager implements DataManager { return this.createNormalize(config); } - private createNormalize(config: NormalizerConfig): Normalizer { - const normalizer = createNormalizer(config.normalizerConfig, config.initialNormalizedData); - - return { - getNormalizedData: () => normalizer.getNormalizedData(), - clearNormalizedData: () => normalizer.clearNormalizedData(), - setQuery: (queryKey: string, queryData: Data) => - normalizer.setQuery(queryKey, queryData), - removeQuery: (queryKey: string) => normalizer.removeQuery(queryKey), - getQueriesToUpdate: (mutationData: Data) => normalizer.getQueriesToUpdate(mutationData), - getObjectById: (id: string, exampleObject?: T) => - normalizer.getObjectById(id, exampleObject), - getQueryFragment: (fragment: Data, exampleObject?: T) => - normalizer.getQueryFragment(fragment, exampleObject), - getDependentQueries: (mutationData: Data) => - normalizer.getDependentQueries(mutationData), - getDependentQueriesByIds: (ids: ReadonlyArray) => - normalizer.getDependentQueriesByIds(ids), - getCurrentData: (newData: T) => normalizer.getCurrentData(newData), - log: (...messages: unknown[]) => normalizer.log(...messages), - }; + private createNormalize( + config: boolean | NormalizerConfig | undefined, + ): Normalizer | undefined { + if (!config) { + return undefined; + } + + if (config === true) { + return createNormalizer({}); + } + + return createNormalizer(config); } } diff --git a/src/react-query/DataSourceProvider.tsx b/src/react-query/DataSourceProvider.tsx index 3c030b6..f9a53cb 100644 --- a/src/react-query/DataSourceProvider.tsx +++ b/src/react-query/DataSourceProvider.tsx @@ -5,24 +5,30 @@ import {QueryClientProvider} from '@tanstack/react-query'; import {DataManagerProvider} from '../react/DataManagerProvider'; import type {ClientDataManager} from './ClientDataManager'; -import type {QueryNormalizerProviderProps} from './normalize/QueryNormalizerProvider'; -import {QueryNormalizerProvider} from './normalize/QueryNormalizerProvider'; -export interface DataSourceProviderProps extends Omit { - /** Pass ClientDataManager to use its queryClient in QueryNormalizerProvider */ +export interface DataSourceProviderProps { dataManager: ClientDataManager; + children: React.ReactNode; } -export const DataSourceProvider: React.FC = ({ - children, - dataManager, - ...restProps -}) => { +export const DataSourceProvider: React.FC = ({children, dataManager}) => { + React.useEffect(() => { + if (!dataManager.queryNormalizer) { + return undefined; + } + + dataManager.queryNormalizer.subscribe(); + + return () => { + dataManager.queryNormalizer?.unsubscribe(); + dataManager.queryNormalizer?.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - - - {children} - - + + {children} + ); }; diff --git a/src/react-query/__tests__/createQueryNormalizer.test.tsx b/src/react-query/__tests__/createQueryNormalizer.test.tsx new file mode 100644 index 0000000..e7754a0 --- /dev/null +++ b/src/react-query/__tests__/createQueryNormalizer.test.tsx @@ -0,0 +1,172 @@ +import type {Data} from '@normy/core'; + +import {ClientDataManager} from '../ClientDataManager'; + +describe('QueryNormalizer API', () => { + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + }, + }); + }); + + afterEach(() => { + dataManager.queryClient.clear(); + }); + + it('should create queryNormalizer with required methods', () => { + expect(dataManager.queryNormalizer).toBeDefined(); + expect(dataManager.queryNormalizer?.getNormalizedData).toBeDefined(); + expect(dataManager.queryNormalizer?.setNormalizedData).toBeDefined(); + expect(dataManager.queryNormalizer?.clear).toBeDefined(); + expect(dataManager.queryNormalizer?.getObjectById).toBeDefined(); + expect(dataManager.queryNormalizer?.getQueryFragment).toBeDefined(); + expect(dataManager.queryNormalizer?.getDependentQueries).toBeDefined(); + expect(dataManager.queryNormalizer?.getDependentQueriesByIds).toBeDefined(); + expect(dataManager.queryNormalizer?.subscribe).toBeDefined(); + expect(dataManager.queryNormalizer?.unsubscribe).toBeDefined(); + }); + + it('getNormalizedData should return normalized data', () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + const data = dataManager.queryNormalizer!.getNormalizedData(); + expect(data).toBeDefined(); + expect(data.objects).toBeDefined(); + expect(data.queries).toBeDefined(); + }); + + it('setNormalizedData should update queries', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + + dataManager.queryNormalizer!.setNormalizedData({id: '1', name: 'New'}); + + const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('New'); + }); + + it('clear should clear normalized data', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'User'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'User'}]); + + dataManager.queryNormalizer!.clear(); + + const data = dataManager.queryNormalizer!.getNormalizedData(); + expect(Object.keys(data.objects)).toHaveLength(0); + }); + + it('getObjectById should return object by ID', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + const userData = [{id: '1', name: 'User 1'}]; + + // Add to normalizer + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), userData); + + // Get normalized data + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + expect(Object.keys(normalized.objects).length).toBeGreaterThan(0); + + // getObjectById should be defined and available + expect(dataManager.queryNormalizer!.getObjectById).toBeDefined(); + expect(typeof dataManager.queryNormalizer!.getObjectById).toBe('function'); + }); + + it('getDependentQueries should return dependent queries', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey1 = ['users']; + const queryKey2 = ['user', '1']; + + dataManager.queryClient.setQueryData(queryKey1, [{id: '1', name: 'User'}]); + dataManager.queryClient.setQueryData(queryKey2, {id: '1', name: 'User'}); + + dataManager.normalizer!.setQuery(JSON.stringify(queryKey1), [{id: '1', name: 'User'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey2), {id: '1', name: 'User'}); + + const dependentQueries = dataManager.queryNormalizer!.getDependentQueries({ + id: '1', + name: 'Updated', + }); + expect(dependentQueries.length).toBeGreaterThan(0); + }); + + it('getDependentQueriesByIds should be available', () => { + expect(dataManager.queryNormalizer).toBeDefined(); + + // getDependentQueriesByIds should be defined and available + expect(dataManager.queryNormalizer!.getDependentQueriesByIds).toBeDefined(); + expect(typeof dataManager.queryNormalizer!.getDependentQueriesByIds).toBe('function'); + + // Call with empty array should not throw + const result = dataManager.queryNormalizer!.getDependentQueriesByIds([]); + expect(Array.isArray(result)).toBe(true); + }); + + it('should not create queryNormalizer when normalizerConfig is false', () => { + const dmWithoutNormalizer = new ClientDataManager({ + normalizerConfig: false, + }); + + expect(dmWithoutNormalizer.normalizer).toBeUndefined(); + expect(dmWithoutNormalizer.queryNormalizer).toBeUndefined(); + }); + + it('should not create queryNormalizer when normalizerConfig is undefined', () => { + const dmWithoutNormalizer = new ClientDataManager({}); + + expect(dmWithoutNormalizer.normalizer).toBeUndefined(); + expect(dmWithoutNormalizer.queryNormalizer).toBeUndefined(); + }); + + it('should create queryNormalizer when normalizerConfig is true', () => { + const dmWithNormalizer = new ClientDataManager({ + normalizerConfig: true, + }); + + expect(dmWithNormalizer.normalizer).toBeDefined(); + expect(dmWithNormalizer.queryNormalizer).toBeDefined(); + }); + + it('should work correctly with optimisticUpdate via setNormalizedData', () => { + expect(dataManager.normalizer).toBeDefined(); + expect(dataManager.queryNormalizer).toBeDefined(); + + const queryKey = ['users']; + const initialData = [{id: '1', name: 'Old Name'}]; + + dataManager.queryClient.setQueryData(queryKey, initialData); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + const mutationData: Data = {id: '1', name: 'New Name'}; + dataManager.queryNormalizer!.setNormalizedData(mutationData); + + const updatedData = dataManager.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(updatedData[0].name).toBe('New Name'); + }); +}); diff --git a/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx b/src/react-query/__tests__/normalizationEdgeCases.test.tsx similarity index 67% rename from src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx rename to src/react-query/__tests__/normalizationEdgeCases.test.tsx index 38bd0e9..15f82ae 100644 --- a/src/react-query/normalize/__tests__/normalizationEdgeCases.test.tsx +++ b/src/react-query/__tests__/normalizationEdgeCases.test.tsx @@ -1,24 +1,20 @@ import type {Data} from '@normy/core'; -import {ClientDataManager} from '../../ClientDataManager'; +import {ClientDataManager} from '../ClientDataManager'; describe('normalization edge cases', () => { let dataManager: ClientDataManager; beforeEach(() => { - dataManager = new ClientDataManager( - { - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, - }, + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, }, - { - normalizerConfig: { - devLogging: false, - }, + normalizerConfig: { + devLogging: false, }, - ); + }); }); afterEach(() => { @@ -26,13 +22,11 @@ describe('normalization edge cases', () => { }); it('should work correctly with empty data', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['empty']; dataManager.queryClient.setQueryData(queryKey, []); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), []); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), []); const mutationData: Data = {id: '1', name: 'New'}; expect(() => { @@ -41,9 +35,7 @@ describe('normalization edge cases', () => { }); it('should work correctly with null data', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['null']; dataManager.queryClient.setQueryData(queryKey, null); @@ -55,9 +47,7 @@ describe('normalization edge cases', () => { }); it('should work correctly with undefined data', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['undefined']; dataManager.queryClient.setQueryData(queryKey, undefined); @@ -69,9 +59,7 @@ describe('normalization edge cases', () => { }); it('should work correctly with arrays of objects', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['array']; const data = [ @@ -80,7 +68,7 @@ describe('normalization edge cases', () => { ]; dataManager.queryClient.setQueryData(queryKey, data); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), data); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), data); const mutationData: Data = {id: '1', name: 'Updated Item 1'}; dataManager.optimisticUpdate(mutationData); @@ -94,15 +82,13 @@ describe('normalization edge cases', () => { }); it('should work correctly with single objects', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['single']; const data = {id: '1', name: 'Item'}; dataManager.queryClient.setQueryData(queryKey, data); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), data); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), data); const mutationData: Data = {id: '1', name: 'Updated Item'}; dataManager.optimisticUpdate(mutationData); diff --git a/src/react-query/normalize/__tests__/subscriptions.test.tsx b/src/react-query/__tests__/subscriptions.test.tsx similarity index 50% rename from src/react-query/normalize/__tests__/subscriptions.test.tsx rename to src/react-query/__tests__/subscriptions.test.tsx index 248d3c3..ee87c20 100644 --- a/src/react-query/normalize/__tests__/subscriptions.test.tsx +++ b/src/react-query/__tests__/subscriptions.test.tsx @@ -1,125 +1,82 @@ import React from 'react'; -import {QueryClientProvider, useMutation} from '@tanstack/react-query'; +import {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'; +import {ClientDataManager} from '../ClientDataManager'; +import {DataSourceProvider} from '../DataSourceProvider'; describe('subscriptions', () => { let dataManager: ClientDataManager; beforeEach(() => { - dataManager = new ClientDataManager( - { - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, - }, + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, }, - { - normalizerConfig: { - devLogging: false, - }, + normalizerConfig: { + devLogging: false, }, - ); + }); }); afterEach(() => { + dataManager.queryNormalizer?.unsubscribe(); 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, - }); + expect(dataManager.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManager.queryNormalizer!.subscribe(); - // Add query + // Add query with normalize: true option await dataManager.queryClient.fetchQuery({ queryKey: ['users'], queryFn: async () => [{id: '1', name: 'User 1'}], - }); + normalize: true, + } as Parameters[0] & {normalize: boolean}); - const normalized = queryNormalizer.getNormalizedData(); + const normalized = dataManager.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, - }); + expect(dataManager.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManager.queryNormalizer!.subscribe(); const queryKey = ['users']; - // Initial data + // Initial data with normalize: true await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Old'}], - }); + normalize: true, + } as Parameters[0] & {normalize: boolean}); - const normalizedBefore = queryNormalizer.getNormalizedData(); + const normalizedBefore = dataManager.queryNormalizer!.getNormalizedData(); const objectCountBefore = Object.keys(normalizedBefore.objects).length; // Update data await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'New'}], - }); + normalize: true, + } as Parameters[0] & {normalize: boolean}); // Verify that normalized data was updated - const normalizedAfter = queryNormalizer.getNormalizedData(); + const normalizedAfter = dataManager.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, - }); + expect(dataManager.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManager.queryNormalizer!.subscribe(); const queryKey = ['users']; @@ -127,7 +84,8 @@ describe('subscriptions', () => { await dataManager.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'User 1'}], - }); + normalize: true, + } as Parameters[0] & {normalize: boolean}); // Remove query dataManager.queryClient.removeQueries({queryKey}); @@ -135,123 +93,96 @@ describe('subscriptions', () => { // Give time to process event await new Promise((resolve) => setTimeout(resolve, 10)); - const normalized = queryNormalizer.getNormalizedData(); + const normalized = dataManager.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, - }); + expect(dataManager.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManager.queryNormalizer!.subscribe(); // Check that we can use meta for configuration // Real check for disabling via meta is already tested in integration tests - const normalized = queryNormalizer.getNormalizedData(); + const normalized = dataManager.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, - }); + expect(dataManager.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); - queryNormalizer.unsubscribe(); + dataManager.queryNormalizer!.subscribe(); + dataManager.queryNormalizer!.unsubscribe(); // After unsubscribing, adding query should not affect normalizer await dataManager.queryClient.fetchQuery({ queryKey: ['users'], queryFn: async () => [{id: '1', name: 'User 1'}], - }); + normalize: true, + } as Parameters[0] & {normalize: boolean}); - const normalized = queryNormalizer.getNormalizedData(); + const normalized = dataManager.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, - }); + expect(dataManager.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); - queryNormalizer.unsubscribe(); + dataManager.queryNormalizer!.subscribe(); + dataManager.queryNormalizer!.unsubscribe(); // Repeated call should not throw error - expect(() => queryNormalizer.unsubscribe()).not.toThrow(); + expect(() => dataManager.queryNormalizer!.unsubscribe()).not.toThrow(); }); }); describe('MutationCache subscription', () => { - const normalizerConfig: DataSourceNormalizerConfig = { - normalize: true, - }; + let dataManagerWithOptimistic: ClientDataManager; - const optimisticUpdateConfig: OptimisticUpdateConfig = { - enabled: true, - autoCalculateRollback: true, - }; + beforeEach(() => { + dataManagerWithOptimistic = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: true, + }, + }, + }); + }); + + afterEach(() => { + dataManagerWithOptimistic.queryNormalizer?.unsubscribe(); + dataManagerWithOptimistic.queryClient.clear(); + }); it('should update queries on successful mutation', async () => { - 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(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManagerWithOptimistic.queryNormalizer!.subscribe(); const queryKey = ['users']; // Initial data - await dataManager.queryClient.fetchQuery({ + await dataManagerWithOptimistic.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Old'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; }); // Create wrapper for hooks const wrapper = ({children}: {children: React.ReactNode}) => ( - + {children} - + ); // Mutation via useMutation @@ -259,50 +190,46 @@ describe('subscriptions', () => { () => useMutation({ mutationFn: async () => ({id: '1', name: 'New'}), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; }), {wrapper}, ); - mutationResult.current.mutate(); + mutationResult.current.mutate(undefined); await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + const data = dataManagerWithOptimistic.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, - }); + expect(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManagerWithOptimistic.queryNormalizer!.subscribe(); const queryKey = ['users']; // Initial data - await dataManager.queryClient.fetchQuery({ + await dataManagerWithOptimistic.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; }); const wrapper = ({children}: {children: React.ReactNode}) => ( - + {children} - + ); // Mutation with optimistic data @@ -316,15 +243,20 @@ describe('subscriptions', () => { onMutate: () => ({ optimisticData: {id: '1', name: 'Optimistic'}, }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; }), {wrapper}, ); - mutationResult.current.mutate(); + mutationResult.current.mutate(undefined); // Check optimistic data await waitFor(() => { - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + const data = dataManagerWithOptimistic.queryClient.getQueryData(queryKey) as Array<{ id: string; name: string; }>; @@ -334,45 +266,35 @@ describe('subscriptions', () => { // Wait for mutation to complete await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - const dataFinal = dataManager.queryClient.getQueryData(queryKey) as Array<{ + const dataFinal = dataManagerWithOptimistic.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, - }, - }); + expect(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManagerWithOptimistic.queryNormalizer!.subscribe(); const queryKey = ['users']; // Initial data - await dataManager.queryClient.fetchQuery({ + await dataManagerWithOptimistic.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; }); const wrapper = ({children}: {children: React.ReactNode}) => ( - + {children} - + ); // Mutation with optimistic data that will fail @@ -386,51 +308,47 @@ describe('subscriptions', () => { onMutate: () => ({ optimisticData: {id: '1', name: 'Optimistic'}, }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; }), {wrapper}, ); - mutationResult.current.mutate(); + mutationResult.current.mutate(undefined); await waitFor(() => expect(mutationResult.current.isError).toBe(true)); // Data should be rolled back to original - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + const data = dataManagerWithOptimistic.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, - }); + expect(dataManagerWithOptimistic.queryNormalizer).toBeDefined(); - queryNormalizer.subscribe(); + dataManagerWithOptimistic.queryNormalizer!.subscribe(); const queryKey = ['users']; // Initial data - await dataManager.queryClient.fetchQuery({ + await dataManagerWithOptimistic.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; }); const wrapper = ({children}: {children: React.ReactNode}) => ( - + {children} - + ); // Mutation with error @@ -444,48 +362,47 @@ describe('subscriptions', () => { onMutate: () => ({ optimisticData: {id: '1', name: 'Optimistic'}, }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; }), {wrapper}, ); - mutationResult.current.mutate(); + mutationResult.current.mutate(undefined); await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + const data = dataManagerWithOptimistic.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}, + it('should ignore mutations with normalize: false', async () => { + const dmNoNormalize = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + }, }); - queryNormalizer.subscribe(); + expect(dmNoNormalize.queryNormalizer).toBeDefined(); const queryKey = ['users']; - dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Original'}]); + dmNoNormalize.queryClient.setQueryData(queryKey, [{id: '1', name: 'Original'}]); const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - + {children} ); - // Mutation should not update data automatically + // Mutation should not update data automatically (no normalize option) const {result: mutationResult} = renderHook( () => useMutation({ @@ -494,51 +411,50 @@ describe('subscriptions', () => { {wrapper}, ); - mutationResult.current.mutate(); + mutationResult.current.mutate(undefined); await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + const data = dmNoNormalize.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, + const dmWithLogging = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: true, + devLogging: true, + }, }, }); - queryNormalizer.subscribe(); + expect(dmWithLogging.queryNormalizer).toBeDefined(); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + dmWithLogging.queryNormalizer!.subscribe(); const queryKey = ['users']; - await dataManager.queryClient.fetchQuery({ + await dmWithLogging.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; }); const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - + {children} ); const {result: mutationResult} = renderHook( @@ -551,11 +467,16 @@ describe('subscriptions', () => { onMutate: () => ({ optimisticData: {id: '1', name: 'Optimistic'}, }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; }), {wrapper}, ); - mutationResult.current.mutate(); + mutationResult.current.mutate(undefined); await waitFor(() => expect(mutationResult.current.isError).toBe(true)); @@ -567,38 +488,38 @@ describe('subscriptions', () => { 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, + const dmNoAutoRollback = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: false, + }, }, }); - queryNormalizer.subscribe(); + expect(dmNoAutoRollback.queryNormalizer).toBeDefined(); + + dmNoAutoRollback.queryNormalizer!.subscribe(); const queryKey = ['users']; - await dataManager.queryClient.fetchQuery({ + await dmNoAutoRollback.queryClient.fetchQuery({ queryKey, queryFn: async () => [{id: '1', name: 'Original'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; }); const wrapper = ({children}: {children: React.ReactNode}) => ( - - {children} - + {children} ); const {result: mutationResult} = renderHook( @@ -612,21 +533,27 @@ describe('subscriptions', () => { optimisticData: {id: '1', name: 'Optimistic'}, rollbackData: {id: '1', name: 'Manual Rollback'}, }), + normalize: true, + optimistic: true, + } as Parameters[0] & { + normalize: boolean; + optimistic: boolean; }), {wrapper}, ); - mutationResult.current.mutate(); + mutationResult.current.mutate(undefined); await waitFor(() => expect(mutationResult.current.isError).toBe(true)); - const data = dataManager.queryClient.getQueryData(queryKey) as Array<{ + const data = dmNoAutoRollback.queryClient.getQueryData(queryKey) as Array<{ id: string; name: string; }>; expect(data[0].name).toBe('Manual Rollback'); - queryNormalizer.unsubscribe(); + dmNoAutoRollback.queryNormalizer!.unsubscribe(); + dmNoAutoRollback.queryClient.clear(); }); }); }); diff --git a/src/react-query/__tests__/threeLevelIntegration.test.tsx b/src/react-query/__tests__/threeLevelIntegration.test.tsx new file mode 100644 index 0000000..f1c0c6d --- /dev/null +++ b/src/react-query/__tests__/threeLevelIntegration.test.tsx @@ -0,0 +1,253 @@ +import React from 'react'; + +import type {QueryClient} from '@tanstack/react-query'; +import {renderHook, waitFor} from '@testing-library/react'; + +import {ClientDataManager} from '../ClientDataManager'; +import {DataSourceProvider} from '../DataSourceProvider'; +import {useQueryData} from '../hooks/useQueryData'; +import {makePlainQueryDataSource} from '../impl/plain/factory'; + +describe('Normalization Configuration Integration', () => { + let queryClient: QueryClient; + let dataManager: ClientDataManager; + + beforeEach(() => { + dataManager = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + }, + }); + queryClient = dataManager.queryClient; + }); + + afterEach(() => { + dataManager.queryNormalizer?.unsubscribe(); + queryClient.clear(); + }); + + describe('ClientDataManager configuration', () => { + it('should use custom getNormalizationObjectKey from config', async () => { + const customGetKey = jest.fn((obj) => `custom:${obj.id}`); + + const customDataManager = new ClientDataManager({ + normalizerConfig: { + getNormalizationObjectKey: customGetKey, + devLogging: false, + }, + }); + + customDataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + await customDataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'User 1'}], + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + const normalized = customDataManager.queryNormalizer!.getNormalizedData(); + + expect(normalized.objects['@@custom:1']).toBeDefined(); + expect(customGetKey).toHaveBeenCalled(); + + customDataManager.queryNormalizer!.unsubscribe(); + customDataManager.queryClient.clear(); + }); + + it('should use custom getArrayType from config', async () => { + const customGetArrayType = jest.fn(({arrayKey}) => `custom:${arrayKey}`); + + const customDataManager = new ClientDataManager({ + normalizerConfig: { + getArrayType: customGetArrayType, + devLogging: false, + }, + }); + + customDataManager.queryNormalizer!.subscribe(); + + const queryKey = ['items']; + + await customDataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => ({ + items: [{id: '1', name: 'Item 1'}], + }), + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }); + + expect(customGetArrayType).toHaveBeenCalled(); + + customDataManager.queryNormalizer!.unsubscribe(); + customDataManager.queryClient.clear(); + }); + }); + + describe('Query-level normalization control', () => { + it('should normalize when query has normalize: true', async () => { + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users-normalized']; + + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'User 1'}], + normalize: true, + } as Parameters[0] & {normalize: boolean}); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data SHOULD be normalized + expect(normalized.objects['@@1']).toBeDefined(); + }); + + it('should NOT normalize when query has normalize: false', async () => { + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users-not-normalized']; + + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '2', name: 'User 2'}], + normalize: false, + } as Parameters[0] & {normalize: boolean}); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data should NOT be normalized + expect(normalized.objects['@@2']).toBeUndefined(); + }); + + it('should NOT normalize when normalize option is not provided', async () => { + dataManager.queryNormalizer!.subscribe(); + + const queryKey = ['users-default']; + + await dataManager.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '3', name: 'User 3'}], + }); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data should NOT be normalized (default behavior) + expect(normalized.objects['@@3']).toBeUndefined(); + }); + }); + + describe('DataSourceProvider integration', () => { + it('should auto-subscribe queryNormalizer on mount', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const dataSource = makePlainQueryDataSource({ + name: 'users', + fetch: async () => [{id: '1', name: 'User 1'}], + options: { + normalize: true, + }, + }); + + const {result} = renderHook(() => useQueryData(dataSource, {}), {wrapper}); + + await waitFor(() => expect(result.current.status).toBe('success')); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + // Data should be normalized because DataSourceProvider subscribes automatically + expect(normalized.objects['@@1']).toBeDefined(); + }); + + it('should work with multiple queries', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const dataSource1 = makePlainQueryDataSource({ + name: 'users', + fetch: async () => [{id: '1', name: 'User 1'}], + options: { + normalize: true, + }, + }); + + const dataSource2 = makePlainQueryDataSource({ + name: 'posts', + fetch: async () => [{id: '2', title: 'Post 1'}], + options: { + normalize: true, + }, + }); + + const {result: result1} = renderHook(() => useQueryData(dataSource1, {}), {wrapper}); + const {result: result2} = renderHook(() => useQueryData(dataSource2, {}), {wrapper}); + + await waitFor(() => { + expect(result1.current.status).toBe('success'); + expect(result2.current.status).toBe('success'); + }); + + const normalized = dataManager.queryNormalizer!.getNormalizedData(); + + expect(normalized.objects['@@1']).toBeDefined(); + expect(normalized.objects['@@2']).toBeDefined(); + }); + }); + + describe('Optimistic updates configuration', () => { + it('should enable optimistic updates when configured', async () => { + const dmWithOptimistic = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + }, + }); + + expect(dmWithOptimistic.queryNormalizer).toBeDefined(); + + dmWithOptimistic.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + const initialData = [{id: '1', name: 'Old'}]; + + dmWithOptimistic.queryClient.setQueryData(queryKey, initialData); + dmWithOptimistic.normalizer!.setQuery(JSON.stringify(queryKey), initialData); + + // Manual optimistic update via setNormalizedData + dmWithOptimistic.queryNormalizer!.setNormalizedData({id: '1', name: 'New'}); + + const data = dmWithOptimistic.queryClient.getQueryData(queryKey) as Array<{ + id: string; + name: string; + }>; + expect(data[0].name).toBe('New'); + + dmWithOptimistic.queryNormalizer!.unsubscribe(); + dmWithOptimistic.queryClient.clear(); + }); + + it('should work with optimistic config object', async () => { + const dmWithOptimisticConfig = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: { + autoCalculateRollback: true, + devLogging: false, + }, + }, + }); + + expect(dmWithOptimisticConfig.queryNormalizer).toBeDefined(); + expect(dmWithOptimisticConfig.normalizer).toBeDefined(); + + dmWithOptimisticConfig.queryClient.clear(); + }); + }); +}); diff --git a/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx b/src/react-query/__tests__/updateQueriesFromMutationData.test.tsx similarity index 74% rename from src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx rename to src/react-query/__tests__/updateQueriesFromMutationData.test.tsx index f2fe202..eaeedc6 100644 --- a/src/react-query/normalize/__tests__/updateQueriesFromMutationData.test.tsx +++ b/src/react-query/__tests__/updateQueriesFromMutationData.test.tsx @@ -1,24 +1,20 @@ import type {Data} from '@normy/core'; -import {ClientDataManager} from '../../ClientDataManager'; +import {ClientDataManager} from '../ClientDataManager'; describe('updateQueriesFromMutationData', () => { let dataManager: ClientDataManager; beforeEach(() => { - dataManager = new ClientDataManager( - { - defaultOptions: { - queries: {retry: false}, - mutations: {retry: false}, - }, + dataManager = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, }, - { - normalizerConfig: { - devLogging: false, - }, + normalizerConfig: { + devLogging: false, }, - ); + }); }); afterEach(() => { @@ -26,16 +22,14 @@ describe('updateQueriesFromMutationData', () => { }); it('should update query data based on normalized data', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); // Set initial data in query const queryKey = ['users']; dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old Name'}]); // Add query to normalizer - dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old Name'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old Name'}]); // Update data via mutation const mutationData: Data = {id: '1', name: 'New Name'}; @@ -52,9 +46,7 @@ describe('updateQueriesFromMutationData', () => { }); it('should update multiple queries with the same object', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey1 = ['users']; const queryKey2 = ['user', '1']; @@ -64,8 +56,8 @@ describe('updateQueriesFromMutationData', () => { 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'}); + 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'}; @@ -83,9 +75,7 @@ describe('updateQueriesFromMutationData', () => { }); it('should preserve dataUpdatedAt on update', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['users']; const originalUpdatedAt = Date.now(); @@ -94,7 +84,7 @@ describe('updateQueriesFromMutationData', () => { dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}], { updatedAt: originalUpdatedAt, }); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); // Update data const mutationData: Data = {id: '1', name: 'New'}; @@ -106,9 +96,7 @@ describe('updateQueriesFromMutationData', () => { }); it('should preserve error state on update', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['users']; const error = new Error('Test error'); @@ -118,7 +106,7 @@ describe('updateQueriesFromMutationData', () => { const cachedQuery = dataManager.queryClient.getQueryCache().find({queryKey}); cachedQuery?.setState({error, status: 'error'}); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); // Update data const mutationData: Data = {id: '1', name: 'New'}; @@ -131,9 +119,7 @@ describe('updateQueriesFromMutationData', () => { }); it('should preserve isInvalidated flag on update', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['users']; @@ -141,7 +127,7 @@ describe('updateQueriesFromMutationData', () => { dataManager.queryClient.setQueryData(queryKey, [{id: '1', name: 'Old'}]); dataManager.queryClient.invalidateQueries({queryKey}); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), [{id: '1', name: 'Old'}]); const cachedQueryBefore = dataManager.queryClient.getQueryCache().find({queryKey}); const isInvalidatedBefore = cachedQueryBefore?.state.isInvalidated; @@ -156,9 +142,7 @@ describe('updateQueriesFromMutationData', () => { }); it('should work correctly with nested objects', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const queryKey = ['posts']; const initialData = [ @@ -170,7 +154,7 @@ describe('updateQueriesFromMutationData', () => { ]; dataManager.queryClient.setQueryData(queryKey, initialData); - dataManager.normalizer.setQuery(JSON.stringify(queryKey), initialData); + dataManager.normalizer!.setQuery(JSON.stringify(queryKey), initialData); // Update author const mutationData: Data = {id: '10', name: 'Updated Author'}; @@ -186,9 +170,7 @@ describe('updateQueriesFromMutationData', () => { }); it('should not throw if query is not in cache', () => { - if (!dataManager.normalizer) { - throw new Error('Normalizer should be initialized'); - } + expect(dataManager.normalizer).toBeDefined(); const mutationData: Data = {id: '1', name: 'New'}; diff --git a/src/react-query/index.ts b/src/react-query/index.ts index da48a3d..1945362 100644 --- a/src/react-query/index.ts +++ b/src/react-query/index.ts @@ -24,9 +24,4 @@ export {ClientDataManager} from './ClientDataManager'; export {DataSourceProvider} from './DataSourceProvider'; -export {QueryNormalizerProvider, useQueryNormalizer} from './normalize/QueryNormalizerProvider'; -export type { - OptimisticUpdateConfig, - DataSourceNormalizerConfig, - OptionsNormalizerConfig, -} from './types/normalizer'; +export type {OptimisticConfig} from './types/normalizer'; 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__/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/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/normalizer.ts b/src/react-query/types/normalizer.ts index 9499d1a..d8ddbb3 100644 --- a/src/react-query/types/normalizer.ts +++ b/src/react-query/types/normalizer.ts @@ -1,17 +1,6 @@ -import type {NormalizerConfig} from '@normy/core'; - -export interface OptimisticUpdateConfig { - /** Whether optimistic synchronization is enabled, defaults to false. Note: won't work without normalization */ - enabled?: boolean; +export interface OptimisticConfig { /** Automatically calculate rollback data, defaults to true */ autoCalculateRollback?: boolean; /** Whether debug logging is enabled */ devLogging?: boolean; } - -export interface OptionsNormalizerConfig { - /** Whether normalization is enabled, defaults to false */ - normalize?: boolean; -} - -export type DataSourceNormalizerConfig = NormalizerConfig & OptionsNormalizerConfig; diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index 348817d..f7722c3 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -1,6 +1,6 @@ import type {DefaultError, QueryKey} from '@tanstack/react-query'; -import type {OptimisticUpdateConfig, OptionsNormalizerConfig} from './normalizer'; +import type {OptimisticConfig} from './normalizer'; import type {RefetchInterval} from './refetch-interval'; export interface QueryDataAdditionalOptions< @@ -19,7 +19,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); - }); - }); -}); diff --git a/src/react-query/utils/normalize.ts b/src/react-query/utils/normalize.ts index d0c5b24..9b384d9 100644 --- a/src/react-query/utils/normalize.ts +++ b/src/react-query/utils/normalize.ts @@ -1,23 +1,177 @@ -/** Checks if data should be normalized */ -export const shouldNormalize = ( - providerConfig: boolean, - queryConfig: boolean | undefined, +import type {Data} from '@normy/core'; +import type {QueryClient, QueryKey} from '@tanstack/react-query'; + +import type {Normalizer, NormalizerConfig} from '../../core/types/Normalizer'; +import type {OptimisticConfig} from '../types/normalizer'; +import type {QueryCustomOptions} from '../types/options'; + +const shouldUpdateOptimistically = ( + providerConfig?: boolean | OptimisticConfig, + mutationConfig?: boolean | OptimisticConfig, ): boolean => { - if (queryConfig !== undefined) { - return queryConfig; + if (providerConfig || mutationConfig) { + return true; } - // Use setting from Provider - return providerConfig; + return false; }; -/** Checks if data should be optimistically updated */ -export const shouldUpdateOptimistically = ( - providerConfig: boolean, - mutationConfig: boolean | undefined, -): boolean => { - if (mutationConfig !== undefined) { - return mutationConfig; +const getOptimisticProps = ( + globalConfig?: boolean | OptimisticConfig, + mutationConfig?: boolean | OptimisticConfig, +) => { + const globalAutoRollback = + typeof globalConfig === 'object' ? globalConfig.autoCalculateRollback : undefined; + const mutationAutoRollback = + typeof mutationConfig === 'object' ? mutationConfig.autoCalculateRollback : undefined; + const globalDevLogging = typeof globalConfig === 'object' ? globalConfig.devLogging : undefined; + const mutationDevLogging = + typeof mutationConfig === 'object' ? mutationConfig.devLogging : undefined; + + return { + autoRollback: mutationAutoRollback ?? globalAutoRollback, + devLogging: mutationDevLogging ?? globalDevLogging, + }; +}; + +export const createQueryNormalizer = ( + normalizer: Normalizer | undefined, + queryClient: QueryClient, + config: boolean | NormalizerConfig | undefined, + optimisticUpdate: (mutationData: Data) => void, +) => { + if (!normalizer || !config) { + return undefined; } - return providerConfig; + + const globalOptimistic = + typeof config === 'object' && 'optimistic' in config ? config.optimistic : 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 QueryCustomOptions; + + const queryNormalize = queryOptions?.normalize; + + if (!queryNormalize) { + return; + } + + if (event.type === 'added' && event.query.state.data !== undefined) { + normalizer.setQuery(queryKeyStr, event.query.state.data as Data); + } else if ( + event.type === 'updated' && + event.action.type === 'success' && + event.action.data !== undefined + ) { + normalizer.setQuery(queryKeyStr, event.action.data as Data); + } + }); + + // Subscribe to MutationCache for normalization + optimistic updates + unsubscribeMutationCache = queryClient.getMutationCache().subscribe((event) => { + // Cast to extended type with additional configs, if available + const mutationOptions = event.mutation?.options as QueryCustomOptions | undefined; + + const mutationQueryNormalize = mutationOptions?.normalize; + const mutationQueryOptimistic = mutationOptions?.optimistic; + + if ( + !mutationQueryNormalize || + !shouldUpdateOptimistically(globalOptimistic, mutationQueryOptimistic) + ) { + return; + } + + const {autoRollback, devLogging} = getOptimisticProps( + globalOptimistic, + mutationQueryOptimistic, + ); + + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.action.data + ) { + optimisticUpdate(event.action.data as Data); + } else if (event.type === 'updated' && event.action.type === 'pending') { + const context = event.mutation.state.context as { + optimisticData?: Data; + rollbackData?: Data; + }; + + if (context?.optimisticData) { + if ( + !context.rollbackData && + mutationQueryOptimistic && + autoRollback !== false + ) { + context.rollbackData = normalizer.getCurrentData( + context.optimisticData, + ); + + if (devLogging) { + console.log( + '[OptimisticUpdate] Auto-calculated rollbackData:', + context.rollbackData, + ); + } + } + + optimisticUpdate(context.optimisticData); + } + } else if (event.type === 'updated' && event.action.type === 'error') { + const context = event.mutation.state.context as { + rollbackData?: Data; + }; + + if (context?.rollbackData) { + if (devLogging) { + console.log('[OptimisticUpdate] Rolling back changes'); + } + + optimisticUpdate(context.rollbackData); + } + } + }); + }, + unsubscribe: () => { + unsubscribeQueryCache?.(); + unsubscribeMutationCache?.(); + unsubscribeQueryCache = null; + unsubscribeMutationCache = null; + }, + }; }; diff --git a/src/react/__tests__/DataManagerContext.test.tsx b/src/react/__tests__/DataManagerContext.test.tsx index bbc1a2b..efd0e33 100644 --- a/src/react/__tests__/DataManagerContext.test.tsx +++ b/src/react/__tests__/DataManagerContext.test.tsx @@ -10,7 +10,7 @@ describe('useDataManager', () => { const mockDataManager: DataManager = { normalizer: undefined, optimisticUpdate: jest.fn(), - automaticInvalidate: jest.fn(), + invalidateData: jest.fn(), invalidateTag: jest.fn(), invalidateTags: jest.fn(), invalidateSource: jest.fn(), diff --git a/src/react/__tests__/withDataManager.test.tsx b/src/react/__tests__/withDataManager.test.tsx index 30d8fc5..5dd0134 100644 --- a/src/react/__tests__/withDataManager.test.tsx +++ b/src/react/__tests__/withDataManager.test.tsx @@ -19,7 +19,7 @@ describe('withDataManager', () => { const mockDataManager: DataManager = { normalizer: undefined, optimisticUpdate: jest.fn(), - automaticInvalidate: jest.fn(), + invalidateData: jest.fn(), invalidateTag: jest.fn(), invalidateTags: jest.fn(), invalidateSource: jest.fn(), From 7ec6420df746f25aec1166fc5f1326f337cf1293 Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Tue, 2 Dec 2025 21:15:33 +0100 Subject: [PATCH 6/8] fix: fix --- src/core/types/Normalizer.ts | 21 --------------------- src/react-query/ClientDataManager.ts | 16 ++-------------- src/react-query/index.ts | 2 +- src/react-query/types/normalizer.ts | 23 +++++++++++++++++++++++ 4 files changed, 26 insertions(+), 36 deletions(-) diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts index d7370ba..49d1009 100644 --- a/src/core/types/Normalizer.ts +++ b/src/core/types/Normalizer.ts @@ -24,24 +24,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 811719e..eda849d 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -14,8 +14,8 @@ 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'; export interface ClientDataManagerConfig extends QueryClientConfig { @@ -43,7 +43,7 @@ export class ClientDataManager implements DataManager { }, }); - this.normalizer = this.initializeNormalize(config.normalizerConfig); + this.normalizer = this.createNormalize(config.normalizerConfig); this.queryNormalizer = createQueryNormalizer( this.normalizer, this.queryClient, @@ -187,18 +187,6 @@ export class ClientDataManager implements DataManager { } } - private initializeNormalize(config?: NormalizerConfig | boolean): Normalizer | undefined { - if (config === false || config === undefined) { - return undefined; - } - - if (config === true) { - return this.createNormalize({}); - } - - return this.createNormalize(config); - } - private createNormalize( config: boolean | NormalizerConfig | undefined, ): Normalizer | undefined { diff --git a/src/react-query/index.ts b/src/react-query/index.ts index 1945362..05903ec 100644 --- a/src/react-query/index.ts +++ b/src/react-query/index.ts @@ -24,4 +24,4 @@ export {ClientDataManager} from './ClientDataManager'; export {DataSourceProvider} from './DataSourceProvider'; -export type {OptimisticConfig} from './types/normalizer'; +export type {OptimisticConfig, QueryNormalizer} from './types/normalizer'; diff --git a/src/react-query/types/normalizer.ts b/src/react-query/types/normalizer.ts index d8ddbb3..26e0096 100644 --- a/src/react-query/types/normalizer.ts +++ b/src/react-query/types/normalizer.ts @@ -1,6 +1,29 @@ +import type {Data, NormalizedData} from '@normy/core/types/types'; + export interface OptimisticConfig { /** Automatically calculate rollback data, defaults to true */ autoCalculateRollback?: boolean; /** Whether debug logging is enabled */ devLogging?: boolean; } + +export interface 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 5eae0a5a831351ae1dcf013383b090e7b1e1798b Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Wed, 3 Dec 2025 10:57:58 +0100 Subject: [PATCH 7/8] fix: add invalidate --- src/core/types/Normalizer.ts | 1 + src/react-query/ClientDataManager.ts | 1 + .../__tests__/subscriptions.test.tsx | 108 ++++++++++++++++++ .../__tests__/threeLevelIntegration.test.tsx | 37 +++++- src/react-query/impl/infinite/types.ts | 9 +- src/react-query/impl/plain/types.ts | 9 +- src/react-query/types/options.ts | 5 +- src/react-query/utils/normalize.ts | 60 ++++++++-- 8 files changed, 207 insertions(+), 23 deletions(-) diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts index 49d1009..3eb0ac9 100644 --- a/src/core/types/Normalizer.ts +++ b/src/core/types/Normalizer.ts @@ -6,6 +6,7 @@ import type {OptimisticConfig} from '../../react-query/types/normalizer'; export interface NormalizerConfig extends NormalizeConfigBase { initialData?: NormalizedData; optimistic?: boolean | OptimisticConfig; + invalidate?: boolean; } export interface Normalizer { diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index eda849d..0805c4f 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -49,6 +49,7 @@ export class ClientDataManager implements DataManager { this.queryClient, config.normalizerConfig, (data) => this.optimisticUpdate(data), + (data) => this.invalidateData(data), ); } diff --git a/src/react-query/__tests__/subscriptions.test.tsx b/src/react-query/__tests__/subscriptions.test.tsx index ee87c20..b0d9737 100644 --- a/src/react-query/__tests__/subscriptions.test.tsx +++ b/src/react-query/__tests__/subscriptions.test.tsx @@ -555,5 +555,113 @@ describe('subscriptions', () => { dmNoAutoRollback.queryNormalizer!.unsubscribe(); dmNoAutoRollback.queryClient.clear(); }); + + it('should invalidate queries when invalidate option is enabled', async () => { + const dmWithInvalidate = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + invalidate: true, + }, + }); + + expect(dmWithInvalidate.queryNormalizer).toBeDefined(); + + dmWithInvalidate.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + // Initial data + await dmWithInvalidate.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + // Spy on invalidateQueries + const invalidateSpy = jest.spyOn(dmWithInvalidate.queryClient, 'invalidateQueries'); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => ({id: '1', name: 'Updated'}), + normalize: true, + } as Parameters[0] & { + normalize: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + // Verify that invalidateQueries was called + expect(invalidateSpy).toHaveBeenCalled(); + + invalidateSpy.mockRestore(); + dmWithInvalidate.queryNormalizer!.unsubscribe(); + dmWithInvalidate.queryClient.clear(); + }); + + it('should not invalidate queries when invalidate: false is set on mutation', async () => { + const dmWithInvalidate = new ClientDataManager({ + defaultOptions: { + queries: {retry: false}, + mutations: {retry: false}, + }, + normalizerConfig: { + devLogging: false, + invalidate: true, // Globally enabled + }, + }); + + expect(dmWithInvalidate.queryNormalizer).toBeDefined(); + + dmWithInvalidate.queryNormalizer!.subscribe(); + + const queryKey = ['users']; + + await dmWithInvalidate.queryClient.fetchQuery({ + queryKey, + queryFn: async () => [{id: '1', name: 'Original'}], + }); + + const invalidateSpy = jest.spyOn(dmWithInvalidate.queryClient, 'invalidateQueries'); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result: mutationResult} = renderHook( + () => + useMutation({ + mutationFn: async () => ({id: '1', name: 'Updated'}), + normalize: true, + invalidate: false, // Disable for this mutation + } as Parameters[0] & { + normalize: boolean; + invalidate: boolean; + }), + {wrapper}, + ); + + mutationResult.current.mutate(undefined); + + await waitFor(() => expect(mutationResult.current.isSuccess).toBe(true)); + + // Verify that invalidateQueries was NOT called + expect(invalidateSpy).not.toHaveBeenCalled(); + + invalidateSpy.mockRestore(); + dmWithInvalidate.queryNormalizer!.unsubscribe(); + dmWithInvalidate.queryClient.clear(); + }); }); }); diff --git a/src/react-query/__tests__/threeLevelIntegration.test.tsx b/src/react-query/__tests__/threeLevelIntegration.test.tsx index f1c0c6d..415ce8c 100644 --- a/src/react-query/__tests__/threeLevelIntegration.test.tsx +++ b/src/react-query/__tests__/threeLevelIntegration.test.tsx @@ -124,7 +124,7 @@ describe('Normalization Configuration Integration', () => { expect(normalized.objects['@@2']).toBeUndefined(); }); - it('should NOT normalize when normalize option is not provided', async () => { + it('should normalize by default when normalize option is not provided', async () => { dataManager.queryNormalizer!.subscribe(); const queryKey = ['users-default']; @@ -136,8 +136,8 @@ describe('Normalization Configuration Integration', () => { const normalized = dataManager.queryNormalizer!.getNormalizedData(); - // Data should NOT be normalized (default behavior) - expect(normalized.objects['@@3']).toBeUndefined(); + // Data SHOULD be normalized (default behavior is now true) + expect(normalized.objects['@@3']).toBeDefined(); }); }); @@ -250,4 +250,35 @@ describe('Normalization Configuration Integration', () => { dmWithOptimisticConfig.queryClient.clear(); }); }); + + describe('Invalidate configuration', () => { + it('should support invalidate option in global config', async () => { + const dmWithInvalidate = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + invalidate: true, + }, + }); + + expect(dmWithInvalidate.queryNormalizer).toBeDefined(); + expect(dmWithInvalidate.normalizer).toBeDefined(); + + dmWithInvalidate.queryClient.clear(); + }); + + it('should support both optimistic and invalidate options', async () => { + const dmWithBoth = new ClientDataManager({ + normalizerConfig: { + devLogging: false, + optimistic: true, + invalidate: true, + }, + }); + + expect(dmWithBoth.queryNormalizer).toBeDefined(); + expect(dmWithBoth.normalizer).toBeDefined(); + + dmWithBoth.queryClient.clear(); + }); + }); }); diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index faedd4a..042f302 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -6,11 +6,11 @@ import type { QueryFunctionContext, QueryKey, } from '@tanstack/react-query'; -import type {Overwrite} from 'utility-types'; +import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; -import type {QueryCustomOptions, QueryDataAdditionalOptions} from '../../types/options'; +import type {QueryDataAdditionalOptions} from '../../types/options'; export type InfiniteQueryObserverExtendedOptions< TQueryFnData = unknown, @@ -19,7 +19,7 @@ export type InfiniteQueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, -> = Overwrite< +> = Assign< InfiniteQueryObserverOptions, QueryDataAdditionalOptions< TQueryFnData, @@ -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 d32ab4b..381898e 100644 --- a/src/react-query/impl/plain/types.ts +++ b/src/react-query/impl/plain/types.ts @@ -5,11 +5,11 @@ import type { QueryObserverOptions, QueryObserverResult, } from '@tanstack/react-query'; -import type {Overwrite} from 'utility-types'; +import type {Assign, Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types/base'; -import type {QueryCustomOptions, QueryDataAdditionalOptions} from '../../types/options'; +import type {QueryDataAdditionalOptions} from '../../types/options'; export type QueryObserverExtendedOptions< TQueryFnData = unknown, @@ -18,11 +18,10 @@ export type QueryObserverExtendedOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = never, -> = Overwrite< +> = Assign< QueryObserverOptions, QueryDataAdditionalOptions -> & - QueryCustomOptions; +>; export type PlainQueryDataSource = DataSource< QueryDataSourceContext, diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index f7722c3..3809397 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -15,11 +15,10 @@ export interface QueryDataAdditionalOptions< * It is recommended to use idle as query parameters to control query state. */ enabled?: boolean; -} - -export interface QueryCustomOptions { /** Normalization configuration (enable/disable) */ normalize?: boolean; /** Optimistic data update configuration */ optimistic?: boolean | OptimisticConfig; + /** Invalidate data configuration */ + invalidate?: boolean; } diff --git a/src/react-query/utils/normalize.ts b/src/react-query/utils/normalize.ts index 9b384d9..53d2f2b 100644 --- a/src/react-query/utils/normalize.ts +++ b/src/react-query/utils/normalize.ts @@ -3,17 +3,46 @@ import type {QueryClient, QueryKey} from '@tanstack/react-query'; import type {Normalizer, NormalizerConfig} from '../../core/types/Normalizer'; import type {OptimisticConfig} from '../types/normalizer'; -import type {QueryCustomOptions} from '../types/options'; +import type {QueryDataAdditionalOptions} from '../types/options'; + +interface QueryNormalizeOptions { + normalize?: boolean; + optimistic?: boolean | OptimisticConfig; + invalidate?: boolean; +} + +const shouldInvalidateData = (globalConfig?: boolean, mutationConfig?: boolean): boolean => { + if (mutationConfig === false) { + return false; + } + + if (!globalConfig) { + return false; + } + + return true; +}; const shouldUpdateOptimistically = ( - providerConfig?: boolean | OptimisticConfig, + globalConfig?: boolean | OptimisticConfig, mutationConfig?: boolean | OptimisticConfig, ): boolean => { - if (providerConfig || mutationConfig) { + if (mutationConfig === false) { + return false; + } + + if ( + (typeof mutationConfig === 'boolean' && mutationConfig) || + (typeof mutationConfig === 'object' && mutationConfig) + ) { return true; } - return false; + if (!globalConfig) { + return false; + } + + return true; }; const getOptimisticProps = ( @@ -39,6 +68,7 @@ export const createQueryNormalizer = ( queryClient: QueryClient, config: boolean | NormalizerConfig | undefined, optimisticUpdate: (mutationData: Data) => void, + invalidateData: (data: Data) => void, ) => { if (!normalizer || !config) { return undefined; @@ -47,6 +77,9 @@ export const createQueryNormalizer = ( const globalOptimistic = typeof config === 'object' && 'optimistic' in config ? config.optimistic : false; + const globalInvalidateData = + typeof config === 'object' && 'invalidate' in config ? config.invalidate : false; + let unsubscribeQueryCache: (() => void) | null = null; let unsubscribeMutationCache: (() => void) | null = null; @@ -81,9 +114,9 @@ export const createQueryNormalizer = ( // Check if the query should be normalized // At this point options are already merged (DataSource + Hook) - const queryOptions = event.query.options as QueryCustomOptions; + const queryOptions = event.query.options as QueryDataAdditionalOptions; - const queryNormalize = queryOptions?.normalize; + const queryNormalize = queryOptions?.normalize ?? true; if (!queryNormalize) { return; @@ -103,10 +136,23 @@ export const createQueryNormalizer = ( // 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 QueryCustomOptions | undefined; + const mutationOptions = event.mutation?.options as + | QueryNormalizeOptions + | undefined; const mutationQueryNormalize = mutationOptions?.normalize; const mutationQueryOptimistic = mutationOptions?.optimistic; + const mutationQueryInvalidateData = mutationOptions?.invalidate; + + if (shouldInvalidateData(globalInvalidateData, mutationQueryInvalidateData)) { + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.action.data + ) { + invalidateData(event.action.data as Data); + } + } if ( !mutationQueryNormalize || From cb5a8dd77f7e2b2d8b7119b7ea49c26130cb58ea Mon Sep 17 00:00:00 2001 From: NasgulNexus Date: Wed, 3 Dec 2025 11:06:53 +0100 Subject: [PATCH 8/8] fix: fix types --- src/core/index.ts | 2 +- src/core/types/Normalizer.ts | 7 ++++++- src/react-query/index.ts | 2 +- src/react-query/types/normalizer.ts | 7 ------- src/react-query/types/options.ts | 3 ++- src/react-query/utils/normalize.ts | 3 +-- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 5591714..737ad3a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -18,7 +18,7 @@ export type { export type {DataManager} from './types/DataManager'; export type {DataLoaderStatus} from './types/DataLoaderStatus'; export type {InvalidateRepeatOptions, InvalidateOptions} from './types/DataManagerOptions'; -export type {Normalizer, NormalizerConfig} from './types/Normalizer'; +export type {Normalizer, NormalizerConfig, OptimisticConfig} from './types/Normalizer'; export {idle} from './constants'; diff --git a/src/core/types/Normalizer.ts b/src/core/types/Normalizer.ts index 3eb0ac9..092d37f 100644 --- a/src/core/types/Normalizer.ts +++ b/src/core/types/Normalizer.ts @@ -1,7 +1,12 @@ import type {NormalizerConfig as NormalizeConfigBase} from '@normy/core'; import type {Data, NormalizedData} from '@normy/core/types/types'; -import type {OptimisticConfig} from '../../react-query/types/normalizer'; +export interface OptimisticConfig { + /** Automatically calculate rollback data, defaults to true */ + autoCalculateRollback?: boolean; + /** Whether debug logging is enabled */ + devLogging?: boolean; +} export interface NormalizerConfig extends NormalizeConfigBase { initialData?: NormalizedData; diff --git a/src/react-query/index.ts b/src/react-query/index.ts index 05903ec..9104eb6 100644 --- a/src/react-query/index.ts +++ b/src/react-query/index.ts @@ -24,4 +24,4 @@ export {ClientDataManager} from './ClientDataManager'; export {DataSourceProvider} from './DataSourceProvider'; -export type {OptimisticConfig, QueryNormalizer} from './types/normalizer'; +export type {QueryNormalizer} from './types/normalizer'; diff --git a/src/react-query/types/normalizer.ts b/src/react-query/types/normalizer.ts index 26e0096..f2ee744 100644 --- a/src/react-query/types/normalizer.ts +++ b/src/react-query/types/normalizer.ts @@ -1,12 +1,5 @@ import type {Data, NormalizedData} from '@normy/core/types/types'; -export interface OptimisticConfig { - /** Automatically calculate rollback data, defaults to true */ - autoCalculateRollback?: boolean; - /** Whether debug logging is enabled */ - devLogging?: boolean; -} - export interface QueryNormalizer { /** Get normalized data */ getNormalizedData: () => NormalizedData; diff --git a/src/react-query/types/options.ts b/src/react-query/types/options.ts index 3809397..f04eebe 100644 --- a/src/react-query/types/options.ts +++ b/src/react-query/types/options.ts @@ -1,6 +1,7 @@ import type {DefaultError, QueryKey} from '@tanstack/react-query'; -import type {OptimisticConfig} from './normalizer'; +import type {OptimisticConfig} from '../../core/types/Normalizer'; + import type {RefetchInterval} from './refetch-interval'; export interface QueryDataAdditionalOptions< diff --git a/src/react-query/utils/normalize.ts b/src/react-query/utils/normalize.ts index 53d2f2b..1ce56b0 100644 --- a/src/react-query/utils/normalize.ts +++ b/src/react-query/utils/normalize.ts @@ -1,8 +1,7 @@ import type {Data} from '@normy/core'; import type {QueryClient, QueryKey} from '@tanstack/react-query'; -import type {Normalizer, NormalizerConfig} from '../../core/types/Normalizer'; -import type {OptimisticConfig} from '../types/normalizer'; +import type {Normalizer, NormalizerConfig, OptimisticConfig} from '../../core/types/Normalizer'; import type {QueryDataAdditionalOptions} from '../types/options'; interface QueryNormalizeOptions {