From 34abaca307f4dd18dad62304e6612ac7b17c8848 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Tue, 3 Mar 2026 22:35:21 +0100 Subject: [PATCH 1/6] feat: tanstack query plugin improvements, refetch control & state exposure --- src/sync-plugins/tanstack-query.ts | 83 +++- tests/sync-plugins/tanstack-query.test.ts | 551 +++++++++++++++++++++- 2 files changed, 615 insertions(+), 19 deletions(-) diff --git a/src/sync-plugins/tanstack-query.ts b/src/sync-plugins/tanstack-query.ts index f5d8acff6..7b0d01013 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,6 +101,7 @@ export function syncedQuery< observer = new Observer!(queryClient!, latestOptions); let isFirstRun = true; + let rejectInitialPromise: undefined | ((error: Error) => void) = undefined; const get = (async () => { if (isFirstRun) { isFirstRun = false; @@ -92,27 +110,82 @@ export function syncedQuery< const result = observer!.getOptimisticResult(latestOptions); if (result.isLoading) { - await new Promise((resolve) => { + await new Promise((resolve, reject) => { resolveInitialPromise = resolve; + rejectInitialPromise = reject; }); } return result.data!; } else { - return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); + // refetchOnMount option from TanStack Query + const refetchOnMount = latestOptions.refetchOnMount; + + if (refetchOnMount === false) { + const currentResult = observer!.getCurrentResult(); + return currentResult.data!; + } + + if (refetchOnMount === 'always') { + return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); + } + + // Default behavior (refetchOnMount is true or undefined): + // Only refetch if the data is stale + const currentResult = observer!.getCurrentResult(); + if (currentResult.isStale) { + return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); + } + + return currentResult.data!; } }) 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) => { // Subscribe to Query's observer and update the observable const unsubscribe = observer!.subscribe( - notifyManager.batchCalls((result) => { + notifyManager.batchCalls((result: QueryObserverResult) => { + emitQueryState(result); + + // Propagate fetching state to syncState + if (node.state) { + const isFetching = result.fetchStatus === 'fetching'; + if (node.state.isGetting.peek() !== isFetching) { + node.state.isGetting.set(isFetching); + } + } + 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.test.ts b/tests/sync-plugins/tanstack-query.test.ts index b4ec4c0b5..e1b909536 100644 --- a/tests/sync-plugins/tanstack-query.test.ts +++ b/tests/sync-plugins/tanstack-query.test.ts @@ -4,6 +4,7 @@ jest.mock('@tanstack/query-core', () => { class QueryObserver { client: any; options: any; + notifyOptions: any; constructor(client: any, options: any) { this.client = client; @@ -11,7 +12,32 @@ jest.mock('@tanstack/query-core', () => { } getOptimisticResult() { - return { isLoading: false, data: 'initial', status: 'success' }; + return { + data: 'initial', + status: 'success', + isLoading: false, + isFetching: false, + isStale: false, + error: null, + fetchStatus: 'idle' as const, + }; + } + + getCurrentResult() { + return { + data: 'initial', + status: 'success', + isLoading: false, + isFetching: false, + isStale: false, + error: null, + fetchStatus: 'idle' as const, + }; + } + + setOptions(_options: any, _notifyOptions?: any) { + this.options = _options; + this.notifyOptions = _notifyOptions; } refetch() { @@ -66,30 +92,527 @@ jest.mock('@tanstack/query-core', () => { QueryClient, notifyManager, DefaultError: Error, + refetchMock, }; }); 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('get returns refetched data after the initial run (stale data)', async () => { const queryClient = new QueryClient(); - const linkedFactory = syncedQuery({ - queryClient, - query: { - queryKey: ['test'], - }, - }) as () => Synced; + const mockModule = jest.requireMock('@tanstack/query-core') as any; + const OrigObserver = mockModule.QueryObserver; + const originalGetCurrentResult = OrigObserver.prototype.getCurrentResult; + + OrigObserver.prototype.getCurrentResult = function () { + return { + data: 'initial', + status: 'success', + isLoading: false, + isFetching: false, + isStale: true, + error: null, + fetchStatus: 'idle', + }; + }; + + try { + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const initial = await options.get!({}); + expect(initial).toBe('initial'); + + const second = await options.get!({}); + expect(second).toBe('fresh'); + } finally { + OrigObserver.prototype.getCurrentResult = originalGetCurrentResult; + } + }); + + describe('refetchOnMount control', () => { + test('refetchOnMount: false - returns cached data without refetching', async () => { + const queryClient = new QueryClient(); + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-no-refetch'], + refetchOnMount: false, + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const initial = await options.get!({}); + expect(initial).toBe('initial'); + + refetchMock.mockClear(); + const second = await options.get!({}); + expect(second).toBe('initial'); + expect(refetchMock).not.toHaveBeenCalled(); + }); + + test('refetchOnMount: "always" - always refetches even if data is fresh', async () => { + const queryClient = new QueryClient(); + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-always-refetch'], + refetchOnMount: 'always', + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const initial = await options.get!({}); + expect(initial).toBe('initial'); + + refetchMock.mockClear(); + const second = await options.get!({}); + expect(second).toBe('fresh'); + expect(refetchMock).toHaveBeenCalledTimes(1); + }); + + test('refetchOnMount: true - refetches when data is stale', async () => { + const queryClient = new QueryClient(); + const mockModule = jest.requireMock('@tanstack/query-core') as any; + const OrigObserver = mockModule.QueryObserver; + const originalGetCurrentResult = OrigObserver.prototype.getCurrentResult; + + OrigObserver.prototype.getCurrentResult = function () { + return { + data: 'initial', + status: 'success', + isLoading: false, + isFetching: false, + isStale: true, + error: null, + fetchStatus: 'idle', + }; + }; + + try { + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-stale-refetch'], + refetchOnMount: true, + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const initial = await options.get!({}); + expect(initial).toBe('initial'); + + refetchMock.mockClear(); + const second = await options.get!({}); + expect(second).toBe('fresh'); + expect(refetchMock).toHaveBeenCalledTimes(1); + } finally { + OrigObserver.prototype.getCurrentResult = originalGetCurrentResult; + } + }); + }); + + describe('observer integration', () => { + 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('isFetching state is propagated to node.state.isGetting', 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-observer-fetching'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + let currentIsGetting = false; + const mockNodeState = { + isGetting: { + peek: jest.fn(() => currentIsGetting), + set: jest.fn((val: boolean) => { + currentIsGetting = val; + }), + }, + 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, + }); + + subscriberCallback!({ + status: 'success', + data: 'data', + error: null, + isLoading: false, + isFetching: true, + isStale: false, + fetchStatus: 'fetching', + }); + + expect(mockNodeState.isGetting.set).toHaveBeenCalledWith(true); + + mockNodeState.isGetting.set.mockClear(); + subscriberCallback!({ + status: 'success', + data: 'fresh-data', + error: null, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); + + expect(mockNodeState.isGetting.set).toHaveBeenCalledWith(false); + } 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 options = linkedFactory()[symbolLinked]; + const getPromise = options.get!({}); - const initial = await options.get!({}); - expect(initial).toBe('initial'); + const loadError = new Error('Initial load failed'); + subscriberCallback!({ + status: 'error', + data: undefined, + error: loadError, + isLoading: false, + isFetching: false, + isStale: false, + fetchStatus: 'idle', + }); - const second = await options.get!({}); - expect(second).toBe('fresh'); + await expect(getPromise).rejects.toThrow('Initial load failed'); + expect(errorSpy).toHaveBeenCalledWith(loadError); + } finally { + OrigObserver.prototype.subscribe = originalSubscribe; + OrigObserver.prototype.getOptimisticResult = originalGetOptimistic; + } + }); }); }); From 32318bb59b89eded25ac56c8940a1f42471a92bc Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Wed, 4 Mar 2026 10:15:04 +0100 Subject: [PATCH 2/6] fix: simplify implementation --- src/sync-plugins/tanstack-query.ts | 22 ++++----- tests/sync-plugins/tanstack-query.test.ts | 59 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/sync-plugins/tanstack-query.ts b/src/sync-plugins/tanstack-query.ts index 7b0d01013..921e26a37 100644 --- a/src/sync-plugins/tanstack-query.ts +++ b/src/sync-plugins/tanstack-query.ts @@ -110,7 +110,7 @@ export function syncedQuery< const result = observer!.getOptimisticResult(latestOptions); if (result.isLoading) { - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { resolveInitialPromise = resolve; rejectInitialPromise = reject; }); @@ -118,22 +118,20 @@ export function syncedQuery< return result.data!; } else { - // refetchOnMount option from TanStack Query - const refetchOnMount = latestOptions.refetchOnMount; + const currentResult = observer!.getCurrentResult(); + const rawRefetchOnMount = latestOptions.refetchOnMount; + + // Resolve callback form: (query) => boolean | 'always' + const refetchOnMount = + typeof rawRefetchOnMount === 'function' + ? rawRefetchOnMount(queryClient!.getQueryCache().find({ queryKey: latestOptions.queryKey })!) + : rawRefetchOnMount; if (refetchOnMount === false) { - const currentResult = observer!.getCurrentResult(); return currentResult.data!; } - if (refetchOnMount === 'always') { - return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); - } - - // Default behavior (refetchOnMount is true or undefined): - // Only refetch if the data is stale - const currentResult = observer!.getCurrentResult(); - if (currentResult.isStale) { + if (refetchOnMount === 'always' || currentResult.isStale) { return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); } diff --git a/tests/sync-plugins/tanstack-query.test.ts b/tests/sync-plugins/tanstack-query.test.ts index e1b909536..e87164b83 100644 --- a/tests/sync-plugins/tanstack-query.test.ts +++ b/tests/sync-plugins/tanstack-query.test.ts @@ -70,6 +70,16 @@ jest.mock('@tanstack/query-core', () => { return options; } + getQueryCache() { + return { + find: () => ({ + state: { dataUpdatedAt: Date.now() }, + isStaleByTime: 0, + getObserversCount: () => 1, + }), + }; + } + getMutationCache() { return { findAll: () => [], @@ -190,6 +200,55 @@ describe('syncedQuery', () => { expect(refetchMock).toHaveBeenCalledTimes(1); }); + test('refetchOnMount: callback returning false - returns cached data without refetching', async () => { + const queryClient = new QueryClient(); + const callbackSpy = jest.fn(() => false as const); + + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-callback-false'], + refetchOnMount: callbackSpy, + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const initial = await options.get!({}); + expect(initial).toBe('initial'); + + refetchMock.mockClear(); + const second = await options.get!({}); + expect(second).toBe('initial'); + expect(refetchMock).not.toHaveBeenCalled(); + expect(callbackSpy).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledWith(expect.objectContaining({ state: expect.any(Object) })); + }); + + test('refetchOnMount: callback returning "always" - always refetches', async () => { + const queryClient = new QueryClient(); + const callbackSpy = jest.fn(() => 'always' as const); + + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-callback-always'], + refetchOnMount: callbackSpy, + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + + const initial = await options.get!({}); + expect(initial).toBe('initial'); + + refetchMock.mockClear(); + const second = await options.get!({}); + expect(second).toBe('fresh'); + expect(refetchMock).toHaveBeenCalledTimes(1); + expect(callbackSpy).toHaveBeenCalledTimes(1); + }); + test('refetchOnMount: true - refetches when data is stale', async () => { const queryClient = new QueryClient(); const mockModule = jest.requireMock('@tanstack/query-core') as any; From b529bd40bb531ae40c0600a673dfd46cc72799a8 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Wed, 4 Mar 2026 10:17:45 +0100 Subject: [PATCH 3/6] chore: cleanup tests --- tests/sync-plugins/tanstack-query.test.ts | 30 +++++++++-------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/sync-plugins/tanstack-query.test.ts b/tests/sync-plugins/tanstack-query.test.ts index e87164b83..35882fba0 100644 --- a/tests/sync-plugins/tanstack-query.test.ts +++ b/tests/sync-plugins/tanstack-query.test.ts @@ -1,6 +1,14 @@ jest.mock('@tanstack/query-core', () => { const refetchMock = jest.fn(() => Promise.resolve({ data: 'fresh', status: 'success' })); - + const result = { + data: 'initial', + status: 'success', + isLoading: false, + isFetching: false, + isStale: false, + error: null, + fetchStatus: 'idle' as const, + }; class QueryObserver { client: any; options: any; @@ -12,27 +20,11 @@ jest.mock('@tanstack/query-core', () => { } getOptimisticResult() { - return { - data: 'initial', - status: 'success', - isLoading: false, - isFetching: false, - isStale: false, - error: null, - fetchStatus: 'idle' as const, - }; + return result; } getCurrentResult() { - return { - data: 'initial', - status: 'success', - isLoading: false, - isFetching: false, - isStale: false, - error: null, - fetchStatus: 'idle' as const, - }; + return result; } setOptions(_options: any, _notifyOptions?: any) { From 3ba43af07ef303fd4b15e0305045d3304d513dc4 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Wed, 4 Mar 2026 10:45:25 +0100 Subject: [PATCH 4/6] fix: simplify get and remove isGetting override --- src/sync-plugins/tanstack-query.ts | 29 +++-------------------- tests/sync-plugins/tanstack-query.test.ts | 10 -------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/src/sync-plugins/tanstack-query.ts b/src/sync-plugins/tanstack-query.ts index 921e26a37..7e3e7069e 100644 --- a/src/sync-plugins/tanstack-query.ts +++ b/src/sync-plugins/tanstack-query.ts @@ -118,24 +118,9 @@ export function syncedQuery< return result.data!; } else { - const currentResult = observer!.getCurrentResult(); - const rawRefetchOnMount = latestOptions.refetchOnMount; - - // Resolve callback form: (query) => boolean | 'always' - const refetchOnMount = - typeof rawRefetchOnMount === 'function' - ? rawRefetchOnMount(queryClient!.getQueryCache().find({ queryKey: latestOptions.queryKey })!) - : rawRefetchOnMount; - - if (refetchOnMount === false) { - return currentResult.data!; - } - - if (refetchOnMount === 'always' || currentResult.isStale) { - return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); - } - - return currentResult.data!; + // Subsequent calls (including sync()) always refetch from the server. + // TQ observer handles refetchOnMount/staleTime/etc. internally via subscription. + return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); } }) as () => Promise; @@ -157,14 +142,6 @@ export function syncedQuery< notifyManager.batchCalls((result: QueryObserverResult) => { emitQueryState(result); - // Propagate fetching state to syncState - if (node.state) { - const isFetching = result.fetchStatus === 'fetching'; - if (node.state.isGetting.peek() !== isFetching) { - node.state.isGetting.set(isFetching); - } - } - if (result.status === 'success') { // Clear error on success if (node.state && node.state.error.peek()) { diff --git a/tests/sync-plugins/tanstack-query.test.ts b/tests/sync-plugins/tanstack-query.test.ts index 35882fba0..346ba77bd 100644 --- a/tests/sync-plugins/tanstack-query.test.ts +++ b/tests/sync-plugins/tanstack-query.test.ts @@ -62,16 +62,6 @@ jest.mock('@tanstack/query-core', () => { return options; } - getQueryCache() { - return { - find: () => ({ - state: { dataUpdatedAt: Date.now() }, - isStaleByTime: 0, - getObserversCount: () => 1, - }), - }; - } - getMutationCache() { return { findAll: () => [], From 6ee864cd00a95cd79864f838a9779be28a80b15c Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Wed, 4 Mar 2026 10:47:08 +0100 Subject: [PATCH 5/6] chore: update tests --- tests/sync-plugins/tanstack-query.test.ts | 267 +++------------------- 1 file changed, 27 insertions(+), 240 deletions(-) diff --git a/tests/sync-plugins/tanstack-query.test.ts b/tests/sync-plugins/tanstack-query.test.ts index 346ba77bd..61a8a4752 100644 --- a/tests/sync-plugins/tanstack-query.test.ts +++ b/tests/sync-plugins/tanstack-query.test.ts @@ -101,176 +101,37 @@ describe('syncedQuery', () => { refetchMock.mockImplementation(() => Promise.resolve({ data: 'fresh', status: 'success' })); }); - test('get returns refetched data after the initial run (stale data)', async () => { + test('first get returns cached optimistic data', async () => { const queryClient = new QueryClient(); - const mockModule = jest.requireMock('@tanstack/query-core') as any; - const OrigObserver = mockModule.QueryObserver; - const originalGetCurrentResult = OrigObserver.prototype.getCurrentResult; - - OrigObserver.prototype.getCurrentResult = function () { - return { - data: 'initial', - status: 'success', - isLoading: false, - isFetching: false, - isStale: true, - error: null, - fetchStatus: 'idle', - }; - }; - - try { - const linkedFactory = syncedQuery({ - queryClient, - query: { - queryKey: ['test'], - }, - }) as () => Synced; - - const options = linkedFactory()[symbolLinked]; - - const initial = await options.get!({}); - expect(initial).toBe('initial'); - - const second = await options.get!({}); - expect(second).toBe('fresh'); - } finally { - OrigObserver.prototype.getCurrentResult = originalGetCurrentResult; - } + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-first-get'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + const result = await options.get!({}); + expect(result).toBe('initial'); + expect(refetchMock).not.toHaveBeenCalled(); }); - describe('refetchOnMount control', () => { - test('refetchOnMount: false - returns cached data without refetching', async () => { - const queryClient = new QueryClient(); - const linkedFactory = syncedQuery({ - queryClient, - query: { - queryKey: ['test-no-refetch'], - refetchOnMount: false, - }, - }) as () => Synced; - - const options = linkedFactory()[symbolLinked]; - - const initial = await options.get!({}); - expect(initial).toBe('initial'); - - refetchMock.mockClear(); - const second = await options.get!({}); - expect(second).toBe('initial'); - expect(refetchMock).not.toHaveBeenCalled(); - }); - - test('refetchOnMount: "always" - always refetches even if data is fresh', async () => { - const queryClient = new QueryClient(); - const linkedFactory = syncedQuery({ - queryClient, - query: { - queryKey: ['test-always-refetch'], - refetchOnMount: 'always', - }, - }) as () => Synced; - - const options = linkedFactory()[symbolLinked]; - - const initial = await options.get!({}); - expect(initial).toBe('initial'); - - refetchMock.mockClear(); - const second = await options.get!({}); - expect(second).toBe('fresh'); - expect(refetchMock).toHaveBeenCalledTimes(1); - }); - - test('refetchOnMount: callback returning false - returns cached data without refetching', async () => { - const queryClient = new QueryClient(); - const callbackSpy = jest.fn(() => false as const); - - const linkedFactory = syncedQuery({ - queryClient, - query: { - queryKey: ['test-callback-false'], - refetchOnMount: callbackSpy, - }, - }) as () => Synced; - - const options = linkedFactory()[symbolLinked]; - - const initial = await options.get!({}); - expect(initial).toBe('initial'); - - refetchMock.mockClear(); - const second = await options.get!({}); - expect(second).toBe('initial'); - expect(refetchMock).not.toHaveBeenCalled(); - expect(callbackSpy).toHaveBeenCalledTimes(1); - expect(callbackSpy).toHaveBeenCalledWith(expect.objectContaining({ state: expect.any(Object) })); - }); - - test('refetchOnMount: callback returning "always" - always refetches', async () => { - const queryClient = new QueryClient(); - const callbackSpy = jest.fn(() => 'always' as const); - - const linkedFactory = syncedQuery({ - queryClient, - query: { - queryKey: ['test-callback-always'], - refetchOnMount: callbackSpy, - }, - }) as () => Synced; - - const options = linkedFactory()[symbolLinked]; - - const initial = await options.get!({}); - expect(initial).toBe('initial'); - - refetchMock.mockClear(); - const second = await options.get!({}); - expect(second).toBe('fresh'); - expect(refetchMock).toHaveBeenCalledTimes(1); - expect(callbackSpy).toHaveBeenCalledTimes(1); - }); - - test('refetchOnMount: true - refetches when data is stale', async () => { - const queryClient = new QueryClient(); - const mockModule = jest.requireMock('@tanstack/query-core') as any; - const OrigObserver = mockModule.QueryObserver; - const originalGetCurrentResult = OrigObserver.prototype.getCurrentResult; - - OrigObserver.prototype.getCurrentResult = function () { - return { - data: 'initial', - status: 'success', - isLoading: false, - isFetching: false, - isStale: true, - error: null, - fetchStatus: 'idle', - }; - }; - - try { - const linkedFactory = syncedQuery({ - queryClient, - query: { - queryKey: ['test-stale-refetch'], - refetchOnMount: true, - }, - }) as () => Synced; - - const options = linkedFactory()[symbolLinked]; - - const initial = await options.get!({}); - expect(initial).toBe('initial'); + test('subsequent get always refetches (sync equivalence)', async () => { + const queryClient = new QueryClient(); + const linkedFactory = syncedQuery({ + queryClient, + query: { + queryKey: ['test-sync'], + }, + }) as () => Synced; + + const options = linkedFactory()[symbolLinked]; + await options.get!({}); + refetchMock.mockClear(); - refetchMock.mockClear(); - const second = await options.get!({}); - expect(second).toBe('fresh'); - expect(refetchMock).toHaveBeenCalledTimes(1); - } finally { - OrigObserver.prototype.getCurrentResult = originalGetCurrentResult; - } - }); + const second = await options.get!({}); + expect(second).toBe('fresh'); + expect(refetchMock).toHaveBeenCalledTimes(1); }); describe('observer integration', () => { @@ -412,80 +273,6 @@ describe('syncedQuery', () => { } }); - test('isFetching state is propagated to node.state.isGetting', 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-observer-fetching'], - }, - }) as () => Synced; - - const options = linkedFactory()[symbolLinked]; - - let currentIsGetting = false; - const mockNodeState = { - isGetting: { - peek: jest.fn(() => currentIsGetting), - set: jest.fn((val: boolean) => { - currentIsGetting = val; - }), - }, - 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, - }); - - subscriberCallback!({ - status: 'success', - data: 'data', - error: null, - isLoading: false, - isFetching: true, - isStale: false, - fetchStatus: 'fetching', - }); - - expect(mockNodeState.isGetting.set).toHaveBeenCalledWith(true); - - mockNodeState.isGetting.set.mockClear(); - subscriberCallback!({ - status: 'success', - data: 'fresh-data', - error: null, - isLoading: false, - isFetching: false, - isStale: false, - fetchStatus: 'idle', - }); - - expect(mockNodeState.isGetting.set).toHaveBeenCalledWith(false); - } finally { - OrigObserver.prototype.subscribe = originalSubscribe; - } - }); - test('onQueryStateChange tracks full lifecycle: loading -> error -> retry -> success', async () => { const queryClient = new QueryClient(); const stateChanges: QueryState[] = []; From 8084889dd98aee807854b5ac2377b97d459beca3 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Wed, 4 Mar 2026 12:11:07 +0100 Subject: [PATCH 6/6] fix: improve implementation --- jest.config.json | 1 + src/sync-plugins/tanstack-query.ts | 17 +- tests/sync-plugins/tanstack-query.mock.ts | 93 ++++++ tests/sync-plugins/tanstack-query.test.ts | 185 ++++++------ .../tanstack-react-query.test.tsx | 282 ++++++++++++++++++ 5 files changed, 485 insertions(+), 93 deletions(-) create mode 100644 tests/sync-plugins/tanstack-query.mock.ts create mode 100644 tests/sync-plugins/tanstack-react-query.test.tsx 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 7e3e7069e..b5a5ac024 100644 --- a/src/sync-plugins/tanstack-query.ts +++ b/src/sync-plugins/tanstack-query.ts @@ -102,7 +102,15 @@ export function syncedQuery< 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; @@ -117,9 +125,12 @@ export function syncedQuery< } 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 { - // Subsequent calls (including sync()) always refetch from the server. - // TQ observer handles refetchOnMount/staleTime/etc. internally via subscription. + // Explicit sync(): always force refetch from the server return Promise.resolve(observer!.refetch()).then((res) => (res as any).data as TData); } }) as () => Promise; @@ -137,6 +148,8 @@ export function syncedQuery< }; const subscribe = ({ update, onError, node }: SyncedSubscribeParams) => { + subscribedInThisCycle = true; + // Subscribe to Query's observer and update the observable const unsubscribe = observer!.subscribe( notifyManager.batchCalls((result: QueryObserverResult) => { 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 61a8a4752..1fb8f02d1 100644 --- a/tests/sync-plugins/tanstack-query.test.ts +++ b/tests/sync-plugins/tanstack-query.test.ts @@ -1,92 +1,6 @@ -jest.mock('@tanstack/query-core', () => { - const refetchMock = jest.fn(() => Promise.resolve({ data: 'fresh', status: 'success' })); - const result = { - 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 result; - } - - getCurrentResult() { - return result; - } - - setOptions(_options: any, _notifyOptions?: any) { - this.options = _options; - this.notifyOptions = _notifyOptions; - } - - 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, - refetchMock, - }; -}); +import { createQueryCoreMock } from './tanstack-query.mock'; + +jest.mock('@tanstack/query-core', () => createQueryCoreMock()); import { Synced } from '@legendapp/state/sync'; import { symbolLinked } from '../../src/globals'; @@ -116,12 +30,12 @@ describe('syncedQuery', () => { expect(refetchMock).not.toHaveBeenCalled(); }); - test('subsequent get always refetches (sync equivalence)', async () => { + test('re-observation returns cached data without refetching (TQ observer handles refetch)', async () => { const queryClient = new QueryClient(); const linkedFactory = syncedQuery({ queryClient, query: { - queryKey: ['test-sync'], + queryKey: ['test-reobserve'], }, }) as () => Synced; @@ -129,12 +43,101 @@ describe('syncedQuery', () => { 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[] = []; 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); + }); +});