From 77947afddb9165c9bbb7f3e58e66e339ab1e16d0 Mon Sep 17 00:00:00 2001 From: Kevin Bonnoron <2421321+KevinBonnoron@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:39:25 +0000 Subject: [PATCH 1/4] feat: add transformers functionality --- CLAUDE.md | 14 +- README.md | 209 ++++++++++++++++++++- examples/README.md | 2 +- src/hooks/useCollection.ts | 27 ++- src/hooks/useRecord.ts | 34 ++-- src/index.ts | 1 + src/lib/utils.ts | 23 ++- src/transformers/date.transformer.ts | 20 ++ src/transformers/index.ts | 1 + src/types/index.ts | 2 + src/types/record-transformer.type.ts | 10 + src/types/useCollection.type.ts | 2 +- src/types/useCommon.type.ts | 17 +- src/types/useRecord.type.ts | 2 +- tests/hooks/useCollection.test.tsx | 246 +++++++++++++++++++++++++ tests/hooks/useRecord.test.tsx | 265 +++++++++++++++++++++++++++ tests/setup.ts | 6 + vite.config.ts | 14 -- vitest.config.ts | 19 ++ 19 files changed, 867 insertions(+), 47 deletions(-) create mode 100644 src/transformers/date.transformer.ts create mode 100644 src/transformers/index.ts create mode 100644 src/types/record-transformer.type.ts create mode 100644 tests/setup.ts create mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index b0e8cbf..dfe333d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,11 +45,17 @@ This pattern follows TanStack Query conventions with `isLoading`, `isSuccess`, ` **Request Cancellation**: The `requestKey` parameter is passed directly to PocketBase SDK and can be used with `pb.cancelRequest(key)` to abort pending requests. Useful for handling search queries, preventing race conditions, or cleaning up on unmount. +**Data Transformers**: Both `useCollection` and `useRecord` support a `transformers` option that accepts an array of `RecordTransformer` functions. Transformers are applied sequentially (via `reduce()`) to transform records before they're returned to components. By default, both hooks automatically apply `dateTransformer()` which converts `created` and `updated` fields from ISO strings to `Date` objects. Transformers are applied to: +- Initial fetch results +- Real-time subscription events (create, update) + +Error handling: If a transformer throws, `applyTransformers()` catches the error, logs to console, and returns the original record. Transformers use `useRef` to maintain stable references and avoid re-creating subscriptions. + ### Hook Architecture -**useCollection**: Fetches collection data with `getFullList()` or `getList()` based on `fetchAll` option. Handles real-time updates by applying create/update/delete actions to the current data array and re-sorting if needed. +**useCollection**: Fetches collection data with `getFullList()` or `getList()` based on `fetchAll` option. Handles real-time updates by applying create/update/delete actions to the current data array and re-sorting if needed. Applies transformers to all records (both initial fetch and real-time updates). -**useRecord**: Fetches a single record by ID using `getOne()` or by filter using `getFirstListItem()`. Subscribes to that specific record's changes. +**useRecord**: Fetches a single record by ID using `getOne()` or by filter using `getFirstListItem()`. Subscribes to that specific record's changes. Applies transformers to the record (both initial fetch and real-time updates). **useAuth**: Manages authentication state by listening to `pb.authStore.onChange()`. Provides `signIn.email()`, `signIn.social()`, `signUp.email()`, and `signOut()` methods. Returns the authenticated user via `pb.authStore.model`. @@ -66,7 +72,8 @@ This pattern follows TanStack Query conventions with `isLoading`, `isSuccess`, ` - `src/context/PocketBaseContext.tsx` - React Context definition and `usePocketBaseContext()` hook - `src/providers/PocketBaseProvider.tsx` - Provider component that wraps the app - `src/hooks/` - All custom hooks (`useAuth`, `useCollection`, `useRecord`, `useCreateMutation`, `useUpdateMutation`, `useDeleteMutation`, `usePocketBase`) -- `src/lib/utils.ts` - Shared utilities (`createQueryResult`, `sortRecords`) +- `src/lib/utils.ts` - Shared utilities (`sortRecords`, `applyTransformers`) +- `src/transformers/` - Built-in transformers (`dateTransformer`) - `src/types/index.ts` - TypeScript type definitions for hook options and results - `tests/hooks/` - Test files mirroring the hooks structure @@ -135,4 +142,5 @@ This pattern follows TanStack Query conventions with `isLoading`, `isSuccess`, ` - **Conditional Fetching**: Use `enabled: false` to disable data fetching (similar to TanStack Query) - **Error Handling**: All hooks expose `isError` and `error` for graceful error states - **Request Cancellation**: Use `requestKey` option in `useCollection` and `useRecord` to enable request cancellation via `pb.cancelRequest(key)` +- **Data Transformers**: By default, `useCollection` and `useRecord` apply `dateTransformer()` to convert `created` and `updated` fields to `Date` objects. Users can provide custom transformers via the `transformers` option or disable all transformations with `transformers: []`. Transformers are stored in `useRef` to maintain stable references and prevent re-renders. - **React StrictMode**: The library handles React StrictMode's double-mounting correctly with cancellation flags to prevent auto-cancelled requests from updating state. If you encounter infinite loops or auto-cancellation issues, ensure dependencies are stable (use `useRef` for objects/arrays passed as options) diff --git a/README.md b/README.md index 0abf5ea..a947f02 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ Fetches and manages a collection of data with real-time subscriptions. - `fetchAll`: Boolean to use `getFullList` (true) or `getList` (false) (default: `true`) - `subscribe`: Boolean to enable/disable real-time subscriptions (default: `true`) - `requestKey`: Optional key passed to PocketBase for request cancellation (optional) + - `transformers`: Array of transformer functions to apply to records (default: `[dateTransformer()]`) **Returns:** - `data`: Array of records or null @@ -411,6 +412,7 @@ Fetches and manages a single record with real-time updates. Can fetch by ID or b - `fields`: Fields to return - `defaultValue`: Default value while loading - `requestKey`: Optional key passed to PocketBase for request cancellation (optional) + - `transformers`: Array of transformer functions to apply to the record (default: `[dateTransformer()]`) **Returns:** - `data`: Record object or null @@ -627,7 +629,7 @@ interface Post extends RecordModel { status: 'draft' | 'published' | 'archived'; author: string; // relation to users tags: string[]; - published_at?: string; + publishedAt?: Date; } // Use with custom types @@ -640,6 +642,211 @@ const { mutate: updatePost } = useUpdateMutation('posts'); const { mutate: deletePost } = useDeleteMutation('posts'); ``` +## Data Transformers + +The library includes a powerful data transformation system that allows you to automatically transform data received from PocketBase before it reaches your components. + +### Default Date Transformation + +By default, both `useCollection` and `useRecord` automatically apply a `dateTransformer` that converts ISO date strings to JavaScript `Date` objects for the `created` and `updated` fields. + +```tsx +import { useCollection } from 'pocketbase-react-hooks'; + +interface Post extends RecordModel { + title: string; + content: string; + created: Date; // Automatically transformed from string to Date + updated: Date; // Automatically transformed from string to Date +} + +function PostsList() { + const { data: posts } = useCollection('posts'); + + return ( +
+ {posts?.map(post => ( +
+

{post.title}

+

Created: {post.created.toLocaleDateString()}

+

Updated: {post.updated.toLocaleString()}

+
+ ))} +
+ ); +} +``` + +### Custom Date Fields + +You can configure the `dateTransformer` to transform additional date fields: + +```tsx +import { useCollection, dateTransformer } from 'pocketbase-react-hooks'; + +interface Post extends RecordModel { + title: string; + publishedAt: Date; + created: Date; + updated: Date; +} + +function PostsList() { + const { data: posts } = useCollection('posts', { + transformers: [ + dateTransformer(['created', 'updated', 'publishedAt']) + ] + }); + + return ( +
+ {posts?.map(post => ( +
+

{post.title}

+

Published: {post.publishedAt.toLocaleDateString()}

+
+ ))} +
+ ); +} +``` + +### Custom Transformers + +Create your own transformers to apply custom data transformations: + +```tsx +import { useCollection, dateTransformer } from 'pocketbase-react-hooks'; +import type { RecordTransformer } from 'pocketbase-react-hooks'; + +interface Post extends RecordModel { + title: string; + content: string; + status: 'draft' | 'published' | 'archived'; +} + +const uppercaseTransformer: RecordTransformer = (record) => ({ + ...record, + title: record.title.toUpperCase(), +}); + +const statusNormalizer: RecordTransformer = (record) => ({ + ...record, + status: record.status.toLowerCase() as 'draft' | 'published' | 'archived', +}); + +function PostsList() { + const { data: posts } = useCollection('posts', { + transformers: [ + dateTransformer(), + uppercaseTransformer, + statusNormalizer, + ] + }); + + return ( +
+ {posts?.map(post => ( +
+

{post.title}

+ {post.status} +
+ ))} +
+ ); +} +``` + +### Transformer Composition + +Transformers are applied in sequence, allowing you to compose multiple transformations: + +```tsx +import { useCollection, dateTransformer } from 'pocketbase-react-hooks'; + +const trimWhitespace: RecordTransformer = (record) => ({ + ...record, + title: record.title.trim(), + content: record.content.trim(), +}); + +const addComputedFields: RecordTransformer = (record) => ({ + ...record, + excerpt: record.content.substring(0, 100) + '...', + wordCount: record.content.split(' ').length, +}); + +function PostsList() { + const { data: posts } = useCollection('posts', { + transformers: [ + dateTransformer(), + trimWhitespace, + addComputedFields, + ] + }); + + return
{/* ... */}
; +} +``` + +### Disabling Transformers + +If you don't want any transformations (including the default date transformer), pass an empty array: + +```tsx +import { useCollection } from 'pocketbase-react-hooks'; + +function PostsList() { + const { data: posts } = useCollection('posts', { + transformers: [] // No transformations applied + }); + + return
{/* ... */}
; +} +``` + +### Error Handling + +Transformers include built-in error handling. If a transformer throws an error, the original record is returned unchanged, and the error is logged to the console: + +```tsx +const faultyTransformer: RecordTransformer = (record) => { + if (!record.title) { + throw new Error('Title is required'); + } + return record; +}; + +function PostsList() { + const { data: posts } = useCollection('posts', { + transformers: [ + dateTransformer(), + faultyTransformer, // If this fails, original record is returned + ] + }); + + return
{/* ... */}
; +} +``` + +### Real-time Updates + +Transformers are automatically applied to: +- Initial data fetch +- Real-time subscription events (create, update) + +This ensures data consistency across all updates: + +```tsx +function PostsList() { + const { data: posts } = useCollection('posts', { + transformers: [dateTransformer()], + }); + + return
{/* All posts have Date objects, even from real-time updates */}
; +} +``` + ## Real-time Features All hooks support real-time updates through PocketBase subscriptions: diff --git a/examples/README.md b/examples/README.md index 737ce96..76178e7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,7 +29,7 @@ First, here's an example of a PocketBase database schema you might use: - `status` (select: draft, published, archived) - `author` (relation to users) - `tags` (json array) -- `published_at` (date, optional) +- `publishedAt` (date, optional) **comments** (Regular collection) - `content` (text, required) diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index dd70494..ac4bf07 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -1,6 +1,7 @@ import type { RecordModel } from 'pocketbase'; -import { useEffect, useMemo } from 'react'; -import { sortRecords } from '../lib/utils'; +import { useEffect, useMemo, useRef } from 'react'; +import { applyTransformers, sortRecords } from '../lib/utils'; +import { dateTransformer } from '../transformers'; import type { UseCollectionOptions, UseCollectionResult } from '../types'; import { useQueryState } from './internal/useQueryState'; import { usePocketBase } from './usePocketBase'; @@ -41,6 +42,9 @@ export function useCollection(collectionName: string initialLoading: enabled, }); + const transformers = useRef(options.transformers ?? [dateTransformer()]); + transformers.current = options.transformers ?? [dateTransformer()]; + useEffect(() => { if (!enabled) { queryState.reset(); @@ -48,7 +52,7 @@ export function useCollection(collectionName: string } return queryState.executeFetch(async () => { - let result: Record[]; + let result: Record[] | null; if (fetchAll) { result = await recordService.getFullList({ ...(page && { page }), @@ -60,39 +64,42 @@ export function useCollection(collectionName: string ...(requestKey && { requestKey }), }); } else { - const listResult = await recordService.getList(page ?? 1, perPage ?? 20, { + const { items } = await recordService.getList(page ?? 1, perPage ?? 20, { ...(filter && { filter }), ...(sort && { sort }), ...(expand && { expand }), ...(fields && { fields }), ...(requestKey && { requestKey }), }); - result = listResult.items; + result = items; } - return result; + + return result ? result.map((record) => applyTransformers(record, transformers.current)) : []; }, 'Failed to fetch collection'); }, [enabled, recordService, page, perPage, filter, sort, expand, fields, fetchAll, requestKey, queryState.reset, queryState.executeFetch]); useEffect(() => { if (!enabled || !subscribe) return; - const unsubscribe = recordService.subscribe( + const unsubscribe = recordService.subscribe( '*', (e) => { queryState.setData((currentData) => { let newData = currentData ? [...currentData] : []; switch (e.action) { case 'create': - newData.push(e.record as Record); + { + newData.push(applyTransformers(e.record, transformers.current)); + } break; case 'update': { const updateIndex = newData.findIndex(({ id }) => id === e.record.id); if (updateIndex !== -1) { - newData[updateIndex] = e.record as Record; + newData[updateIndex] = applyTransformers(e.record, transformers.current); } else { - newData.push(e.record as Record); + newData.push(applyTransformers(e.record, transformers.current)); } } break; diff --git a/src/hooks/useRecord.ts b/src/hooks/useRecord.ts index e1d42c5..26e3533 100644 --- a/src/hooks/useRecord.ts +++ b/src/hooks/useRecord.ts @@ -1,5 +1,7 @@ import type { RecordModel } from 'pocketbase'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { applyTransformers } from '../lib/utils'; +import { dateTransformer } from '../transformers'; import type { UseRecordOptions, UseRecordResult } from '../types'; import { useQueryState } from './internal/useQueryState'; import { usePocketBase } from './usePocketBase'; @@ -42,19 +44,22 @@ export function useRecord(collectionName: string, re initialLoading: !!recordIdOrFilter, }); + const transformers = useRef(options.transformers ?? [dateTransformer()]); + transformers.current = options.transformers ?? [dateTransformer()]; + const fetcher = useCallback(async (): Promise => { if (!recordIdOrFilter) { throw new Error('Record ID or filter is required'); } if (isId) { - return await recordService.getOne(recordIdOrFilter, { + return await recordService.getOne(recordIdOrFilter, { ...(expand && { expand }), ...(fields && { fields }), ...(requestKey && { requestKey }), }); } else { - return await recordService.getFirstListItem(recordIdOrFilter, { + return await recordService.getFirstListItem(recordIdOrFilter, { ...(expand && { expand }), ...(fields && { fields }), ...(requestKey && { requestKey }), @@ -68,22 +73,25 @@ export function useRecord(collectionName: string, re return; } - return queryState.executeFetch(fetcher, 'Failed to fetch record'); + return queryState.executeFetch(async () => { + const record = await fetcher(); + return applyTransformers(record, transformers.current); + }, 'Failed to fetch record'); }, [recordIdOrFilter, fetcher, queryState.reset, queryState.executeFetch]); useEffect(() => { - if (!recordIdOrFilter || !queryState.data) return; + if (!recordIdOrFilter) return; if (isId) { - const unsubscribe = recordService.subscribe( + const unsubscribe = recordService.subscribe( recordIdOrFilter, (e) => { switch (e.action) { case 'update': - queryState.setData(e.record as Record); + queryState.setData(() => applyTransformers(e.record, transformers.current)); break; case 'delete': - queryState.setData(null); + queryState.setData(() => null); break; } }, @@ -97,18 +105,16 @@ export function useRecord(collectionName: string, re unsubscribe.then((unsub) => unsub()); }; } else { - const unsubscribe = recordService.subscribe( + const unsubscribe = recordService.subscribe( '*', (e) => { switch (e.action) { case 'create': case 'update': - queryState.setData(e.record as Record); + queryState.setData(() => applyTransformers(e.record, transformers.current)); break; case 'delete': - if (queryState.data && e.record.id === queryState.data.id) { - queryState.setData(null); - } + queryState.setData((current) => (current && current.id === e.record.id ? null : current)); break; } }, @@ -123,7 +129,7 @@ export function useRecord(collectionName: string, re unsubscribe.then((unsub) => unsub()); }; } - }, [recordService, recordIdOrFilter, expand, isId, queryState.data, queryState.setData, requestKey]); + }, [recordService, recordIdOrFilter, expand, isId, queryState.setData, requestKey]); return queryState.result; } diff --git a/src/index.ts b/src/index.ts index 9354bcc..92afc38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { } from './context/PocketBaseContext'; export { useAuth, useCollection, useCreateMutation, useDeleteMutation, usePocketBase, useRecord, useUpdateMutation } from './hooks'; export { PocketBaseProvider } from './providers/PocketBaseProvider'; +export { dateTransformer } from './transformers'; export type { AuthProvider, QueryResult, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3bb9e34..14f1e4f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,9 +1,10 @@ import type { RecordModel } from 'pocketbase'; +import type { RecordTransformer } from '../types'; export function sortRecords(records: T[], sortString: string): T[] { const [direction, field] = sortString.startsWith('-') ? ['desc', sortString.slice(1)] : ['asc', sortString.startsWith('+') ? sortString.slice(1) : sortString]; - return records.sort((a, b) => { + return [...records].sort((a, b) => { const aVal = (a as Record)[field]; const bVal = (b as Record)[field]; @@ -26,3 +27,23 @@ export function sortRecords(records: T[], sortString: str return 0; }); } + +/** + * Applies transformers to a record with error handling. + * If any transformer fails, the error is logged to the console and the record is returned unchanged. + * + * @template T - The record type extending RecordModel + * @param record - The record to transform + * @param transformers - Array of transformer functions + * @returns The transformed record or original if transformation fails + */ +export const applyTransformers = (record: T, transformers: RecordTransformer[]): T => { + return transformers.reduce((data, transformer) => { + try { + return transformer(data); + } catch (error) { + console.error('Error applying transformers:', error); + return data; + } + }, record); +}; diff --git a/src/transformers/date.transformer.ts b/src/transformers/date.transformer.ts new file mode 100644 index 0000000..e91ae30 --- /dev/null +++ b/src/transformers/date.transformer.ts @@ -0,0 +1,20 @@ +import type { RecordModel } from 'pocketbase'; +import type { RecordTransformer } from '../types'; + +function isISODateString(str: string): boolean { + return /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(str); +} + +export function dateTransformer(fields: (keyof Record)[] = ['created', 'updated']): RecordTransformer { + return (record) => { + const transformed: Record = { ...record }; + fields.forEach((field) => { + const value = transformed[field]; + if (typeof value === 'string' && isISODateString(value)) { + transformed[field] = new Date(value) as Record[typeof field]; + } + }); + + return transformed; + }; +} diff --git a/src/transformers/index.ts b/src/transformers/index.ts new file mode 100644 index 0000000..142ca14 --- /dev/null +++ b/src/transformers/index.ts @@ -0,0 +1 @@ +export * from './date.transformer'; diff --git a/src/types/index.ts b/src/types/index.ts index 4f368c7..b25ee23 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,9 @@ export * from './auth-provider.type'; export * from './query-result.type'; +export * from './record-transformer.type'; export * from './useAuth.type'; export * from './useCollection.type'; +export * from './useCommon.type'; export * from './useCreateMutation.type'; export * from './useDeleteMutation.type'; export * from './useRecord.type'; diff --git a/src/types/record-transformer.type.ts b/src/types/record-transformer.type.ts new file mode 100644 index 0000000..c44537a --- /dev/null +++ b/src/types/record-transformer.type.ts @@ -0,0 +1,10 @@ +import type { RecordModel } from 'pocketbase'; + +/** + * A function that transforms a record into a new record. + * + * @template T - The record type extending RecordModel + * @param record - The record to transform + * @returns The transformed record + */ +export type RecordTransformer = (record: T) => T; diff --git a/src/types/useCollection.type.ts b/src/types/useCollection.type.ts index 66e3e38..de1b9d8 100644 --- a/src/types/useCollection.type.ts +++ b/src/types/useCollection.type.ts @@ -7,7 +7,7 @@ import type { UseCommonOptions } from './useCommon.type'; * * @template T - The record type extending RecordModel */ -export interface UseCollectionOptions extends UseCommonOptions { +export interface UseCollectionOptions extends UseCommonOptions { /** * PocketBase filter query (e.g., 'published = true') */ diff --git a/src/types/useCommon.type.ts b/src/types/useCommon.type.ts index 6c4ddc5..98ef26e 100644 --- a/src/types/useCommon.type.ts +++ b/src/types/useCommon.type.ts @@ -1,4 +1,19 @@ -export interface UseCommonOptions { +import type { RecordModel } from 'pocketbase'; +import type { RecordTransformer } from './record-transformer.type'; + +export interface UseCommonOptions { + /** + * Expand related records (e.g., 'author,comments') + */ expand?: string; + + /** + * Select specific fields (e.g., 'name,email') + */ fields?: string; + + /** + * Transformers to apply to the data + */ + transformers?: RecordTransformer[]; } diff --git a/src/types/useRecord.type.ts b/src/types/useRecord.type.ts index 6ef5f9d..5b029a1 100644 --- a/src/types/useRecord.type.ts +++ b/src/types/useRecord.type.ts @@ -7,7 +7,7 @@ import type { UseCommonOptions } from './useCommon.type'; * * @template T - The record type extending RecordModel */ -export interface UseRecordOptions extends UseCommonOptions { +export interface UseRecordOptions extends UseCommonOptions { /** * Default value to use before data is loaded */ diff --git a/tests/hooks/useCollection.test.tsx b/tests/hooks/useCollection.test.tsx index 8fb39b3..81baad7 100644 --- a/tests/hooks/useCollection.test.tsx +++ b/tests/hooks/useCollection.test.tsx @@ -625,4 +625,250 @@ describe('useCollection', () => { expect(mockSubscribe).toHaveBeenCalled(); }); }); + + describe('transformers', () => { + it('should apply default dateTransformer to created and updated fields', async () => { + const mockData = [ + { + id: '1', + title: 'Test 1', + collectionId: 'test', + collectionName: 'test', + created: '2024-01-01T10:00:00.123Z', + updated: '2024-01-01T11:00:00.456Z', + }, + ]; + + mockGetFullList.mockResolvedValue(mockData); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook(() => useCollection('test'), { wrapper }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.data?.[0]?.created).toBeInstanceOf(Date); + expect(result.current.data?.[0]?.updated).toBeInstanceOf(Date); + expect((result.current.data?.[0]?.created as Date).toISOString()).toBe('2024-01-01T10:00:00.123Z'); + expect((result.current.data?.[0]?.updated as Date).toISOString()).toBe('2024-01-01T11:00:00.456Z'); + }); + + it('should apply transformers to fetched data', async () => { + const mockData = [ + { + id: '1', + title: 'Test 1', + collectionId: 'test', + collectionName: 'test', + created: '2024-01-01T10:00:00Z', + updated: '2024-01-01T11:00:00Z', + }, + ]; + + mockGetFullList.mockResolvedValue(mockData); + + const customTransformer = (record: any) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useCollection('test', { + transformers: [customTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.data).toEqual([ + { + id: '1', + title: 'TEST 1', + collectionId: 'test', + collectionName: 'test', + created: '2024-01-01T10:00:00Z', + updated: '2024-01-01T11:00:00Z', + }, + ]); + }); + + it('should apply transformers to real-time create events', async () => { + const initialData = [{ id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }]; + const newRecord = { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }; + + mockGetFullList.mockResolvedValue(initialData); + + let subscriptionCallback: (event: { action: string; record: RecordModel }) => void; + mockSubscribe.mockImplementation((_pattern: string, callback: (event: { action: string; record: RecordModel }) => void) => { + subscriptionCallback = callback; + return Promise.resolve(() => {}); + }); + + const customTransformer = (record: any) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useCollection('test', { + transformers: [customTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + subscriptionCallback({ + action: 'create', + record: newRecord, + }); + }); + + expect(result.current.data).toEqual([ + { id: '1', title: 'TEST 1', collectionId: 'test', collectionName: 'test' }, + { id: '2', title: 'TEST 2', collectionId: 'test', collectionName: 'test' }, + ]); + }); + + it('should apply transformers to real-time update events', async () => { + const initialData = [ + { id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }, + { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }, + ]; + const updatedRecord = { id: '1', title: 'Updated Test 1', collectionId: 'test', collectionName: 'test' }; + + mockGetFullList.mockResolvedValue(initialData); + + let subscriptionCallback: (event: { action: string; record: RecordModel }) => void; + mockSubscribe.mockImplementation((_pattern: string, callback: (event: { action: string; record: RecordModel }) => void) => { + subscriptionCallback = callback; + return Promise.resolve(() => {}); + }); + + const customTransformer = (record: any) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useCollection('test', { + transformers: [customTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + subscriptionCallback({ + action: 'update', + record: updatedRecord, + }); + }); + + expect(result.current.data).toEqual([ + { id: '1', title: 'UPDATED TEST 1', collectionId: 'test', collectionName: 'test' }, + { id: '2', title: 'TEST 2', collectionId: 'test', collectionName: 'test' }, + ]); + }); + + it('should handle transformer errors gracefully', async () => { + const mockData = [{ id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }]; + + // Mock getFullList to return data without transformers applied + mockGetFullList.mockResolvedValue(mockData); + + let subscriptionCallback: (event: { action: string; record: RecordModel }) => void; + mockSubscribe.mockImplementation((_pattern: string, callback: (event: { action: string; record: RecordModel }) => void) => { + subscriptionCallback = callback; + return Promise.resolve(() => {}); + }); + + const faultyTransformer = (record: any) => { + throw new Error('Transformer error'); + }; + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useCollection('test', { + transformers: [faultyTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // The initial data should be returned without transformation when transformer fails + expect(result.current.data).toEqual([{ id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }]); + expect(result.current.isError).toBe(false); + + await act(async () => { + subscriptionCallback({ + action: 'create', + record: { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }, + }); + }); + + // Should fallback to original record when transformer fails in real-time + expect(result.current.data).toEqual([ + { id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }, + { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }, + ]); + }); + + it('should apply multiple transformers in sequence', async () => { + const mockData = [{ id: '1', title: 'test', collectionId: 'test', collectionName: 'test' }]; + + mockGetFullList.mockResolvedValue(mockData); + + const transformer1 = (record: any) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const transformer2 = (record: any) => ({ + ...record, + title: record.title + '!', + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useCollection('test', { + transformers: [transformer1, transformer2], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.data).toEqual([{ id: '1', title: 'TEST!', collectionId: 'test', collectionName: 'test' }]); + }); + }); }); diff --git a/tests/hooks/useRecord.test.tsx b/tests/hooks/useRecord.test.tsx index 82be465..c74e0e7 100644 --- a/tests/hooks/useRecord.test.tsx +++ b/tests/hooks/useRecord.test.tsx @@ -276,6 +276,271 @@ describe('useRecord', () => { expect(result.current.data).toEqual(initialRecord); }); + describe('transformers', () => { + it('should apply default dateTransformer to created and updated fields', async () => { + const mockRecord: RecordModel = { + id: '1', + title: 'Test Record', + collectionId: 'test', + collectionName: 'test', + created: '2024-01-01T10:00:00.123Z', + updated: '2024-01-01T11:00:00.456Z', + }; + mockGetOne.mockResolvedValue(mockRecord); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook(() => useRecord('test', '1'), { wrapper }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.data?.created).toBeInstanceOf(Date); + expect(result.current.data?.updated).toBeInstanceOf(Date); + expect((result.current.data?.created as Date).toISOString()).toBe('2024-01-01T10:00:00.123Z'); + expect((result.current.data?.updated as Date).toISOString()).toBe('2024-01-01T11:00:00.456Z'); + }); + + it('should apply transformers to fetched data', async () => { + const mockRecord: RecordModel = { + id: '1', + title: 'Test Record', + collectionId: 'test', + collectionName: 'test', + created: '2021-01-01T00:00:00.000Z', + updated: '2021-01-01T00:00:00.000Z', + }; + mockGetOne.mockResolvedValue(mockRecord); + + const customTransformer = (record: RecordModel) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useRecord('test', '1', { + transformers: [customTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.data).toEqual({ + ...mockRecord, + title: 'TEST RECORD', + }); + }); + + it('should apply transformers to real-time update events (by ID)', async () => { + const initialRecord: RecordModel = { + id: '1', + title: 'Test Record', + collectionId: 'test', + collectionName: 'test', + }; + const updatedRecord: RecordModel = { + id: '1', + title: 'Updated Record', + collectionId: 'test', + collectionName: 'test', + }; + + mockGetOne.mockResolvedValue(initialRecord); + + let subscriptionCallback: (event: { action: string; record: RecordModel }) => void; + mockSubscribe.mockImplementation((_pattern: string, callback: (event: { action: string; record: RecordModel }) => void) => { + subscriptionCallback = callback; + return Promise.resolve(() => {}); + }); + + const customTransformer = (record: RecordModel) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useRecord('test', '1', { + transformers: [customTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + subscriptionCallback({ + action: 'update', + record: updatedRecord, + }); + }); + + expect(result.current.data).toEqual({ + ...updatedRecord, + title: 'UPDATED RECORD', + }); + }); + + it('should apply transformers to real-time update events (by filter)', async () => { + const initialRecord: RecordModel = { + id: '1', + title: 'Test Record', + collectionId: 'test', + collectionName: 'test', + }; + const updatedRecord: RecordModel = { + id: '1', + title: 'Updated Record', + collectionId: 'test', + collectionName: 'test', + }; + + mockGetFirstListItem.mockResolvedValue(initialRecord); + + let subscriptionCallback: (event: { action: string; record: RecordModel }) => void; + mockSubscribe.mockImplementation((_pattern: string, callback: (event: { action: string; record: RecordModel }) => void) => { + subscriptionCallback = callback; + return Promise.resolve(() => {}); + }); + + const customTransformer = (record: RecordModel) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useRecord('test', 'title = "Test Record"', { + transformers: [customTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + subscriptionCallback({ + action: 'update', + record: updatedRecord, + }); + }); + + expect(result.current.data).toEqual({ + ...updatedRecord, + title: 'UPDATED RECORD', + }); + }); + + it('should handle transformer errors gracefully', async () => { + const mockRecord: RecordModel = { + id: '1', + title: 'Test Record', + collectionId: 'test', + collectionName: 'test', + }; + + mockGetOne.mockResolvedValue(mockRecord); + + let subscriptionCallback: (event: { action: string; record: RecordModel }) => void; + mockSubscribe.mockImplementation((_pattern: string, callback: (event: { action: string; record: RecordModel }) => void) => { + subscriptionCallback = callback; + return Promise.resolve(() => {}); + }); + + const faultyTransformer = (record: RecordModel) => { + throw new Error('Transformer error'); + }; + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useRecord('test', '1', { + transformers: [faultyTransformer], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // The initial data should be returned without transformation when transformer fails + expect(result.current.data).toEqual(mockRecord); + expect(result.current.isError).toBe(false); + + await act(async () => { + subscriptionCallback({ + action: 'update', + record: { id: '1', title: 'Updated Record', collectionId: 'test', collectionName: 'test' }, + }); + }); + + // Should fallback to original record when transformer fails in real-time + expect(result.current.data).toEqual({ + id: '1', + title: 'Updated Record', + collectionId: 'test', + collectionName: 'test', + }); + }); + + it('should apply multiple transformers in sequence', async () => { + const mockRecord: RecordModel = { + id: '1', + title: 'test', + collectionId: 'test', + collectionName: 'test', + }; + + mockGetOne.mockResolvedValue(mockRecord); + + const transformer1 = (record: RecordModel) => ({ + ...record, + title: record.title.toUpperCase(), + }); + + const transformer2 = (record: RecordModel) => ({ + ...record, + title: record.title + '!', + }); + + const wrapper = createWrapper(mockPocketBase); + + const { result } = renderHook( + () => + useRecord('test', '1', { + transformers: [transformer1, transformer2], + }), + { wrapper }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.data).toEqual({ + ...mockRecord, + title: 'TEST!', + }); + }); + }); + it('should throw error when used outside provider', () => { expect(() => { renderHook(() => useRecord('test', '1')); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..f75d459 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,6 @@ +import '@testing-library/jest-dom'; + +// Ensure jsdom environment is properly set up +if (typeof globalThis.document === 'undefined') { + throw new Error('jsdom environment is not properly configured'); +} diff --git a/vite.config.ts b/vite.config.ts index c85e048..e1a1fb2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,18 +30,4 @@ export default defineConfig({ }, sourcemap: true, }, - test: { - environment: 'jsdom', - globals: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['node_modules/', 'dist/', 'tests/', 'examples/', 'scripts/', '**/index.ts', '**/*.type.ts', '**/*.d.ts', '**/*.config.*', '**/coverage/**'], - }, - onConsoleLog(_log, type) { - if (type === 'stderr') { - return false; - } - }, - }, }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6e673bf --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/', 'tests/', 'examples/', 'scripts/', '**/index.ts', '**/*.type.ts', '**/*.d.ts', '**/*.config.*', '**/coverage/**'], + }, + onConsoleLog(_log, type) { + if (type === 'stderr') { + return false; + } + }, + }, +}); From e83b738dc15127b3247d304abc2192667e0aa532 Mon Sep 17 00:00:00 2001 From: Kevin Bonnoron <2421321+KevinBonnoron@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:40:53 +0000 Subject: [PATCH 2/4] chore: rename subscribe to realtime in useCollection & useRecord --- CLAUDE.md | 2 +- README.md | 10 ++--- src/hooks/useCollection.ts | 6 +-- src/hooks/useRecord.ts | 10 +++-- src/types/useCollection.type.ts | 2 +- src/types/useRecord.type.ts | 5 +++ tests/context.test.tsx | 3 +- tests/hooks/useCollection.test.tsx | 49 ++++++++++++++++------- tests/hooks/usePocketBase.test.tsx | 2 +- tests/hooks/useRecord.test.tsx | 62 +++++++++++++++++++++++++++++- tests/setup.ts | 2 +- 11 files changed, 121 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dfe333d..cd6e42f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,7 @@ Error handling: If a transformer throws, `applyTransformers()` catches the error ## Important Notes - **Peer Dependencies**: React >=19.0.0 and PocketBase ^0.26.2 must be installed by consumers -- **Real-time Subscriptions**: Enabled by default but can be disabled with `subscribe: false` option +- **Real-time Subscriptions**: Enabled by default but can be disabled with `realtime: false` option - **Conditional Fetching**: Use `enabled: false` to disable data fetching (similar to TanStack Query) - **Error Handling**: All hooks expose `isError` and `error` for graceful error states - **Request Cancellation**: Use `requestKey` option in `useCollection` and `useRecord` to enable request cancellation via `pb.cancelRequest(key)` diff --git a/README.md b/README.md index a947f02..044cb06 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Fetches and manages a collection of data with real-time subscriptions. - `defaultValue`: Default value while loading - `enabled`: Boolean to enable/disable data fetching (default: `true`) - `fetchAll`: Boolean to use `getFullList` (true) or `getList` (false) (default: `true`) - - `subscribe`: Boolean to enable/disable real-time subscriptions (default: `true`) + - `realtime`: Boolean to enable/disable real-time subscriptions (default: `true`) - `requestKey`: Optional key passed to PocketBase for request cancellation (optional) - `transformers`: Array of transformer functions to apply to records (default: `[dateTransformer()]`) @@ -337,7 +337,7 @@ import { useCollection } from 'pocketbase-react-hooks'; function StaticPostsList() { const { data: posts, isLoading, isError, error } = useCollection('posts', { filter: 'status = "published"', - subscribe: false // Disable real-time updates + realtime: false // Disable real-time updates }); if (isLoading) return
Loading posts...
; @@ -378,7 +378,7 @@ function AdvancedPostsList({ expand: 'author', fields: 'id,title,content,author', enabled: shouldFetch, - subscribe: enableRealtime + realtime: enableRealtime }); if (!shouldFetch) return
Data fetching is disabled
; @@ -851,11 +851,11 @@ function PostsList() { All hooks support real-time updates through PocketBase subscriptions: -- `useCollection` automatically updates when records are created, updated, or deleted (can be disabled with `subscribe: false`) +- `useCollection` automatically updates when records are created, updated, or deleted (can be disabled with `realtime: false`) - `useRecord` automatically updates when the specific record changes - `useAuth` automatically updates when authentication state changes -**Note:** Real-time subscriptions are enabled by default but can be disabled using the `subscribe` option for better performance when real-time updates are not needed. +**Note:** Real-time subscriptions are enabled by default but can be disabled using the `realtime` option for better performance when real-time updates are not needed. ## Request Cancellation with requestKey diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index ac4bf07..458eb94 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -32,7 +32,7 @@ import { usePocketBase } from './usePocketBase'; * ``` */ export function useCollection(collectionName: string, options: UseCollectionOptions = {}): UseCollectionResult { - const { enabled = true, page, perPage, filter, sort, expand, fields, defaultValue, fetchAll = true, subscribe = true, requestKey } = options; + const { enabled = true, page, perPage, filter, sort, expand, fields, defaultValue, fetchAll = true, realtime = true, requestKey } = options; const pb = usePocketBase(); const recordService = useMemo(() => pb.collection(collectionName), [pb, collectionName]); @@ -79,7 +79,7 @@ export function useCollection(collectionName: string }, [enabled, recordService, page, perPage, filter, sort, expand, fields, fetchAll, requestKey, queryState.reset, queryState.executeFetch]); useEffect(() => { - if (!enabled || !subscribe) return; + if (!enabled || !realtime) return; const unsubscribe = recordService.subscribe( '*', @@ -131,7 +131,7 @@ export function useCollection(collectionName: string return () => { unsubscribe.then((unsub) => unsub()); }; - }, [enabled, subscribe, recordService, expand, filter, sort, requestKey, queryState.setData]); + }, [enabled, realtime, recordService, expand, filter, sort, requestKey, queryState.setData]); return queryState.result; } diff --git a/src/hooks/useRecord.ts b/src/hooks/useRecord.ts index 26e3533..bd3c60d 100644 --- a/src/hooks/useRecord.ts +++ b/src/hooks/useRecord.ts @@ -33,7 +33,7 @@ import { usePocketBase } from './usePocketBase'; export function useRecord(collectionName: string, recordId: Record['id'] | null | undefined, options?: UseRecordOptions): UseRecordResult; export function useRecord(collectionName: string, filter: string | null | undefined, options?: UseRecordOptions): UseRecordResult; export function useRecord(collectionName: string, recordIdOrFilter: Record['id'] | string | null | undefined, options: UseRecordOptions = {}): UseRecordResult { - const { expand, fields, defaultValue, requestKey } = options; + const { expand, fields, defaultValue, realtime = true, requestKey } = options; const pb = usePocketBase(); const recordService = useMemo(() => pb.collection(collectionName), [pb, collectionName]); @@ -45,7 +45,9 @@ export function useRecord(collectionName: string, re }); const transformers = useRef(options.transformers ?? [dateTransformer()]); - transformers.current = options.transformers ?? [dateTransformer()]; + useEffect(() => { + transformers.current = options.transformers ?? [dateTransformer()]; + }, [options.transformers]); const fetcher = useCallback(async (): Promise => { if (!recordIdOrFilter) { @@ -80,7 +82,7 @@ export function useRecord(collectionName: string, re }, [recordIdOrFilter, fetcher, queryState.reset, queryState.executeFetch]); useEffect(() => { - if (!recordIdOrFilter) return; + if (!recordIdOrFilter || !realtime) return; if (isId) { const unsubscribe = recordService.subscribe( @@ -129,7 +131,7 @@ export function useRecord(collectionName: string, re unsubscribe.then((unsub) => unsub()); }; } - }, [recordService, recordIdOrFilter, expand, isId, queryState.setData, requestKey]); + }, [recordService, recordIdOrFilter, expand, isId, realtime, queryState.setData, requestKey]); return queryState.result; } diff --git a/src/types/useCollection.type.ts b/src/types/useCollection.type.ts index de1b9d8..bce43c4 100644 --- a/src/types/useCollection.type.ts +++ b/src/types/useCollection.type.ts @@ -46,7 +46,7 @@ export interface UseCollectionOptions extends UseCommonOp /** * Enable real-time subscription to collection changes (default: true) */ - subscribe?: boolean; + realtime?: boolean; /** * Request key for cancellation via pb.cancelRequest() diff --git a/src/types/useRecord.type.ts b/src/types/useRecord.type.ts index 5b029a1..b116894 100644 --- a/src/types/useRecord.type.ts +++ b/src/types/useRecord.type.ts @@ -13,6 +13,11 @@ export interface UseRecordOptions extends UseCommonOption */ defaultValue?: T | null; + /** + * Enable real-time subscription to record changes (default: true) + */ + realtime?: boolean; + /** * Request key for cancellation via pb.cancelRequest() */ diff --git a/tests/context.test.tsx b/tests/context.test.tsx index be7eeb3..abaf037 100644 --- a/tests/context.test.tsx +++ b/tests/context.test.tsx @@ -1,6 +1,5 @@ -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom/vitest'; import { render, screen } from '@testing-library/react'; -import type PocketBase from 'pocketbase'; import { describe, expect, it } from 'vitest'; import { PocketBaseContext, usePocketBaseContext } from '../src/context/PocketBaseContext'; import { PocketBaseProvider } from '../src/providers/PocketBaseProvider'; diff --git a/tests/hooks/useCollection.test.tsx b/tests/hooks/useCollection.test.tsx index 81baad7..fc61cd9 100644 --- a/tests/hooks/useCollection.test.tsx +++ b/tests/hooks/useCollection.test.tsx @@ -1,8 +1,8 @@ -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom/vitest'; import { act, renderHook } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import type { RecordModel } from 'pocketbase'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useCollection } from '../../src/hooks/useCollection'; import { createMockPocketBase, createWrapper, getMockCollectionMethods } from '../test-utils'; @@ -552,14 +552,14 @@ describe('useCollection', () => { }); }); - describe('subscribe option', () => { - it('should subscribe to real-time updates when subscribe is true (default)', async () => { + describe('realtime option', () => { + it('should subscribe to real-time updates when realtime is true (default)', async () => { const mockData = [{ id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }]; mockGetFullList.mockResolvedValue(mockData); const wrapper = createWrapper(mockPocketBase); - renderHook(() => useCollection('test', { subscribe: true }), { wrapper }); + renderHook(() => useCollection('test', { realtime: true }), { wrapper }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -571,13 +571,13 @@ describe('useCollection', () => { }); }); - it('should not subscribe to real-time updates when subscribe is false', async () => { + it('should not subscribe to real-time updates when realtime is false', async () => { const mockData = [{ id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }]; mockGetFullList.mockResolvedValue(mockData); const wrapper = createWrapper(mockPocketBase); - renderHook(() => useCollection('test', { subscribe: false }), { wrapper }); + renderHook(() => useCollection('test', { realtime: false }), { wrapper }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -586,10 +586,10 @@ describe('useCollection', () => { expect(mockSubscribe).not.toHaveBeenCalled(); }); - it('should not subscribe when both enabled and subscribe are false', async () => { + it('should not subscribe when both enabled and realtime are false', async () => { const wrapper = createWrapper(mockPocketBase); - renderHook(() => useCollection('test', { enabled: false, subscribe: false }), { wrapper }); + renderHook(() => useCollection('test', { enabled: false, realtime: false }), { wrapper }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -599,15 +599,15 @@ describe('useCollection', () => { expect(mockGetFullList).not.toHaveBeenCalled(); }); - it('should subscribe when enabled is true but subscribe is false initially, then subscribe becomes true', async () => { + it('should subscribe when enabled is true but realtime is false initially, then realtime becomes true', async () => { const mockData = [{ id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }]; mockGetFullList.mockResolvedValue(mockData); const wrapper = createWrapper(mockPocketBase); - const { rerender } = renderHook(({ subscribe }) => useCollection('test', { subscribe }), { + const { rerender } = renderHook(({ realtime }) => useCollection('test', { realtime }), { wrapper, - initialProps: { subscribe: false }, + initialProps: { realtime: false }, }); await act(async () => { @@ -616,7 +616,7 @@ describe('useCollection', () => { expect(mockSubscribe).not.toHaveBeenCalled(); - rerender({ subscribe: true }); + rerender({ realtime: true }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -624,6 +624,26 @@ describe('useCollection', () => { expect(mockSubscribe).toHaveBeenCalled(); }); + + it('should unsubscribe when realtime changes from true to false', async () => { + const mockData = [{ id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }]; + mockGetFullList.mockResolvedValue(mockData); + const unsubSpy = vi.fn(); + mockSubscribe.mockResolvedValue(unsubSpy); + const wrapper = createWrapper(mockPocketBase); + const { rerender } = renderHook(({ realtime }) => useCollection('test', { realtime }), { + wrapper, + initialProps: { realtime: true }, + }); + await act(async () => { + await Promise.resolve(); + }); + rerender({ realtime: false }); + await act(async () => { + await Promise.resolve(); + }); + expect(unsubSpy).toHaveBeenCalled(); + }); }); describe('transformers', () => { @@ -792,6 +812,7 @@ describe('useCollection', () => { }); it('should handle transformer errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const mockData = [{ id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }]; // Mock getFullList to return data without transformers applied @@ -837,6 +858,8 @@ describe('useCollection', () => { { id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }, { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }, ]); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); }); it('should apply multiple transformers in sequence', async () => { diff --git a/tests/hooks/usePocketBase.test.tsx b/tests/hooks/usePocketBase.test.tsx index cb70db6..9f2892a 100644 --- a/tests/hooks/usePocketBase.test.tsx +++ b/tests/hooks/usePocketBase.test.tsx @@ -1,4 +1,4 @@ -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom/vitest'; import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { usePocketBase } from '../../src/hooks/usePocketBase'; diff --git a/tests/hooks/useRecord.test.tsx b/tests/hooks/useRecord.test.tsx index c74e0e7..abd951c 100644 --- a/tests/hooks/useRecord.test.tsx +++ b/tests/hooks/useRecord.test.tsx @@ -1,4 +1,4 @@ -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom/vitest'; import { act, renderHook } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import type { RecordModel } from 'pocketbase'; @@ -541,6 +541,66 @@ describe('useRecord', () => { }); }); + describe('realtime option', () => { + it('should subscribe to real-time updates when realtime is true (default)', async () => { + const mockRecord = { id: '1', title: 'Test Record', collectionId: 'test', collectionName: 'test' }; + mockGetOne.mockResolvedValue(mockRecord); + + const wrapper = createWrapper(mockPocketBase); + + renderHook(() => useRecord('test', '1', { realtime: true }), { wrapper }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockSubscribe).toHaveBeenCalledWith('1', expect.any(Function), { + expand: undefined, + }); + }); + + it('should not subscribe to real-time updates when realtime is false', async () => { + const mockRecord = { id: '1', title: 'Test Record', collectionId: 'test', collectionName: 'test' }; + mockGetOne.mockResolvedValue(mockRecord); + + const wrapper = createWrapper(mockPocketBase); + + renderHook(() => useRecord('test', '1', { realtime: false }), { wrapper }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockSubscribe).not.toHaveBeenCalled(); + }); + + it('should subscribe when realtime is false initially, then realtime becomes true', async () => { + const mockRecord = { id: '1', title: 'Test Record', collectionId: 'test', collectionName: 'test' }; + mockGetOne.mockResolvedValue(mockRecord); + + const wrapper = createWrapper(mockPocketBase); + + const { rerender } = renderHook(({ realtime }) => useRecord('test', '1', { realtime }), { + wrapper, + initialProps: { realtime: false }, + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockSubscribe).not.toHaveBeenCalled(); + + rerender({ realtime: true }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockSubscribe).toHaveBeenCalled(); + }); + }); + it('should throw error when used outside provider', () => { expect(() => { renderHook(() => useRecord('test', '1')); diff --git a/tests/setup.ts b/tests/setup.ts index f75d459..e80928f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,4 +1,4 @@ -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom/vitest'; // Ensure jsdom environment is properly set up if (typeof globalThis.document === 'undefined') { From ad62ba4f028d1344001ea18d8f4ecb541ec3d193 Mon Sep 17 00:00:00 2001 From: Kevin Bonnoron <2421321+KevinBonnoron@users.noreply.github.com> Date: Sat, 18 Oct 2025 10:47:59 +0000 Subject: [PATCH 3/4] chore: deduplicate imports --- tests/context.test.tsx | 1 - tests/hooks/useCollection.test.tsx | 1 - tests/hooks/usePocketBase.test.tsx | 1 - tests/hooks/useRecord.test.tsx | 1 - 4 files changed, 4 deletions(-) diff --git a/tests/context.test.tsx b/tests/context.test.tsx index abaf037..3c4c03a 100644 --- a/tests/context.test.tsx +++ b/tests/context.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom/vitest'; import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { PocketBaseContext, usePocketBaseContext } from '../src/context/PocketBaseContext'; diff --git a/tests/hooks/useCollection.test.tsx b/tests/hooks/useCollection.test.tsx index fc61cd9..241f04e 100644 --- a/tests/hooks/useCollection.test.tsx +++ b/tests/hooks/useCollection.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom/vitest'; import { act, renderHook } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import type { RecordModel } from 'pocketbase'; diff --git a/tests/hooks/usePocketBase.test.tsx b/tests/hooks/usePocketBase.test.tsx index 9f2892a..508ca6e 100644 --- a/tests/hooks/usePocketBase.test.tsx +++ b/tests/hooks/usePocketBase.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom/vitest'; import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { usePocketBase } from '../../src/hooks/usePocketBase'; diff --git a/tests/hooks/useRecord.test.tsx b/tests/hooks/useRecord.test.tsx index abd951c..9065d6c 100644 --- a/tests/hooks/useRecord.test.tsx +++ b/tests/hooks/useRecord.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom/vitest'; import { act, renderHook } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import type { RecordModel } from 'pocketbase'; From dd48f2585bffe8110a763952df6a782f24f864cb Mon Sep 17 00:00:00 2001 From: Kevin Bonnoron <2421321+KevinBonnoron@users.noreply.github.com> Date: Sat, 18 Oct 2025 11:11:43 +0000 Subject: [PATCH 4/4] fix(tests): ensure better tests --- CLAUDE.md | 18 +- tests/hooks/useAuth.test.tsx | 205 ++++++------- tests/hooks/useCollection.test.tsx | 388 ++++++++++--------------- tests/hooks/useCreateMutation.test.tsx | 79 +++-- tests/hooks/useDeleteMutation.test.tsx | 67 ++--- tests/hooks/useRecord.test.tsx | 257 +++++++--------- tests/hooks/useUpdateMutation.test.tsx | 70 +++-- tests/types.d.ts | 1 - 8 files changed, 487 insertions(+), 598 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd6e42f..786ad62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,7 +128,23 @@ Error handling: If a transformer throws, `applyTransformers()` catches the error - Verify subscription setup and cleanup - Use `renderHook` from `@testing-library/react` to test hooks - Wrap hooks in `PocketBaseProvider` with mocked client - +- Use `waitFor` from `@testing-library/react` for assertions only (not `act` + `setTimeout`): + - Do NOT pass an async callback. + - Perform actions (user events/hook calls) before waitFor. + - Always return or await the waitFor promise. + ```typescript + // ✅ Correct + await result.current.mutate('1'); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // ❌ Incorrect + await waitFor(async () => { + await result.current.mutate('1'); + expect(result.current.isSuccess).toBe(true); + }); +``` ### Code Style - **NEVER add comments** to code (enforced by .cursorrules) - Use Biome for formatting and linting diff --git a/tests/hooks/useAuth.test.tsx b/tests/hooks/useAuth.test.tsx index 89c562c..3844a62 100644 --- a/tests/hooks/useAuth.test.tsx +++ b/tests/hooks/useAuth.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useAuth } from '../../src/hooks/useAuth'; @@ -24,16 +24,32 @@ describe('useAuth', () => { }); it('should return loading state when isLoading is true', () => { + const mockAuth = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve({ + record: { id: '1', email: 'test@example.com' }, + token: null, + }); + }, 100); + }), + ); + mockPocketBase.collection = vi.fn().mockReturnValue({ + authWithPassword: mockAuth, + }); + const wrapper = createWrapper(mockPocketBase); const { result } = renderHook(() => useAuth(), { wrapper }); - act(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthenticated).toBe(false); + + return waitFor(() => { result.current.signIn.email('test@example.com', 'password'); + expect(result.current.isLoading).toBe(true); }); - - expect(result.current.isLoading).toBe(true); - expect(result.current.isAuthenticated).toBe(false); }); it('should return error state when error is present', async () => { @@ -47,12 +63,12 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.signIn.email('test@example.com', 'password'); - }); + await result.current.signIn.email('test@example.com', 'password'); - expect(result.current.error).toEqual(mockError); - expect(result.current.isAuthenticated).toBe(false); + return waitFor(() => { + expect(result.current.error).toEqual(mockError); + expect(result.current.isAuthenticated).toBe(false); + }); }); it('should return authenticated state when user is present', () => { @@ -117,14 +133,14 @@ describe('useAuth', () => { expect(result.current.user).toBe(null); - act(() => { - listeners.forEach((listener) => { - listener('mock-token', mockUser); - }); + listeners.forEach((listener) => { + listener('mock-token', mockUser); }); - expect(result.current.user).toEqual(mockUser); - expect(result.current.isAuthenticated).toBe(true); + return waitFor(() => { + expect(result.current.user).toEqual(mockUser); + expect(result.current.isAuthenticated).toBe(true); + }); }); describe('realtime', () => { @@ -172,7 +188,6 @@ describe('useAuth', () => { it('should not handle user deletion via subscription when realtime is false', async () => { const mockUser = { id: '1', email: 'test@example.com' }; - let subscriptionCallback: ((e: { action: string; record: unknown }) => void) | null = null; const mockPocketBaseWithUser = createMockAuthPocketBase({ isValid: true, @@ -180,10 +195,7 @@ describe('useAuth', () => { }); mockPocketBaseWithUser.collection = vi.fn().mockReturnValue({ - subscribe: vi.fn((id: string, callback: (e: { action: string; record: unknown }) => void) => { - subscriptionCallback = callback; - return Promise.resolve(() => {}); - }), + subscribe: vi.fn(() => Promise.resolve(() => {})), }); const wrapper = createWrapper(mockPocketBaseWithUser); @@ -191,13 +203,6 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth({ realtime: false }), { wrapper }); expect(result.current.user).toEqual(mockUser); - - act(() => { - if (subscriptionCallback) { - subscriptionCallback({ action: 'delete', record: mockUser }); - } - }); - expect(result.current.user).toEqual(mockUser); expect(result.current.isAuthenticated).toBe(true); }); @@ -223,11 +228,11 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.signIn.email('test@example.com', 'password', options); - }); + await result.current.signIn.email('test@example.com', 'password', options); - expect(mockAuth).toHaveBeenCalledWith('test@example.com', 'password', expected); + return waitFor(() => { + expect(mockAuth).toHaveBeenCalledWith('test@example.com', 'password', expected); + }); }); it.each` @@ -251,11 +256,11 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.signIn.social('google', options); - }); + await result.current.signIn.social('google', options); - expect(mockAuth).toHaveBeenCalledWith({ provider: 'google', ...expected }); + return waitFor(() => { + expect(mockAuth).toHaveBeenCalledWith({ provider: 'google', ...expected }); + }); }); it.each` @@ -277,16 +282,16 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.signIn.otp('123456', 'password', options); - }); + await result.current.signIn.otp('123456', 'password', options); - expect(mockAuth).toHaveBeenCalledWith('123456', 'password', expected); + return waitFor(() => { + expect(mockAuth).toHaveBeenCalledWith('123456', 'password', expected); + }); }); }); describe('signOut', () => { - it('should handle signOut', async () => { + it('should handle signOut', () => { const mockClear = vi.fn(); mockPocketBase.authStore = { ...mockPocketBase.authStore, @@ -297,11 +302,11 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { + return waitFor(() => { result.current.signOut(); - }); - expect(mockClear).toHaveBeenCalled(); + expect(mockClear).toHaveBeenCalled(); + }); }); }); @@ -323,19 +328,19 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.signUp.email('new@example.com', 'password', options); - }); + await result.current.signUp.email('new@example.com', 'password', options); - const { additionalData, ...rest } = expected ?? {}; - expect(mockCreate).toHaveBeenCalledWith( - { - email: 'new@example.com', - password: 'password', - ...additionalData, - }, - rest, - ); + return waitFor(() => { + const { additionalData, ...rest } = expected ?? {}; + expect(mockCreate).toHaveBeenCalledWith( + { + email: 'new@example.com', + password: 'password', + ...additionalData, + }, + rest, + ); + }); }); }); @@ -360,13 +365,13 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.passwordReset.request('test@example.com', options); - }); + await result.current.passwordReset.request('test@example.com', options); - expect(mockRequestPasswordReset).toHaveBeenCalledWith('test@example.com', expected); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); + return waitFor(() => { + expect(mockRequestPasswordReset).toHaveBeenCalledWith('test@example.com', expected); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); }); it.each` @@ -389,13 +394,13 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.passwordReset.confirm('token123', 'newpassword', 'newpassword', options); - }); + await result.current.passwordReset.confirm('token123', 'newpassword', 'newpassword', options); - expect(mockConfirmPasswordReset).toHaveBeenCalledWith('token123', 'newpassword', 'newpassword', expected); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); + return waitFor(() => { + expect(mockConfirmPasswordReset).toHaveBeenCalledWith('token123', 'newpassword', 'newpassword', expected); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); }); it.each` @@ -419,12 +424,12 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.passwordReset.request('test@example.com', options); - }); + await result.current.passwordReset.request('test@example.com', options); - expect(result.current.error).toBe(mockError); - expect(result.current.isLoading).toBe(false); + return waitFor(() => { + expect(result.current.error).toBe(mockError); + expect(result.current.isLoading).toBe(false); + }); }); it.each` @@ -448,12 +453,12 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.passwordReset.confirm('token123', 'newpassword', 'newpassword', options); - }); + await result.current.passwordReset.confirm('token123', 'newpassword', 'newpassword', options); - expect(result.current.error).toBe(mockError); - expect(result.current.isLoading).toBe(false); + return waitFor(() => { + expect(result.current.error).toBe(mockError); + expect(result.current.isLoading).toBe(false); + }); }); }); @@ -478,13 +483,13 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.verification.request('test@example.com', options); - }); + await result.current.verification.request('test@example.com', options); - expect(mockRequestVerification).toHaveBeenCalledWith('test@example.com', expected); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); + return waitFor(() => { + expect(mockRequestVerification).toHaveBeenCalledWith('test@example.com', expected); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); }); it.each` @@ -507,13 +512,13 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.verification.confirm('token123', options); - }); + await result.current.verification.confirm('token123', options); - expect(mockConfirmVerification).toHaveBeenCalledWith('token123', expected); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); + return waitFor(() => { + expect(mockConfirmVerification).toHaveBeenCalledWith('token123', expected); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); }); it.each` @@ -537,12 +542,12 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.verification.request('test@example.com', options); - }); + await result.current.verification.request('test@example.com', options); - expect(result.current.error).toBe(mockError); - expect(result.current.isLoading).toBe(false); + return waitFor(() => { + expect(result.current.error).toBe(mockError); + expect(result.current.isLoading).toBe(false); + }); }); it.each` @@ -566,12 +571,12 @@ describe('useAuth', () => { const { result } = renderHook(() => useAuth(), { wrapper }); - await act(async () => { - await result.current.verification.confirm('token123', options); - }); + await result.current.verification.confirm('token123', options); - expect(result.current.error).toBe(mockError); - expect(result.current.isLoading).toBe(false); + return waitFor(() => { + expect(result.current.error).toBe(mockError); + expect(result.current.isLoading).toBe(false); + }); }); }); }); diff --git a/tests/hooks/useCollection.test.tsx b/tests/hooks/useCollection.test.tsx index 241f04e..9be06b0 100644 --- a/tests/hooks/useCollection.test.tsx +++ b/tests/hooks/useCollection.test.tsx @@ -1,8 +1,9 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import type { RecordModel } from 'pocketbase'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useCollection } from '../../src/hooks/useCollection'; +import type { RecordTransformer } from '../../src/types'; import { createMockPocketBase, createWrapper, getMockCollectionMethods } from '../test-utils'; describe('useCollection', () => { @@ -43,16 +44,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockGetFullList).toHaveBeenCalledWith({}); + return waitFor(() => { + expect(mockGetFullList).toHaveBeenCalledWith({}); - expect(result.current.data).toEqual(mockData); - expect(result.current.isLoading).toBe(false); - expect(result.current.isSuccess).toBe(true); - expect(result.current.isError).toBe(false); + expect(result.current.data).toEqual(mockData); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + }); }); it('should handle fetch error', async () => { @@ -63,14 +62,12 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.error).toEqual('Fetch failed'); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.data).toBe(null); }); - - expect(result.current.error).toEqual('Fetch failed'); - expect(result.current.isLoading).toBe(false); - expect(result.current.isError).toBe(true); - expect(result.current.data).toBe(null); }); it('should subscribe to real-time updates', async () => { @@ -81,13 +78,8 @@ describe('useCollection', () => { renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockSubscribe).toHaveBeenCalledWith('*', expect.any(Function), { - expand: undefined, - filter: undefined, + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('*', expect.any(Function), {}); }); }); @@ -110,17 +102,15 @@ describe('useCollection', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockGetFullList).toHaveBeenCalledWith({ - page: 2, - perPage: 10, - sort: '-created', - filter: 'status = "published"', - expand: 'author', - fields: 'id,title,content', + return waitFor(() => { + expect(mockGetFullList).toHaveBeenCalledWith({ + page: 2, + perPage: 10, + sort: '-created', + filter: 'status = "published"', + expand: 'author', + fields: 'id,title,content', + }); }); }); @@ -132,12 +122,10 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.error).toBe('The request was autocancelled'); + expect(result.current.isError).toBe(true); }); - - expect(result.current.error).toBe('The request was autocancelled'); - expect(result.current.isError).toBe(true); }); it('should handle non-Error exceptions', async () => { @@ -147,12 +135,10 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.error).toBe('Failed to fetch collection'); + expect(result.current.isError).toBe(true); }); - - expect(result.current.error).toBe('Failed to fetch collection'); - expect(result.current.isError).toBe(true); }); it('should handle real-time create events', async () => { @@ -171,18 +157,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'create', record: newRecord, }); - }); - expect(result.current.data).toEqual([...initialData, newRecord]); + expect(result.current.data).toEqual([...initialData, newRecord]); + }); }); it('should handle real-time update events', async () => { @@ -204,18 +186,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'update', record: updatedRecord, }); - }); - expect(result.current.data).toEqual([updatedRecord, { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }]); + expect(result.current.data).toEqual([updatedRecord, { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }]); + }); }); it('should handle real-time update events for non-existent records', async () => { @@ -234,18 +212,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'update', record: newRecord, }); - }); - expect(result.current.data).toEqual([...initialData, newRecord]); + expect(result.current.data).toEqual([...initialData, newRecord]); + }); }); it('should handle real-time delete events', async () => { @@ -266,18 +240,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'delete', record: { id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }, }); - }); - expect(result.current.data).toEqual([{ id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }]); + expect(result.current.data).toEqual([{ id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }]); + }); }); it('should apply sorting after real-time updates', async () => { @@ -298,22 +268,18 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test', { sort: 'title' }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'create', record: { id: '3', title: 'Beta', collectionId: 'test', collectionName: 'test' }, }); - }); - expect(result.current.data).toEqual([ - { id: '2', title: 'Alpha', collectionId: 'test', collectionName: 'test' }, - { id: '3', title: 'Beta', collectionId: 'test', collectionName: 'test' }, - { id: '1', title: 'Charlie', collectionId: 'test', collectionName: 'test' }, - ]); + expect(result.current.data).toEqual([ + { id: '2', title: 'Alpha', collectionId: 'test', collectionName: 'test' }, + { id: '3', title: 'Beta', collectionId: 'test', collectionName: 'test' }, + { id: '1', title: 'Charlie', collectionId: 'test', collectionName: 'test' }, + ]); + }); }); it('should handle real-time updates when currentData is null', async () => { @@ -329,18 +295,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'create', record: { id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }, }); - }); - expect(result.current.data).toStrictEqual([{ id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }]); + expect(result.current.data).toStrictEqual([{ id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }]); + }); }); it('should throw error when used outside provider', () => { @@ -365,11 +327,9 @@ describe('useCollection', () => { renderHook(() => useCollection('test', { enabled: false }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockSubscribe).not.toHaveBeenCalled(); }); - - expect(mockSubscribe).not.toHaveBeenCalled(); }); it('should fetch data when enabled changes from false to true', async () => { @@ -388,13 +348,11 @@ describe('useCollection', () => { rerender({ enabled: true }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual(mockData); + expect(mockGetFullList).toHaveBeenCalled(); }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.data).toEqual(mockData); - expect(mockGetFullList).toHaveBeenCalled(); }); it('should subscribe to real-time updates when enabled changes from false to true', async () => { @@ -412,11 +370,9 @@ describe('useCollection', () => { rerender({ enabled: true }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalled(); }); - - expect(mockSubscribe).toHaveBeenCalled(); }); it('should stop fetching and subscribing when enabled changes from true to false', async () => { @@ -430,21 +386,17 @@ describe('useCollection', () => { initialProps: { enabled: true }, }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitFor(() => { + expect(result.current.data).toEqual(mockData); + expect(mockGetFullList).toHaveBeenCalledTimes(1); }); - expect(result.current.data).toEqual(mockData); - expect(mockGetFullList).toHaveBeenCalledTimes(1); - rerender({ enabled: false }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(mockGetFullList).toHaveBeenCalledTimes(1); }); - - expect(result.current.isLoading).toBe(false); - expect(mockGetFullList).toHaveBeenCalledTimes(1); }); it('should default enabled to true when not provided', async () => { @@ -457,12 +409,10 @@ describe('useCollection', () => { expect(result.current.isLoading).toBe(true); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.data).toEqual(mockData); + expect(mockGetFullList).toHaveBeenCalled(); }); - - expect(result.current.data).toEqual(mockData); - expect(mockGetFullList).toHaveBeenCalled(); }); }); @@ -475,13 +425,11 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test', { fetchAll: true }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockGetFullList).toHaveBeenCalledWith({}); + expect(mockGetList).not.toHaveBeenCalled(); + expect(result.current.data).toEqual(mockData); }); - - expect(mockGetFullList).toHaveBeenCalledWith({}); - expect(mockGetList).not.toHaveBeenCalled(); - expect(result.current.data).toEqual(mockData); }); it('should use getList when fetchAll is false', async () => { @@ -493,13 +441,11 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test', { fetchAll: false }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockGetList).toHaveBeenCalledWith(1, 20, {}); + expect(mockGetFullList).not.toHaveBeenCalled(); + expect(result.current.data).toEqual(mockData); }); - - expect(mockGetList).toHaveBeenCalledWith(1, 20, {}); - expect(mockGetFullList).not.toHaveBeenCalled(); - expect(result.current.data).toEqual(mockData); }); it('should use getList with custom page and perPage when fetchAll is false', async () => { @@ -521,16 +467,14 @@ describe('useCollection', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockGetList).toHaveBeenCalledWith(2, 10, { - filter: 'status = "published"', - sort: '-created', + return waitFor(() => { + expect(mockGetList).toHaveBeenCalledWith(2, 10, { + filter: 'status = "published"', + sort: '-created', + }); + expect(mockGetFullList).not.toHaveBeenCalled(); + expect(result.current.data).toEqual(mockData); }); - expect(mockGetFullList).not.toHaveBeenCalled(); - expect(result.current.data).toEqual(mockData); }); it('should use default page and perPage values when fetchAll is false and values not provided', async () => { @@ -542,12 +486,10 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test', { fetchAll: false }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockGetList).toHaveBeenCalledWith(1, 20, {}); + expect(result.current.data).toEqual(mockData); }); - - expect(mockGetList).toHaveBeenCalledWith(1, 20, {}); - expect(result.current.data).toEqual(mockData); }); }); @@ -560,13 +502,8 @@ describe('useCollection', () => { renderHook(() => useCollection('test', { realtime: true }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockSubscribe).toHaveBeenCalledWith('*', expect.any(Function), { - expand: undefined, - filter: undefined, + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('*', expect.any(Function), {}); }); }); @@ -578,11 +515,9 @@ describe('useCollection', () => { renderHook(() => useCollection('test', { realtime: false }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockSubscribe).not.toHaveBeenCalled(); }); - - expect(mockSubscribe).not.toHaveBeenCalled(); }); it('should not subscribe when both enabled and realtime are false', async () => { @@ -590,12 +525,10 @@ describe('useCollection', () => { renderHook(() => useCollection('test', { enabled: false, realtime: false }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockSubscribe).not.toHaveBeenCalled(); + expect(mockGetFullList).not.toHaveBeenCalled(); }); - - expect(mockSubscribe).not.toHaveBeenCalled(); - expect(mockGetFullList).not.toHaveBeenCalled(); }); it('should subscribe when enabled is true but realtime is false initially, then realtime becomes true', async () => { @@ -609,22 +542,18 @@ describe('useCollection', () => { initialProps: { realtime: false }, }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitFor(() => { + expect(mockSubscribe).not.toHaveBeenCalled(); }); - expect(mockSubscribe).not.toHaveBeenCalled(); - rerender({ realtime: true }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalled(); }); - - expect(mockSubscribe).toHaveBeenCalled(); }); - it('should unsubscribe when realtime changes from true to false', async () => { + it('should unsubscribe when realtime changes from true to false', () => { const mockData = [{ id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }]; mockGetFullList.mockResolvedValue(mockData); const unsubSpy = vi.fn(); @@ -634,14 +563,12 @@ describe('useCollection', () => { wrapper, initialProps: { realtime: true }, }); - await act(async () => { - await Promise.resolve(); - }); + rerender({ realtime: false }); - await act(async () => { - await Promise.resolve(); + + return waitFor(() => { + expect(unsubSpy).toHaveBeenCalled(); }); - expect(unsubSpy).toHaveBeenCalled(); }); }); @@ -664,14 +591,12 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('test'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.data?.[0]?.created).toBeInstanceOf(Date); + expect(result.current.data?.[0]?.updated).toBeInstanceOf(Date); + expect((result.current.data?.[0]?.created as Date).toISOString()).toBe('2024-01-01T10:00:00.123Z'); + expect((result.current.data?.[0]?.updated as Date).toISOString()).toBe('2024-01-01T11:00:00.456Z'); }); - - expect(result.current.data?.[0]?.created).toBeInstanceOf(Date); - expect(result.current.data?.[0]?.updated).toBeInstanceOf(Date); - expect((result.current.data?.[0]?.created as Date).toISOString()).toBe('2024-01-01T10:00:00.123Z'); - expect((result.current.data?.[0]?.updated as Date).toISOString()).toBe('2024-01-01T11:00:00.456Z'); }); it('should apply transformers to fetched data', async () => { @@ -703,20 +628,18 @@ describe('useCollection', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.data).toEqual([ + { + id: '1', + title: 'TEST 1', + collectionId: 'test', + collectionName: 'test', + created: '2024-01-01T10:00:00Z', + updated: '2024-01-01T11:00:00Z', + }, + ]); }); - - expect(result.current.data).toEqual([ - { - id: '1', - title: 'TEST 1', - collectionId: 'test', - collectionName: 'test', - created: '2024-01-01T10:00:00Z', - updated: '2024-01-01T11:00:00Z', - }, - ]); }); it('should apply transformers to real-time create events', async () => { @@ -746,21 +669,17 @@ describe('useCollection', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'create', record: newRecord, }); - }); - expect(result.current.data).toEqual([ - { id: '1', title: 'TEST 1', collectionId: 'test', collectionName: 'test' }, - { id: '2', title: 'TEST 2', collectionId: 'test', collectionName: 'test' }, - ]); + expect(result.current.data).toEqual([ + { id: '1', title: 'TEST 1', collectionId: 'test', collectionName: 'test' }, + { id: '2', title: 'TEST 2', collectionId: 'test', collectionName: 'test' }, + ]); + }); }); it('should apply transformers to real-time update events', async () => { @@ -793,21 +712,17 @@ describe('useCollection', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'update', record: updatedRecord, }); - }); - expect(result.current.data).toEqual([ - { id: '1', title: 'UPDATED TEST 1', collectionId: 'test', collectionName: 'test' }, - { id: '2', title: 'TEST 2', collectionId: 'test', collectionName: 'test' }, - ]); + expect(result.current.data).toEqual([ + { id: '1', title: 'UPDATED TEST 1', collectionId: 'test', collectionName: 'test' }, + { id: '2', title: 'TEST 2', collectionId: 'test', collectionName: 'test' }, + ]); + }); }); it('should handle transformer errors gracefully', async () => { @@ -837,28 +752,23 @@ describe('useCollection', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - // The initial data should be returned without transformation when transformer fails - expect(result.current.data).toEqual([{ id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }]); - expect(result.current.isError).toBe(false); - - await act(async () => { + await waitFor(() => { + expect(result.current.data).toEqual([{ id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }]); + expect(result.current.isError).toBe(false); subscriptionCallback({ action: 'create', record: { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }, }); }); - // Should fallback to original record when transformer fails in real-time - expect(result.current.data).toEqual([ - { id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }, - { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }, - ]); - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + return waitFor(() => { + expect(result.current.data).toEqual([ + { id: '1', title: 'Test 1', collectionId: 'test', collectionName: 'test' }, + { id: '2', title: 'Test 2', collectionId: 'test', collectionName: 'test' }, + ]); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); }); it('should apply multiple transformers in sequence', async () => { @@ -866,14 +776,14 @@ describe('useCollection', () => { mockGetFullList.mockResolvedValue(mockData); - const transformer1 = (record: any) => ({ + const transformer1: RecordTransformer = (record) => ({ ...record, title: record.title.toUpperCase(), }); - const transformer2 = (record: any) => ({ + const transformer2: RecordTransformer = (record) => ({ ...record, - title: record.title + '!', + title: `${record.title}!`, }); const wrapper = createWrapper(mockPocketBase); @@ -886,11 +796,9 @@ describe('useCollection', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.data).toEqual([{ id: '1', title: 'TEST!', collectionId: 'test', collectionName: 'test' }]); }); - - expect(result.current.data).toEqual([{ id: '1', title: 'TEST!', collectionId: 'test', collectionName: 'test' }]); }); }); }); diff --git a/tests/hooks/useCreateMutation.test.tsx b/tests/hooks/useCreateMutation.test.tsx index 14a5095..9118088 100644 --- a/tests/hooks/useCreateMutation.test.tsx +++ b/tests/hooks/useCreateMutation.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import { beforeEach, describe, expect, it } from 'vitest'; import { useCreateMutation } from '../../src/hooks/useCreateMutation'; @@ -33,22 +33,21 @@ describe('useCreateMutation', () => { const { result } = renderHook(() => useCreateMutation('test'), { wrapper }); - let mutationResult: unknown; - await act(async () => { - mutationResult = await result.current.mutate({ - title: 'New Item', - }); + const mutationResult = await result.current.mutate({ + title: 'New Item', }); - expect(mockCreate).toHaveBeenCalledWith( - { - title: 'New Item', - }, - undefined, - ); - expect(mutationResult).toEqual(mockData); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(true); + return waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + { + title: 'New Item', + }, + undefined, + ); + expect(mutationResult).toEqual(mockData); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); }); it('should handle create mutation with options', async () => { @@ -60,11 +59,12 @@ describe('useCreateMutation', () => { const { result } = renderHook(() => useCreateMutation('test'), { wrapper }); const options = { expand: 'relation' }; - await act(async () => { - await result.current.mutate({ title: 'New Item' }, options); - }); - expect(mockCreate).toHaveBeenCalledWith({ title: 'New Item' }, options); + await result.current.mutate({ title: 'New Item' }, options); + + return waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith({ title: 'New Item' }, options); + }); }); it('should handle mutation error', async () => { @@ -75,13 +75,13 @@ describe('useCreateMutation', () => { const { result } = renderHook(() => useCreateMutation('test'), { wrapper }); - await act(async () => { - await result.current.mutate({ title: 'Test' }); - }); + await result.current.mutate({ title: 'Test' }); - expect(result.current.error).toEqual('Create failed'); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(false); + return waitFor(() => { + expect(result.current.error).toEqual('Create failed'); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); }); it('should handle non-Error exceptions', async () => { @@ -91,13 +91,13 @@ describe('useCreateMutation', () => { const { result } = renderHook(() => useCreateMutation('test'), { wrapper }); - await act(async () => { - await result.current.mutate({ title: 'Test' }); - }); + await result.current.mutate({ title: 'Test' }); - expect(result.current.error).toBe('Error creating record'); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(false); + return waitFor(() => { + expect(result.current.error).toBe('Error creating record'); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); }); it('should set isPending to true during mutation', async () => { @@ -111,20 +111,19 @@ describe('useCreateMutation', () => { const { result } = renderHook(() => useCreateMutation('test'), { wrapper }); - act(() => { + waitFor(() => { result.current.mutate({ title: 'Test' }); + expect(result.current.isPending).toBe(true); + expect(result.current.isSuccess).toBe(false); + resolveCreate({ id: '1', title: 'Test' }); }); - expect(result.current.isPending).toBe(true); - expect(result.current.isSuccess).toBe(false); + await createPromise; - await act(async () => { - resolveCreate({ id: '1', title: 'Test' }); - await createPromise; + return waitFor(() => { + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); }); - - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(true); }); it('should throw error when used outside provider', () => { diff --git a/tests/hooks/useDeleteMutation.test.tsx b/tests/hooks/useDeleteMutation.test.tsx index 83cee37..b03c6e3 100644 --- a/tests/hooks/useDeleteMutation.test.tsx +++ b/tests/hooks/useDeleteMutation.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import { beforeEach, describe, expect, it } from 'vitest'; import { useDeleteMutation } from '../../src/hooks/useDeleteMutation'; @@ -32,15 +32,14 @@ describe('useDeleteMutation', () => { const { result } = renderHook(() => useDeleteMutation('test'), { wrapper }); - let mutationResult: unknown; - await act(async () => { - mutationResult = await result.current.mutate('1'); - }); + const mutationResult = await result.current.mutate('1'); - expect(mockDelete).toHaveBeenCalledWith('1', undefined); - expect(mutationResult).toBe(true); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(true); + return waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('1', undefined); + expect(mutationResult).toBe(true); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); }); it('should handle delete mutation with options', async () => { @@ -51,11 +50,11 @@ describe('useDeleteMutation', () => { const { result } = renderHook(() => useDeleteMutation('test'), { wrapper }); const options = { headers: { 'X-Custom': 'value' } }; - await act(async () => { - await result.current.mutate('1', options); - }); - expect(mockDelete).toHaveBeenCalledWith('1', options); + await result.current.mutate('1', options); + return waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('1', options); + }); }); it('should handle mutation error', async () => { @@ -66,13 +65,13 @@ describe('useDeleteMutation', () => { const { result } = renderHook(() => useDeleteMutation('test'), { wrapper }); - await act(async () => { - await result.current.mutate('1'); - }); + await result.current.mutate('1'); - expect(result.current.error).toEqual('Delete failed'); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(false); + return waitFor(() => { + expect(result.current.error).toEqual('Delete failed'); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); }); it('should handle non-Error exceptions', async () => { @@ -82,13 +81,13 @@ describe('useDeleteMutation', () => { const { result } = renderHook(() => useDeleteMutation('test'), { wrapper }); - await act(async () => { - await result.current.mutate('1'); - }); + await result.current.mutate('1'); - expect(result.current.error).toBe('Error deleting record'); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(false); + return waitFor(() => { + expect(result.current.error).toBe('Error deleting record'); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); }); it('should set isPending to true during mutation', async () => { @@ -102,20 +101,22 @@ describe('useDeleteMutation', () => { const { result } = renderHook(() => useDeleteMutation('test'), { wrapper }); - act(() => { + waitFor(() => { result.current.mutate('1'); + expect(result.current.isPending).toBe(true); + expect(result.current.isSuccess).toBe(false); }); - expect(result.current.isPending).toBe(true); - expect(result.current.isSuccess).toBe(false); - - await act(async () => { + await waitFor(() => { resolveDelete(true); - await deletePromise; }); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(true); + await deletePromise; + + return waitFor(() => { + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); }); it('should throw error when used outside provider', () => { diff --git a/tests/hooks/useRecord.test.tsx b/tests/hooks/useRecord.test.tsx index 9065d6c..1d595c8 100644 --- a/tests/hooks/useRecord.test.tsx +++ b/tests/hooks/useRecord.test.tsx @@ -1,7 +1,7 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import type { RecordModel } from 'pocketbase'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useRecord } from '../../src/hooks/useRecord'; import { createMockPocketBase, createWrapper, getMockCollectionMethods } from '../test-utils'; @@ -39,15 +39,13 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', '1'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockGetOne).toHaveBeenCalledWith('1', {}); + expect(result.current.data).toEqual(mockRecord); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); }); - - expect(mockGetOne).toHaveBeenCalledWith('1', {}); - expect(result.current.data).toEqual(mockRecord); - expect(result.current.isLoading).toBe(false); - expect(result.current.isSuccess).toBe(true); - expect(result.current.isError).toBe(false); }); it('should fetch record by filter', async () => { @@ -58,15 +56,13 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', 'slug="test-record"'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockGetFirstListItem).toHaveBeenCalledWith('slug="test-record"', {}); + expect(result.current.data).toEqual(mockRecord); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); }); - - expect(mockGetFirstListItem).toHaveBeenCalledWith('slug="test-record"', {}); - expect(result.current.data).toEqual(mockRecord); - expect(result.current.isLoading).toBe(false); - expect(result.current.isSuccess).toBe(true); - expect(result.current.isError).toBe(false); }); it('should handle fetch error', async () => { @@ -77,14 +73,12 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', '1'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.error).toBe('Record not found'); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.data).toBe(null); }); - - expect(result.current.error).toBe('Record not found'); - expect(result.current.isLoading).toBe(false); - expect(result.current.isError).toBe(true); - expect(result.current.data).toBe(null); }); it('should use custom options', async () => { @@ -102,13 +96,11 @@ describe('useRecord', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockGetOne).toHaveBeenCalledWith('1', { - expand: 'author', - fields: 'id,title,content', + return waitFor(() => { + expect(mockGetOne).toHaveBeenCalledWith('1', { + expand: 'author', + fields: 'id,title,content', + }); }); }); @@ -120,12 +112,8 @@ describe('useRecord', () => { renderHook(() => useRecord('test', '1'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockSubscribe).toHaveBeenCalledWith('1', expect.any(Function), { - expand: undefined, + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('1', expect.any(Function), {}); }); }); @@ -137,13 +125,10 @@ describe('useRecord', () => { renderHook(() => useRecord('test', 'slug="test-record"'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockSubscribe).toHaveBeenCalledWith('*', expect.any(Function), { - expand: undefined, - filter: 'slug="test-record"', + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('*', expect.any(Function), { + filter: 'slug="test-record"', + }); }); }); @@ -163,20 +148,17 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', '1'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.data).toEqual(initialRecord); - - await act(async () => { + await waitFor(() => { + expect(result.current.data).toEqual(initialRecord); subscriptionCallback({ action: 'update', record: updatedRecord, }); }); - expect(result.current.data).toEqual(updatedRecord); + return waitFor(() => { + expect(result.current.data).toEqual(updatedRecord); + }); }); it('should handle real-time delete events', async () => { @@ -195,20 +177,17 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', '1'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.data).toEqual(initialRecord); - - await act(async () => { + await waitFor(() => { + expect(result.current.data).toEqual(initialRecord); subscriptionCallback({ action: 'delete', record: deletedRecord, }); }); - expect(result.current.data).toBe(null); + return waitFor(() => { + expect(result.current.data).toBe(null); + }); }); it('should handle real-time create events for filter-based queries', async () => { @@ -227,20 +206,17 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', 'slug="test-record"'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.data).toEqual(initialRecord); - - await act(async () => { + await waitFor(() => { + expect(result.current.data).toEqual(initialRecord); subscriptionCallback({ action: 'create', record: newRecord, }); }); - expect(result.current.data).toEqual(newRecord); + return waitFor(() => { + expect(result.current.data).toEqual(newRecord); + }); }); it('should ignore real-time delete events for different records when using filter', async () => { @@ -259,20 +235,17 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', 'slug="test-record"'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.data).toEqual(initialRecord); - - await act(async () => { + await waitFor(() => { + expect(result.current.data).toEqual(initialRecord); subscriptionCallback({ action: 'delete', record: otherRecord, }); }); - expect(result.current.data).toEqual(initialRecord); + return waitFor(() => { + expect(result.current.data).toEqual(initialRecord); + }); }); describe('transformers', () => { @@ -291,14 +264,12 @@ describe('useRecord', () => { const { result } = renderHook(() => useRecord('test', '1'), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(result.current.data?.created).toBeInstanceOf(Date); + expect(result.current.data?.updated).toBeInstanceOf(Date); + expect((result.current.data?.created as Date).toISOString()).toBe('2024-01-01T10:00:00.123Z'); + expect((result.current.data?.updated as Date).toISOString()).toBe('2024-01-01T11:00:00.456Z'); }); - - expect(result.current.data?.created).toBeInstanceOf(Date); - expect(result.current.data?.updated).toBeInstanceOf(Date); - expect((result.current.data?.created as Date).toISOString()).toBe('2024-01-01T10:00:00.123Z'); - expect((result.current.data?.updated as Date).toISOString()).toBe('2024-01-01T11:00:00.456Z'); }); it('should apply transformers to fetched data', async () => { @@ -327,13 +298,11 @@ describe('useRecord', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.data).toEqual({ - ...mockRecord, - title: 'TEST RECORD', + return waitFor(() => { + expect(result.current.data).toEqual({ + ...mockRecord, + title: 'TEST RECORD', + }); }); }); @@ -374,20 +343,16 @@ describe('useRecord', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'update', record: updatedRecord, }); - }); - expect(result.current.data).toEqual({ - ...updatedRecord, - title: 'UPDATED RECORD', + expect(result.current.data).toEqual({ + ...updatedRecord, + title: 'UPDATED RECORD', + }); }); }); @@ -428,24 +393,22 @@ describe('useRecord', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'update', record: updatedRecord, }); - }); - expect(result.current.data).toEqual({ - ...updatedRecord, - title: 'UPDATED RECORD', + expect(result.current.data).toEqual({ + ...updatedRecord, + title: 'UPDATED RECORD', + }); }); }); it('should handle transformer errors gracefully', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockRecord: RecordModel = { id: '1', title: 'Test Record', @@ -475,27 +438,25 @@ describe('useRecord', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitFor(() => { + expect(result.current.data).toEqual(mockRecord); + expect(result.current.isError).toBe(false); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); }); - // The initial data should be returned without transformation when transformer fails - expect(result.current.data).toEqual(mockRecord); - expect(result.current.isError).toBe(false); - - await act(async () => { + return waitFor(() => { subscriptionCallback({ action: 'update', record: { id: '1', title: 'Updated Record', collectionId: 'test', collectionName: 'test' }, }); - }); - // Should fallback to original record when transformer fails in real-time - expect(result.current.data).toEqual({ - id: '1', - title: 'Updated Record', - collectionId: 'test', - collectionName: 'test', + expect(result.current.data).toEqual({ + id: '1', + title: 'Updated Record', + collectionId: 'test', + collectionName: 'test', + }); }); }); @@ -529,13 +490,11 @@ describe('useRecord', () => { { wrapper }, ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.data).toEqual({ - ...mockRecord, - title: 'TEST!', + return waitFor(() => { + expect(result.current.data).toEqual({ + ...mockRecord, + title: 'TEST!', + }); }); }); }); @@ -549,12 +508,8 @@ describe('useRecord', () => { renderHook(() => useRecord('test', '1', { realtime: true }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(mockSubscribe).toHaveBeenCalledWith('1', expect.any(Function), { - expand: undefined, + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('1', expect.any(Function), {}); }); }); @@ -566,11 +521,9 @@ describe('useRecord', () => { renderHook(() => useRecord('test', '1', { realtime: false }), { wrapper }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockSubscribe).not.toHaveBeenCalled(); }); - - expect(mockSubscribe).not.toHaveBeenCalled(); }); it('should subscribe when realtime is false initially, then realtime becomes true', async () => { @@ -584,19 +537,29 @@ describe('useRecord', () => { initialProps: { realtime: false }, }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitFor(() => { + expect(mockSubscribe).not.toHaveBeenCalled(); }); - expect(mockSubscribe).not.toHaveBeenCalled(); - rerender({ realtime: true }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + return waitFor(() => { + expect(mockSubscribe).toHaveBeenCalled(); }); + }); - expect(mockSubscribe).toHaveBeenCalled(); + it('should cleanup subscription on unmount', async () => { + const mockRecord = { id: '1', title: 'Test', collectionId: 'test', collectionName: 'test' }; + mockGetOne.mockResolvedValue(mockRecord); + const unsub = vi.fn(); + mockSubscribe.mockResolvedValue(unsub); + const wrapper = createWrapper(mockPocketBase); + const { unmount } = renderHook(() => useRecord('test', '1'), { wrapper }); + + unmount(); + return waitFor(() => { + expect(unsub).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/tests/hooks/useUpdateMutation.test.tsx b/tests/hooks/useUpdateMutation.test.tsx index 9473ed1..d289617 100644 --- a/tests/hooks/useUpdateMutation.test.tsx +++ b/tests/hooks/useUpdateMutation.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import type PocketBase from 'pocketbase'; import { beforeEach, describe, expect, it } from 'vitest'; import { useUpdateMutation } from '../../src/hooks/useUpdateMutation'; @@ -33,19 +33,18 @@ describe('useUpdateMutation', () => { const { result } = renderHook(() => useUpdateMutation('test'), { wrapper }); - let mutationResult: unknown; - await act(async () => { - mutationResult = await result.current.mutate('1', { - title: 'Updated Item', - }); + const mutationResult = await result.current.mutate('1', { + title: 'Updated Item', }); - expect(mockUpdate).toHaveBeenCalledWith('1', { - title: 'Updated Item', + return waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith('1', { + title: 'Updated Item', + }); + expect(mutationResult).toEqual(mockData); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); }); - expect(mutationResult).toEqual(mockData); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(true); }); it('should handle update mutation with options', async () => { @@ -57,11 +56,11 @@ describe('useUpdateMutation', () => { const { result } = renderHook(() => useUpdateMutation('test'), { wrapper }); const options = { expand: 'relation' }; - await act(async () => { - await result.current.mutate('1', { title: 'Updated Item' }, options); - }); + await result.current.mutate('1', { title: 'Updated Item' }, options); - expect(mockUpdate).toHaveBeenCalledWith('1', { title: 'Updated Item' }, options); + return waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith('1', { title: 'Updated Item' }, options); + }); }); it('should handle mutation error', async () => { @@ -72,13 +71,13 @@ describe('useUpdateMutation', () => { const { result } = renderHook(() => useUpdateMutation('test'), { wrapper }); - await act(async () => { - await result.current.mutate('1', { title: 'Test' }); - }); + await result.current.mutate('1', { title: 'Test' }); - expect(result.current.error).toEqual('Update failed'); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(false); + return waitFor(() => { + expect(result.current.error).toEqual('Update failed'); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); }); it('should handle non-Error exceptions', async () => { @@ -88,13 +87,13 @@ describe('useUpdateMutation', () => { const { result } = renderHook(() => useUpdateMutation('test'), { wrapper }); - await act(async () => { - await result.current.mutate('1', { title: 'Test' }); - }); + await result.current.mutate('1', { title: 'Test' }); - expect(result.current.error).toBe('Error updating record'); - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(false); + return waitFor(() => { + expect(result.current.error).toBe('Error updating record'); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); }); it('should set isPending to true during mutation', async () => { @@ -108,20 +107,19 @@ describe('useUpdateMutation', () => { const { result } = renderHook(() => useUpdateMutation('test'), { wrapper }); - act(() => { + waitFor(() => { result.current.mutate('1', { title: 'Test' }); + expect(result.current.isPending).toBe(true); + expect(result.current.isSuccess).toBe(false); + resolveUpdate({ id: '1', title: 'Test' }); }); - expect(result.current.isPending).toBe(true); - expect(result.current.isSuccess).toBe(false); + await updatePromise; - await act(async () => { - resolveUpdate({ id: '1', title: 'Test' }); - await updatePromise; + return waitFor(() => { + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); }); - - expect(result.current.isPending).toBe(false); - expect(result.current.isSuccess).toBe(true); }); it('should throw error when used outside provider', () => { diff --git a/tests/types.d.ts b/tests/types.d.ts index 7036640..1945e38 100644 --- a/tests/types.d.ts +++ b/tests/types.d.ts @@ -1,5 +1,4 @@ declare global { - function setTimeout(callback: () => void, ms?: number): number; function clearTimeout(id: number): void; }