diff --git a/jest.config.json b/jest.config.json index c8d6e4a17..c62dec907 100644 --- a/jest.config.json +++ b/jest.config.json @@ -3,6 +3,7 @@ "testEnvironment": "node", "modulePathIgnorePatterns": ["/dist"], "moduleNameMapper": { + "@legendapp/state/sync-plugins/tanstack-query": "/src/sync-plugins/tanstack-query", "@legendapp/state/sync-plugins/crud": "/src/sync-plugins/crud", "@legendapp/state/sync": "/sync", "@legendapp/state/config/configureLegendState": "/src/config/configureLegendState", diff --git a/src/sync-plugins/tanstack-query.ts b/src/sync-plugins/tanstack-query.ts index f5d8acff6..b5a5ac024 100644 --- a/src/sync-plugins/tanstack-query.ts +++ b/src/sync-plugins/tanstack-query.ts @@ -9,6 +9,7 @@ import { QueryKey, QueryObserver, QueryObserverOptions, + QueryObserverResult, notifyManager, } from '@tanstack/query-core'; @@ -19,11 +20,20 @@ export interface ObservableQueryOptions TQueryKey); } +export interface QueryState { + isLoading: boolean; + isFetching: boolean; + error: TError | null; + status: 'pending' | 'error' | 'success'; + fetchStatus: 'fetching' | 'paused' | 'idle'; +} + export interface SyncedQueryParams extends Omit, 'get' | 'set' | 'retry'> { queryClient: QueryClient; query: ObservableQueryOptions; mutation?: MutationObserverOptions; + onQueryStateChange?: (state: QueryState) => void; } export function syncedQuery< @@ -32,7 +42,14 @@ export function syncedQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >(params: SyncedQueryParams): Synced { - const { query: options, mutation: mutationOptions, queryClient, initial: initialParam, ...rest } = params; + const { + query: options, + mutation: mutationOptions, + queryClient, + initial: initialParam, + onQueryStateChange, + ...rest + } = params; if (initialParam !== undefined) { const initialValue = isFunction(initialParam) ? initialParam() : initialParam; @@ -84,7 +101,16 @@ export function syncedQuery< observer = new Observer!(queryClient!, latestOptions); let isFirstRun = true; + let rejectInitialPromise: undefined | ((error: Error) => void) = undefined; + // Track whether subscribe was just called in this sync cycle. + // The sync infrastructure calls subscribe() before get() on (re-)observation, + // but skips subscribe() on explicit sync() when already subscribed. + let subscribedInThisCycle = false; + const get = (async () => { + const wasJustSubscribed = subscribedInThisCycle; + subscribedInThisCycle = false; + if (isFirstRun) { isFirstRun = false; @@ -92,27 +118,62 @@ export function syncedQuery< const result = observer!.getOptimisticResult(latestOptions); if (result.isLoading) { - await new Promise((resolve) => { + return await new Promise((resolve, reject) => { resolveInitialPromise = resolve; + rejectInitialPromise = reject; }); } return result.data!; + } else if (wasJustSubscribed) { + // Re-observation (remount): return cached data, let TQ observer + // handle refetch decisions via subscription (refetchOnMount, staleTime, etc.) + return observer!.getCurrentResult().data!; } else { + // Explicit sync(): always force refetch from the server return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); } }) as () => Promise; - const subscribe = ({ update }: SyncedSubscribeParams) => { + const emitQueryState = (result: QueryObserverResult) => { + if (onQueryStateChange) { + onQueryStateChange({ + isLoading: result.isLoading, + isFetching: result.isFetching, + error: result.error, + status: result.status, + fetchStatus: result.fetchStatus, + }); + } + }; + + const subscribe = ({ update, onError, node }: SyncedSubscribeParams) => { + subscribedInThisCycle = true; + // Subscribe to Query's observer and update the observable const unsubscribe = observer!.subscribe( - notifyManager.batchCalls((result) => { + notifyManager.batchCalls((result: QueryObserverResult) => { + emitQueryState(result); + if (result.status === 'success') { + // Clear error on success + if (node.state && node.state.error.peek()) { + node.state.error.set(undefined); + } if (resolveInitialPromise) { resolveInitialPromise(result.data); resolveInitialPromise = undefined; + rejectInitialPromise = undefined; } update({ value: result.data }); + } else if (result.status === 'error') { + // Propagate error to syncState via onError + if (rejectInitialPromise) { + rejectInitialPromise(result.error as Error); + rejectInitialPromise = undefined; + resolveInitialPromise = undefined; + } + onError(result.error as Error); } }), ); diff --git a/tests/sync-plugins/tanstack-query.mock.ts b/tests/sync-plugins/tanstack-query.mock.ts new file mode 100644 index 000000000..08fd56c8e --- /dev/null +++ b/tests/sync-plugins/tanstack-query.mock.ts @@ -0,0 +1,93 @@ +export function createQueryCoreMock() { + const refetchMock = jest.fn(() => Promise.resolve({ data: 'fresh', status: 'success' })); + let subscriberCallback: ((result: any) => void) | undefined; + + const defaultResult = { + data: 'initial', + status: 'success', + isLoading: false, + isFetching: false, + isStale: false, + error: null, + fetchStatus: 'idle' as const, + }; + + class QueryObserver { + client: any; + options: any; + notifyOptions: any; + + constructor(client: any, options: any) { + this.client = client; + this.options = options; + } + + getOptimisticResult() { + return defaultResult; + } + + getCurrentResult() { + return defaultResult; + } + + setOptions(_options: any, _notifyOptions?: any) { + this.options = _options; + this.notifyOptions = _notifyOptions; + } + + refetch() { + return refetchMock(); + } + + subscribe(cb: any) { + subscriberCallback = cb; + return () => { + subscriberCallback = undefined; + }; + } + + updateResult() {} + } + + class MutationObserver { + client: any; + options: any; + + constructor(client: any, options: any) { + this.client = client; + this.options = options; + } + + mutate(value: any) { + return Promise.resolve(value); + } + } + + class QueryClient { + defaultQueryOptions(options: any) { + return options; + } + + getMutationCache() { + return { findAll: () => [], remove: () => {} }; + } + } + + const notifyManager = { + batchCalls: + (fn: (...args: any[]) => any) => + (...args: any[]) => + fn(...args), + }; + + return { + __esModule: true, + QueryObserver, + MutationObserver, + QueryClient, + notifyManager, + DefaultError: Error, + refetchMock, + getSubscriberCallback: () => subscriberCallback, + }; +} diff --git a/tests/sync-plugins/tanstack-query.test.ts b/tests/sync-plugins/tanstack-query.test.ts index b4ec4c0b5..1fb8f02d1 100644 --- a/tests/sync-plugins/tanstack-query.test.ts +++ b/tests/sync-plugins/tanstack-query.test.ts @@ -1,95 +1,449 @@ -jest.mock('@tanstack/query-core', () => { - const refetchMock = jest.fn(() => Promise.resolve({ data: 'fresh', status: 'success' })); - - class QueryObserver { - client: any; - options: any; - - constructor(client: any, options: any) { - this.client = client; - this.options = options; - } - - getOptimisticResult() { - return { isLoading: false, data: 'initial', status: 'success' }; - } - - refetch() { - return refetchMock(); - } - - subscribe() { - return () => {}; - } - - updateResult() {} - } - - class MutationObserver { - client: any; - options: any; - - constructor(client: any, options: any) { - this.client = client; - this.options = options; - } - - mutate(value: any) { - return Promise.resolve(value); - } - } - - class QueryClient { - defaultQueryOptions(options: any) { - return options; - } - - getMutationCache() { - return { - findAll: () => [], - remove: () => {}, - }; - } - } - - const notifyManager = { - batchCalls: - (fn: (...args: any[]) => any) => - (...args: any[]) => - fn(...args), - }; - - return { - __esModule: true, - QueryObserver, - MutationObserver, - QueryClient, - notifyManager, - DefaultError: Error, - }; -}); +import { createQueryCoreMock } from './tanstack-query.mock'; + +jest.mock('@tanstack/query-core', () => createQueryCoreMock()); import { Synced } from '@legendapp/state/sync'; import { symbolLinked } from '../../src/globals'; -import { syncedQuery } from '../../src/sync-plugins/tanstack-query'; +import { syncedQuery, QueryState } from '../../src/sync-plugins/tanstack-query'; import { QueryClient } from '@tanstack/query-core'; +const { refetchMock } = jest.requireMock('@tanstack/query-core') as { refetchMock: jest.Mock }; + describe('syncedQuery', () => { - test('get returns refetched data after the initial run', async () => { + beforeEach(() => { + refetchMock.mockClear(); + refetchMock.mockImplementation(() => Promise.resolve({ data: 'fresh', status: 'success' })); + }); + + test('first get returns cached optimistic data', async () => { const queryClient = new QueryClient(); const linkedFactory = syncedQuery({ queryClient, query: { - queryKey: ['test'], + queryKey: ['test-first-get'], }, }) as () => Synced; const options = linkedFactory()[symbolLinked]; + const result = await options.get!({}); + expect(result).toBe('initial'); + expect(refetchMock).not.toHaveBeenCalled(); + }); - const initial = await options.get!({}); - expect(initial).toBe('initial'); + test('re-observation returns cached data without refetching (TQ observer handles refetch)', async () => { + const queryClient = new QueryClient(); + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-reobserve'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + await options.get!({}); + refetchMock.mockClear(); + + // Simulate re-observation: sync infra calls subscribe() then get() + const mockNodeState = { + isGetting: { peek: jest.fn(() => false), set: jest.fn() }, + error: { peek: jest.fn(() => undefined), set: jest.fn() }, + }; + options.subscribe!({ + update: jest.fn(), + onError: jest.fn(), + node: { state: mockNodeState } as any, + value$: {} as any, + refresh: jest.fn(), + lastSync: undefined, + }); + const second = await options.get!({}); + expect(second).toBe('initial'); + expect(refetchMock).not.toHaveBeenCalled(); + }); + + test('explicit sync forces refetch even when data is fresh', async () => { + const queryClient = new QueryClient(); + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-explicit-sync'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + await options.get!({}); + refetchMock.mockClear(); + + // Explicit sync: get() called without preceding subscribe() const second = await options.get!({}); expect(second).toBe('fresh'); + expect(refetchMock).toHaveBeenCalledTimes(1); + }); + + describe('observer integration', () => { + test('observer refetch via subscription updates data', async () => { + const queryClient = new QueryClient(); + let subscriberCallback: ((result: any) => void) | undefined; + const mockModule = jest.requireMock('@tanstack/query-core') as any; + const OrigObserver = mockModule.QueryObserver; + const originalSubscribe = OrigObserver.prototype.subscribe; + + OrigObserver.prototype.subscribe = function (cb: any) { + subscriberCallback = cb; + return () => { + subscriberCallback = undefined; + }; + }; + + try { + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-subscription-refetch'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + const updateSpy = jest.fn(); + const mockNodeState = { + isGetting: { peek: jest.fn(() => false), set: jest.fn() }, + error: { peek: jest.fn(() => undefined), set: jest.fn() }, + }; + + options.subscribe!({ + update: updateSpy, + onError: jest.fn(), + node: { state: mockNodeState } as any, + value$: {} as any, + refresh: jest.fn(), + lastSync: undefined, + }); + + // Simulate TQ observer firing a refetch result + // (e.g., refetchOnMount triggered internally by TQ) + subscriberCallback!({ + status: 'success', + data: 'refetched-data', + error: null, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + + expect(updateSpy).toHaveBeenCalledWith({ value: 'refetched-data' }); + } finally { + OrigObserver.prototype.subscribe = originalSubscribe; + } + }); + + test('error from observer is forwarded to onError and onQueryStateChange', async () => { + const queryClient = new QueryClient(); + const stateChanges: QueryState[] = []; + let subscriberCallback: ((result: any) => void) | undefined; + const mockModule = jest.requireMock('@tanstack/query-core') as any; + const OrigObserver = mockModule.QueryObserver; + const originalSubscribe = OrigObserver.prototype.subscribe; + + OrigObserver.prototype.subscribe = function (cb: any) { + subscriberCallback = cb; + return () => { + subscriberCallback = undefined; + }; + }; + + try { + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-observer-error'], + }, + onQueryStateChange: (state) => { + stateChanges.push({ ...state }); + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const errorSpy = jest.fn(); + const updateSpy = jest.fn(); + const mockNodeState = { + isGetting: { peek: jest.fn(() => false), set: jest.fn() }, + error: { peek: jest.fn(() => undefined), set: jest.fn() }, + }; + + options.subscribe!({ + update: updateSpy, + onError: errorSpy, + node: { state: mockNodeState } as any, + value$: {} as any, + refresh: jest.fn(), + lastSync: undefined, + }); + + expect(subscriberCallback).toBeDefined(); + + const testError = new Error('Query failed'); + subscriberCallback!({ + status: 'error', + error: testError, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + data: undefined, + }); + + expect(errorSpy).toHaveBeenCalledWith(testError); + expect(stateChanges).toHaveLength(1); + expect(stateChanges[0]).toEqual({ + isLoading: false, + isFetching: false, + error: testError, + status: 'error', + fetchStatus: 'idle', + }); + } finally { + OrigObserver.prototype.subscribe = originalSubscribe; + } + }); + + test('success from observer updates value and clears error', async () => { + const queryClient = new QueryClient(); + const stateChanges: QueryState[] = []; + let subscriberCallback: ((result: any) => void) | undefined; + + const mockModule = jest.requireMock('@tanstack/query-core') as any; + const OrigObserver = mockModule.QueryObserver; + const originalSubscribe = OrigObserver.prototype.subscribe; + + OrigObserver.prototype.subscribe = function (cb: any) { + subscriberCallback = cb; + return () => { + subscriberCallback = undefined; + }; + }; + + try { + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-observer-success'], + }, + onQueryStateChange: (state) => { + stateChanges.push({ ...state }); + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const errorSpy = jest.fn(); + const updateSpy = jest.fn(); + const prevError = new Error('previous error'); + const mockNodeState = { + isGetting: { peek: jest.fn(() => false), set: jest.fn() }, + error: { peek: jest.fn(() => prevError), set: jest.fn() }, + }; + + options.subscribe!({ + update: updateSpy, + onError: errorSpy, + node: { state: mockNodeState } as any, + value$: {} as any, + refresh: jest.fn(), + lastSync: undefined, + }); + + subscriberCallback!({ + status: 'success', + data: 'new-data', + error: null, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + + expect(updateSpy).toHaveBeenCalledWith({ value: 'new-data' }); + expect(errorSpy).not.toHaveBeenCalled(); + expect(mockNodeState.error.set).toHaveBeenCalledWith(undefined); + expect(stateChanges).toHaveLength(1); + expect(stateChanges[0].status).toBe('success'); + expect(stateChanges[0].error).toBeNull(); + } finally { + OrigObserver.prototype.subscribe = originalSubscribe; + } + }); + + test('onQueryStateChange tracks full lifecycle: loading -> error -> retry -> success', async () => { + const queryClient = new QueryClient(); + const stateChanges: QueryState[] = []; + let subscriberCallback: ((result: any) => void) | undefined; + + const mockModule = jest.requireMock('@tanstack/query-core') as any; + const OrigObserver = mockModule.QueryObserver; + const originalSubscribe = OrigObserver.prototype.subscribe; + + OrigObserver.prototype.subscribe = function (cb: any) { + subscriberCallback = cb; + return () => { + subscriberCallback = undefined; + }; + }; + + try { + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-error-recovery'], + }, + onQueryStateChange: (state) => { + stateChanges.push({ ...state }); + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + const mockNodeState = { + isGetting: { peek: jest.fn(() => false), set: jest.fn() }, + error: { peek: jest.fn(() => undefined), set: jest.fn() }, + }; + + options.subscribe!({ + update: jest.fn(), + onError: jest.fn(), + node: { state: mockNodeState } as any, + value$: {} as any, + refresh: jest.fn(), + lastSync: undefined, + }); + + const networkError = new Error('Network failed'); + + // Phase 1: Loading + subscriberCallback!({ + status: 'pending', + data: undefined, + error: null, + isLoading: true, + isFetching: true, + isStale: false, + fetchStatus: 'fetching', + }); + + // Phase 2: Error + subscriberCallback!({ + status: 'error', + data: undefined, + error: networkError, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + + // Phase 3: Retry + subscriberCallback!({ + status: 'error', + data: undefined, + error: networkError, + isLoading: false, + isFetching: true, + isStale: false, + fetchStatus: 'fetching', + }); + + // Phase 4: Success + subscriberCallback!({ + status: 'success', + data: 'recovered-data', + error: null, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + + expect(stateChanges).toHaveLength(4); + expect(stateChanges[1]).toMatchObject({ status: 'error', error: networkError }); + expect(stateChanges[3]).toMatchObject({ status: 'success', error: null }); + } finally { + OrigObserver.prototype.subscribe = originalSubscribe; + } + }); + + test('initial load error rejects the get promise', async () => { + const queryClient = new QueryClient(); + let subscriberCallback: ((result: any) => void) | undefined; + + const mockModule = jest.requireMock('@tanstack/query-core') as any; + const OrigObserver = mockModule.QueryObserver; + const originalSubscribe = OrigObserver.prototype.subscribe; + const originalGetOptimistic = OrigObserver.prototype.getOptimisticResult; + + OrigObserver.prototype.getOptimisticResult = function () { + return { + isLoading: true, + data: undefined, + status: 'pending', + isFetching: true, + isStale: false, + error: null, + fetchStatus: 'fetching', + }; + }; + + OrigObserver.prototype.subscribe = function (cb: any) { + subscriberCallback = cb; + return () => { + subscriberCallback = undefined; + }; + }; + + try { + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-initial-error'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const errorSpy = jest.fn(); + options.subscribe!({ + update: jest.fn(), + onError: errorSpy, + node: { + state: { + isGetting: { peek: jest.fn(() => false), set: jest.fn() }, + error: { peek: jest.fn(() => undefined), set: jest.fn() }, + }, + } as any, + value$: {} as any, + refresh: jest.fn(), + lastSync: undefined, + }); + + const getPromise = options.get!({}); + + const loadError = new Error('Initial load failed'); + subscriberCallback!({ + status: 'error', + data: undefined, + error: loadError, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + + await expect(getPromise).rejects.toThrow('Initial load failed'); + expect(errorSpy).toHaveBeenCalledWith(loadError); + } finally { + OrigObserver.prototype.subscribe = originalSubscribe; + OrigObserver.prototype.getOptimisticResult = originalGetOptimistic; + } + }); }); }); diff --git a/tests/sync-plugins/tanstack-react-query.test.tsx b/tests/sync-plugins/tanstack-react-query.test.tsx new file mode 100644 index 000000000..125ee449b --- /dev/null +++ b/tests/sync-plugins/tanstack-react-query.test.tsx @@ -0,0 +1,282 @@ +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +if (typeof document === 'undefined') { + GlobalRegistrator.register(); +} + +import { createQueryCoreMock } from './tanstack-query.mock'; + +jest.mock('@tanstack/query-core', () => createQueryCoreMock()); + +jest.mock('@tanstack/react-query', () => { + const { QueryClient } = jest.requireMock('@tanstack/query-core'); + const defaultClient = new QueryClient(); + return { + __esModule: true, + useQueryClient: () => defaultClient, + }; +}); + +import { createElement, useState } from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { syncState } from '@legendapp/state'; +import { observer } from '../../src/react/reactive-observer'; +import { useObservableSyncedQuery } from '../../src/sync-plugins/tanstack-react-query'; +import { QueryClient } from '@tanstack/query-core'; + +const { refetchMock, getSubscriberCallback } = jest.requireMock('@tanstack/query-core') as { + refetchMock: jest.Mock; + getSubscriberCallback: () => ((result: any) => void) | undefined; +}; + +const promiseTimeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('useObservableSyncedQuery', () => { + beforeEach(() => { + refetchMock.mockClear(); + refetchMock.mockImplementation(() => Promise.resolve({ data: 'fresh', status: 'success' })); + }); + + test('renders initial cached data without refetching', async () => { + const Test = observer(function Test() { + const data$ = useObservableSyncedQuery({ + queryClient: new QueryClient(), + query: { queryKey: ['cached-render'] }, + }); + return createElement('div', { 'data-testid': 'value' }, String(data$.get())); + }); + + const { getByTestId } = render(createElement(Test)); + await waitFor(() => promiseTimeout(0)); + + expect(getByTestId('value').textContent).toBe('initial'); + expect(refetchMock).not.toHaveBeenCalled(); + }); + + test('component remount does not trigger refetch when data is fresh', async () => { + const queryClient = new QueryClient(); + + const Test = observer(function Test() { + const data$ = useObservableSyncedQuery({ + queryClient, + query: { queryKey: ['remount-fresh'] }, + }); + return createElement('div', { 'data-testid': 'value' }, String(data$.get())); + }); + + const { unmount, getByTestId } = render(createElement(Test)); + await waitFor(() => promiseTimeout(0)); + expect(getByTestId('value').textContent).toBe('initial'); + + refetchMock.mockClear(); + unmount(); + + // Remount the same component with the same queryClient + const { getByTestId: getByTestId2 } = render(createElement(Test)); + await waitFor(() => promiseTimeout(0)); + + expect(getByTestId2('value').textContent).toBe('initial'); + expect(refetchMock).not.toHaveBeenCalled(); + }); + + test('observer subscription pushes new data into the component', async () => { + const queryClient = new QueryClient(); + + const Test = observer(function Test() { + const data$ = useObservableSyncedQuery({ + queryClient, + query: { queryKey: ['subscription-push'] }, + }); + return createElement('div', { 'data-testid': 'value' }, String(data$.get())); + }); + + const { getByTestId } = render(createElement(Test)); + await waitFor(() => promiseTimeout(0)); + expect(getByTestId('value').textContent).toBe('initial'); + + // Simulate TQ observer pushing a background refetch result + const cb = getSubscriberCallback(); + expect(cb).toBeDefined(); + + act(() => { + cb!({ + status: 'success', + data: 'background-update', + error: null, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + }); + + await waitFor(() => promiseTimeout(0)); + expect(getByTestId('value').textContent).toBe('background-update'); + }); + + test('onQueryStateChange receives state updates in component context', async () => { + const queryClient = new QueryClient(); + const stateChanges: any[] = []; + + const Test = observer(function Test() { + const data$ = useObservableSyncedQuery({ + queryClient, + query: { queryKey: ['state-change'] }, + onQueryStateChange: (state) => { + stateChanges.push({ ...state }); + }, + }); + return createElement('div', { 'data-testid': 'value' }, String(data$.get())); + }); + + render(createElement(Test)); + await waitFor(() => promiseTimeout(0)); + + const cb = getSubscriberCallback(); + act(() => { + cb!({ + status: 'success', + data: 'updated', + error: null, + isLoading: false, + isFetching: true, + isStale: false, + fetchStatus: 'fetching', + }); + }); + + await waitFor(() => promiseTimeout(0)); + + expect(stateChanges.length).toBeGreaterThanOrEqual(1); + const lastState = stateChanges[stateChanges.length - 1]; + expect(lastState.isFetching).toBe(true); + expect(lastState.fetchStatus).toBe('fetching'); + }); + + test('error from observer is reflected via onQueryStateChange', async () => { + const queryClient = new QueryClient(); + const stateChanges: any[] = []; + + const Test = observer(function Test() { + const data$ = useObservableSyncedQuery({ + queryClient, + query: { queryKey: ['error-component'] }, + onQueryStateChange: (state) => { + stateChanges.push({ ...state }); + }, + }); + return createElement('div', { 'data-testid': 'value' }, String(data$.get() ?? 'loading')); + }); + + render(createElement(Test)); + await waitFor(() => promiseTimeout(0)); + + const cb = getSubscriberCallback(); + const testError = new Error('Network failure'); + + // The sync infrastructure's onGetError re-throws errors, so we need to catch it + try { + act(() => { + cb!({ + status: 'error', + data: undefined, + error: testError, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + }); + } catch { + // sync infrastructure re-throws errors from subscribe + } + + await waitFor(() => promiseTimeout(0)); + + const errorState = stateChanges.find((s) => s.status === 'error'); + expect(errorState).toBeDefined(); + expect(errorState!.error.message).toBe('Network failure'); + }); + + test('manual .sync() forces refetch even when data is not stale', async () => { + const queryClient = new QueryClient(); + let data$Ref: any; + + const Test = observer(function Test() { + const data$ = useObservableSyncedQuery({ + queryClient, + query: { queryKey: ['manual-sync'] }, + }); + data$Ref = data$; + return createElement('div', { 'data-testid': 'value' }, String(data$.get())); + }); + + const { getByTestId } = render(createElement(Test)); + await waitFor(() => promiseTimeout(0)); + + expect(getByTestId('value').textContent).toBe('initial'); + expect(refetchMock).toHaveBeenCalledTimes(0); + + // Data is not stale (mock returns isStale: false), but manual sync should force refetch + await act(async () => { + await syncState(data$Ref).sync(); + }); + + await waitFor(() => promiseTimeout(0)); + expect(refetchMock).toHaveBeenCalledTimes(1); + }); + + test('multiple re-renders do not refetch, but sync() does', async () => { + const queryClient = new QueryClient(); + let data$Ref: any; + let triggerRerender: () => void; + + const Child = observer(function Child() { + const data$ = useObservableSyncedQuery({ + queryClient, + query: { queryKey: ['multi-rerender-sync'] }, + }); + data$Ref = data$; + return createElement('div', { 'data-testid': 'value' }, String(data$.get())); + }); + + function Parent() { + const [count, setCount] = useState(0); + triggerRerender = () => setCount((c) => c + 1); + return createElement('div', null, createElement('span', null, `render-${count}`), createElement(Child)); + } + + const { getByTestId } = render(createElement(Parent)); + await waitFor(() => promiseTimeout(0)); + expect(getByTestId('value').textContent).toBe('initial'); + expect(refetchMock).toHaveBeenCalledTimes(0); + + // Re-render #1 + act(() => triggerRerender()); + await waitFor(() => promiseTimeout(0)); + expect(refetchMock).toHaveBeenCalledTimes(0); + + // Re-render #2 + act(() => triggerRerender()); + await waitFor(() => promiseTimeout(0)); + expect(refetchMock).toHaveBeenCalledTimes(0); + + // Re-render #3 + act(() => triggerRerender()); + await waitFor(() => promiseTimeout(0)); + expect(refetchMock).toHaveBeenCalledTimes(0); + + // Now explicit sync() — should force refetch + await act(async () => { + await syncState(data$Ref).sync(); + }); + await waitFor(() => promiseTimeout(0)); + expect(refetchMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await syncState(data$Ref).sync(); + }); + await waitFor(() => promiseTimeout(0)); + expect(refetchMock).toHaveBeenCalledTimes(2); + }); +});