diff --git a/packages/solid-query/src/QueryClient.ts b/packages/solid-query/src/QueryClient.ts new file mode 100644 index 0000000000..6d78f17f24 --- /dev/null +++ b/packages/solid-query/src/QueryClient.ts @@ -0,0 +1,84 @@ +import type { + QueryClientConfig as QueryCoreClientConfig, + DefaultOptions as CoreDefaultOptions, + QueryObserverOptions as QueryCoreObserverOptions, + InfiniteQueryObserverOptions as QueryCoreInfiniteQueryObserverOptions, + DefaultError, + QueryKey, +} from '@tanstack/query-core' +import { QueryClient as QueryCoreClient } from '@tanstack/query-core' + +export interface QueryObserverOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> extends Omit< + QueryCoreObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, + 'structuralSharing' + > { + /** + * Set this to a reconciliation key to enable reconciliation between query results. + * Set this to `false` to disable reconciliation between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom reconciliation logic. + * Defaults reconciliation key to `id`. + */ + reconcile?: + | string + | false + | ((oldData: TData | undefined, newData: TData) => TData) +} + +export interface InfiniteQueryObserverOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> extends Omit< + QueryCoreInfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, + 'structuralSharing' + > { + /** + * Set this to a reconciliation key to enable reconciliation between query results. + * Set this to `false` to disable reconciliation between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom reconciliation logic. + * Defaults reconciliation key to `id`. + */ + reconcile?: + | string + | false + | ((oldData: TData | undefined, newData: TData) => TData) +} + +export interface DefaultOptions + extends CoreDefaultOptions { + queries?: QueryObserverOptions +} + +export interface QueryClientConfig extends QueryCoreClientConfig { + defaultOptions?: DefaultOptions +} + +export class QueryClient extends QueryCoreClient { + constructor(config: QueryClientConfig = {}) { + super(config) + } +} diff --git a/packages/solid-query/src/QueryClientProvider.tsx b/packages/solid-query/src/QueryClientProvider.tsx index abc766d843..4400d404ee 100644 --- a/packages/solid-query/src/QueryClientProvider.tsx +++ b/packages/solid-query/src/QueryClientProvider.tsx @@ -1,4 +1,4 @@ -import type { QueryClient } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import type { JSX } from 'solid-js' import { createContext, useContext, onMount, onCleanup } from 'solid-js' diff --git a/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx b/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx index af6006be14..48f03ad472 100644 --- a/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx +++ b/packages/solid-query/src/__tests__/createInfiniteQuery.test.tsx @@ -10,6 +10,7 @@ import { Index, Match, Switch, + on, } from 'solid-js' import type { CreateInfiniteQueryResult, @@ -193,7 +194,8 @@ describe('useInfiniteQuery', () => { it('should keep the previous data when placeholderData is set', async () => { const key = queryKey() - const states: CreateInfiniteQueryResult>[] = [] + const states: Partial>>[] = + [] function Page() { const [order, setOrder] = createSignal('desc') @@ -212,7 +214,16 @@ describe('useInfiniteQuery', () => { })) createRenderEffect(() => { - states.push({ ...state }) + states.push({ + data: state.data ? JSON.parse(JSON.stringify(state.data)) : undefined, + hasNextPage: state.hasNextPage, + hasPreviousPage: state.hasPreviousPage, + isFetching: state.isFetching, + isFetchingNextPage: state.isFetchingNextPage, + isFetchingPreviousPage: state.isFetchingPreviousPage, + isSuccess: state.isSuccess, + isPlaceholderData: state.isPlaceholderData, + }) }) return ( @@ -375,7 +386,8 @@ describe('useInfiniteQuery', () => { it('should be able to reverse the data', async () => { const key = queryKey() - const states: CreateInfiniteQueryResult>[] = [] + const states: Partial>>[] = + [] function Page() { const state = createInfiniteQuery(() => ({ @@ -394,9 +406,19 @@ describe('useInfiniteQuery', () => { defaultPageParam: 0, })) - createRenderEffect(() => { - states.push({ ...state }) - }) + createRenderEffect( + on( + () => ({ ...state }), + () => { + states.push({ + data: state.data + ? JSON.parse(JSON.stringify(state.data)) + : undefined, + isSuccess: state.isSuccess, + }) + }, + ), + ) return (
@@ -439,7 +461,8 @@ describe('useInfiniteQuery', () => { it('should be able to fetch a previous page', async () => { const key = queryKey() - const states: CreateInfiniteQueryResult>[] = [] + const states: Partial>>[] = + [] function Page() { const start = 10 @@ -456,7 +479,15 @@ describe('useInfiniteQuery', () => { })) createRenderEffect(() => { - states.push({ ...state }) + states.push({ + data: state.data ? JSON.parse(JSON.stringify(state.data)) : undefined, + hasNextPage: state.hasNextPage, + hasPreviousPage: state.hasPreviousPage, + isFetching: state.isFetching, + isFetchingNextPage: state.isFetchingNextPage, + isFetchingPreviousPage: state.isFetchingPreviousPage, + isSuccess: state.isSuccess, + }) }) createEffect(() => { @@ -518,7 +549,8 @@ describe('useInfiniteQuery', () => { it('should be able to refetch when providing page params automatically', async () => { const key = queryKey() - const states: CreateInfiniteQueryResult>[] = [] + const states: Partial>>[] = + [] function Page() { const state = createInfiniteQuery(() => ({ @@ -535,7 +567,13 @@ describe('useInfiniteQuery', () => { })) createRenderEffect(() => { - states.push({ ...state }) + states.push({ + data: state.data ? JSON.parse(JSON.stringify(state.data)) : undefined, + isFetching: state.isFetching, + isFetchingNextPage: state.isFetchingNextPage, + isRefetching: state.isRefetching, + isFetchingPreviousPage: state.isFetchingPreviousPage, + }) }) return ( @@ -632,7 +670,8 @@ describe('useInfiniteQuery', () => { it('should silently cancel any ongoing fetch when fetching more', async () => { const key = queryKey() - const states: CreateInfiniteQueryResult>[] = [] + const states: Partial>>[] = + [] function Page() { const start = 10 @@ -649,7 +688,13 @@ describe('useInfiniteQuery', () => { })) createRenderEffect(() => { - states.push({ ...state }) + states.push({ + hasNextPage: state.hasNextPage, + data: state.data ? JSON.parse(JSON.stringify(state.data)) : undefined, + isFetching: state.isFetching, + isFetchingNextPage: state.isFetchingNextPage, + isSuccess: state.isSuccess, + }) }) createEffect(() => { @@ -978,7 +1023,8 @@ describe('useInfiniteQuery', () => { it('should be able to set new pages with the query client', async () => { const key = queryKey() - const states: CreateInfiniteQueryResult>[] = [] + const states: Partial>>[] = + [] function Page() { const [firstPage, setFirstPage] = createSignal(0) @@ -996,7 +1042,13 @@ describe('useInfiniteQuery', () => { })) createRenderEffect(() => { - states.push({ ...state }) + states.push({ + hasNextPage: state.hasNextPage, + data: state.data ? JSON.parse(JSON.stringify(state.data)) : undefined, + isFetching: state.isFetching, + isFetchingNextPage: state.isFetchingNextPage, + isSuccess: state.isSuccess, + }) }) createEffect(() => { @@ -1066,7 +1118,8 @@ describe('useInfiniteQuery', () => { it('should only refetch the first page when initialData is provided', async () => { const key = queryKey() - const states: CreateInfiniteQueryResult>[] = [] + const states: Partial>>[] = + [] function Page() { const state = createInfiniteQuery(() => ({ @@ -1083,7 +1136,13 @@ describe('useInfiniteQuery', () => { })) createRenderEffect(() => { - states.push({ ...state }) + states.push({ + data: JSON.parse(JSON.stringify(state.data)), + hasNextPage: state.hasNextPage, + isFetching: state.isFetching, + isFetchingNextPage: state.isFetchingNextPage, + isSuccess: state.isSuccess, + }) }) createEffect(() => { diff --git a/packages/solid-query/src/__tests__/createQuery.test.tsx b/packages/solid-query/src/__tests__/createQuery.test.tsx index bb6228aab6..d9855531e9 100644 --- a/packages/solid-query/src/__tests__/createQuery.test.tsx +++ b/packages/solid-query/src/__tests__/createQuery.test.tsx @@ -35,6 +35,7 @@ import { } from './utils' import { vi } from 'vitest' import type { Mock } from 'vitest' +import { reconcile } from 'solid-js/store' describe('createQuery', () => { const queryCache = new QueryCache() @@ -1282,7 +1283,6 @@ describe('createQuery', () => { count++ return count === 1 ? result1 : result2 }, - notifyOnChangeProps: 'all', })) createRenderEffect(() => { @@ -1322,9 +1322,8 @@ describe('createQuery', () => { expect(todos).toEqual(result1) expect(newTodos).toEqual(result2) - expect(newTodos).not.toBe(todos) expect(newTodo1).toBe(todo1) - expect(newTodo2).not.toBe(todo2) + expect(newTodo2).toBe(todo2) return null }) @@ -3257,7 +3256,7 @@ describe('createQuery', () => { it('should keep initial data when the query key changes', async () => { const key = queryKey() - const states: DefinedCreateQueryResult<{ count: number }>[] = [] + const states: Partial>[] = [] function Page() { const [count, setCount] = createSignal(0) @@ -3266,6 +3265,7 @@ describe('createQuery', () => { queryFn: () => ({ count: 10 }), staleTime: Infinity, initialData: () => ({ count: count() }), + reconcile: false, })) createRenderEffect(() => { states.push({ ...state }) @@ -4610,6 +4610,61 @@ describe('createQuery', () => { expect(states).toHaveLength(1) }) + it('The reconcile fn callback should correctly maintain referential equality', async () => { + const key1 = queryKey() + const states: Array> = [] + + function Page() { + const [forceValue, setForceValue] = createSignal(1) + + const state = createQuery(() => ({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + return [1, 2] + }, + select: (res) => res.map((x) => x + 1), + reconcile(oldData, newData) { + return reconcile(newData)(oldData) + }, + })) + + createEffect(() => { + if (state.data) { + states.push(state.data) + } + }) + + const forceUpdate = () => { + setForceValue((prev) => prev + 1) + } + + return ( +
+

Data: {JSON.stringify(state.data)}

+

forceValue: {forceValue}

+ +
+ ) + } + + render(() => ( + + + + )) + await waitFor(() => screen.getByText('Data: [2,3]')) + expect(states).toHaveLength(1) + + fireEvent.click(screen.getByRole('button', { name: /forceUpdate/i })) + + await waitFor(() => screen.getByText('forceValue: 2')) + await waitFor(() => screen.getByText('Data: [2,3]')) + + // effect should not be triggered again due to structural sharing + expect(states).toHaveLength(1) + }) + it('should cancel the query function when there are no more subscriptions', async () => { const key = queryKey() let cancelFn: Mock = vi.fn() diff --git a/packages/solid-query/src/__tests__/utils.tsx b/packages/solid-query/src/__tests__/utils.tsx index c3991c70cd..3d28152f9f 100644 --- a/packages/solid-query/src/__tests__/utils.tsx +++ b/packages/solid-query/src/__tests__/utils.tsx @@ -1,5 +1,5 @@ import type { QueryClientConfig } from '@tanstack/query-core' -import { QueryClient } from '@tanstack/query-core' +import { QueryClient } from '../QueryClient' import type { ParentProps } from 'solid-js' import { createEffect, createSignal, onCleanup, Show } from 'solid-js' import { vi } from 'vitest' diff --git a/packages/solid-query/src/createBaseQuery.ts b/packages/solid-query/src/createBaseQuery.ts index 00dc80f78a..1493b86f2d 100644 --- a/packages/solid-query/src/createBaseQuery.ts +++ b/packages/solid-query/src/createBaseQuery.ts @@ -3,11 +3,11 @@ // in solid-js/web package. I'll create a GitHub issue with them to see // why that happens. import type { - QueryClient, QueryKey, QueryObserver, QueryObserverResult, } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import { hydrate } from '@tanstack/query-core' import { notifyManager } from '@tanstack/query-core' import type { Accessor } from 'solid-js' @@ -19,11 +19,28 @@ import { on, onCleanup, } from 'solid-js' -import { createStore, unwrap } from 'solid-js/store' +import { createStore, reconcile, unwrap } from 'solid-js/store' import { useQueryClient } from './QueryClientProvider' import type { CreateBaseQueryOptions } from './types' import { shouldThrowError } from './utils' +function reconcileFn( + store: QueryObserverResult, + result: QueryObserverResult, + reconcileOption: + | string + | false + | ((oldData: TData | undefined, newData: TData) => TData), +): QueryObserverResult { + if (reconcileOption === false) return result + if (typeof reconcileOption === 'function') { + const newData = reconcileOption(store.data, result.data as TData) + return { ...result, data: newData } as typeof result + } + const newData = reconcile(result.data, { key: reconcileOption })(store.data) + return { ...result, data: newData } as typeof result +} + // Base Query Function that is used to create the query. export function createBaseQuery< TQueryFnData, @@ -42,6 +59,7 @@ export function createBaseQuery< const defaultedOptions = client().defaultQueryOptions(options()) defaultedOptions._optimisticResults = 'optimistic' + defaultedOptions.structuralSharing = false if (isServer) { defaultedOptions.retry = false defaultedOptions.throwErrors = true @@ -96,18 +114,28 @@ export function createBaseQuery< const createClientSubscriber = () => { return observer.subscribe((result) => { notifyManager.batchCalls(() => { - const unwrappedResult = { ...unwrap(result) } + // @ts-expect-error - This will error because the reconcile option does not + // exist on the query-core QueryObserverResult type + const reconcileOptions = observer.options.reconcile // If the query has data we dont suspend but instead mutate the resource // This could happen when placeholderData/initialData is defined - if ( - queryResource()?.data && - unwrappedResult.data && - !queryResource.loading - ) { - setState(unwrappedResult) + if (queryResource()?.data && result.data && !queryResource.loading) { + setState((store) => { + return reconcileFn( + store, + result, + reconcileOptions === undefined ? 'id' : reconcileOptions, + ) + }) mutate(state) } else { - setState(unwrappedResult) + setState((store) => { + return reconcileFn( + store, + result, + reconcileOptions === undefined ? 'id' : reconcileOptions, + ) + }) refetch() } })() diff --git a/packages/solid-query/src/createInfiniteQuery.ts b/packages/solid-query/src/createInfiniteQuery.ts index 30124303c5..b35ef20efc 100644 --- a/packages/solid-query/src/createInfiniteQuery.ts +++ b/packages/solid-query/src/createInfiniteQuery.ts @@ -1,10 +1,10 @@ import type { QueryObserver, QueryKey, - QueryClient, DefaultError, InfiniteData, } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import { InfiniteQueryObserver } from '@tanstack/query-core' import type { CreateInfiniteQueryOptions, diff --git a/packages/solid-query/src/createMutation.ts b/packages/solid-query/src/createMutation.ts index acb148b90a..fe2574cf35 100644 --- a/packages/solid-query/src/createMutation.ts +++ b/packages/solid-query/src/createMutation.ts @@ -1,4 +1,5 @@ -import type { QueryClient, DefaultError } from '@tanstack/query-core' +import type { DefaultError } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import { MutationObserver } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' import type { diff --git a/packages/solid-query/src/createQueries.ts b/packages/solid-query/src/createQueries.ts index babce5bdc5..ac94422099 100644 --- a/packages/solid-query/src/createQueries.ts +++ b/packages/solid-query/src/createQueries.ts @@ -1,11 +1,11 @@ import type { QueriesPlaceholderDataFunction, - QueryClient, QueryFunction, QueryKey, DefaultError, } from '@tanstack/query-core' import { notifyManager, QueriesObserver } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import type { Accessor } from 'solid-js' import { createComputed, onCleanup, onMount } from 'solid-js' import { createStore, unwrap } from 'solid-js/store' diff --git a/packages/solid-query/src/createQuery.ts b/packages/solid-query/src/createQuery.ts index beac314e19..6fe0643ecb 100644 --- a/packages/solid-query/src/createQuery.ts +++ b/packages/solid-query/src/createQuery.ts @@ -1,5 +1,6 @@ -import type { QueryClient, QueryKey, DefaultError } from '@tanstack/query-core' +import type { QueryKey, DefaultError } from '@tanstack/query-core' import { QueryObserver } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import type { Accessor } from 'solid-js' import { createMemo } from 'solid-js' import { createBaseQuery } from './createBaseQuery' diff --git a/packages/solid-query/src/index.ts b/packages/solid-query/src/index.ts index 633ceb72d5..6c89680b95 100644 --- a/packages/solid-query/src/index.ts +++ b/packages/solid-query/src/index.ts @@ -8,6 +8,13 @@ export * from '@tanstack/query-core' // Solid Query export * from './types' +export { QueryClient } from './QueryClient' +export type { + QueryObserverOptions, + DefaultOptions, + QueryClientConfig, + InfiniteQueryObserverOptions, +} from './QueryClient' export { createQuery } from './createQuery' export { QueryClientContext, diff --git a/packages/solid-query/src/types.ts b/packages/solid-query/src/types.ts index e4f396f0e1..b5545a9b00 100644 --- a/packages/solid-query/src/types.ts +++ b/packages/solid-query/src/types.ts @@ -2,17 +2,19 @@ import type { QueryKey, - QueryObserverOptions, QueryObserverResult, MutateFunction, MutationObserverOptions, MutationObserverResult, DefinedQueryObserverResult, - InfiniteQueryObserverOptions, InfiniteQueryObserverResult, WithRequired, DefaultError, } from '@tanstack/query-core' +import type { + QueryObserverOptions, + InfiniteQueryObserverOptions, +} from './QueryClient' export type FunctionedParams = () => T diff --git a/packages/solid-query/src/useIsFetching.ts b/packages/solid-query/src/useIsFetching.ts index 50915c1fd1..809505139b 100644 --- a/packages/solid-query/src/useIsFetching.ts +++ b/packages/solid-query/src/useIsFetching.ts @@ -1,4 +1,5 @@ -import type { QueryClient, QueryFilters } from '@tanstack/query-core' +import type { QueryFilters } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import type { Accessor } from 'solid-js' import { createMemo, createSignal, onCleanup } from 'solid-js' import { useQueryClient } from './QueryClientProvider' diff --git a/packages/solid-query/src/useIsMutating.ts b/packages/solid-query/src/useIsMutating.ts index 3c1eba3631..1c4093833b 100644 --- a/packages/solid-query/src/useIsMutating.ts +++ b/packages/solid-query/src/useIsMutating.ts @@ -1,4 +1,5 @@ -import type { MutationFilters, QueryClient } from '@tanstack/query-core' +import type { MutationFilters } from '@tanstack/query-core' +import type { QueryClient } from './QueryClient' import { useQueryClient } from './QueryClientProvider' import type { Accessor } from 'solid-js' import { createSignal, onCleanup, createMemo } from 'solid-js' diff --git a/packages/solid-query/tsconfig.json b/packages/solid-query/tsconfig.json index 2dcf15db02..44bfb1b14e 100644 --- a/packages/solid-query/tsconfig.json +++ b/packages/solid-query/tsconfig.json @@ -11,7 +11,7 @@ "emitDeclarationOnly": false, "types": ["vitest/globals"] }, - "include": ["src"], + "include": ["src", "createInfiniteQuery.test.tsx", "suspense.test.tsx"], "exclude": ["node_modules", "build"], "references": [{ "path": "../query-core" }] }