diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index cd4361d9ee..02afc6b66e 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -94,6 +94,7 @@ "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", "@tanstack/query-test-utils": "workspace:*", + "@testing-library/angular": "^18.0.0", "eslint-plugin-jsdoc": "^50.5.0", "npm-run-all2": "^5.0.0", "vite-plugin-dts": "4.2.3", diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts new file mode 100644 index 0000000000..62547fd9e0 --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts @@ -0,0 +1,177 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { skipToken } from '..' +import { injectQueries } from '../inject-queries' +import { queryOptions } from '../query-options' +import type { CreateQueryOptions, CreateQueryResult, OmitKeyof } from '..' +import type { Signal } from '@angular/core' + +describe('InjectQueries config object overload', () => { + it('TData should always be defined when initialData is provided as an object', () => { + const query1 = { + queryKey: ['key1'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: false, + }, + } + + const query2 = { + queryKey: ['key2'], + queryFn: () => 'Query Data', + initialData: 'initial data', + } + + const query3 = { + queryKey: ['key2'], + queryFn: () => 'Query Data', + } + + const queryResults = injectQueries(() => ({ + queries: [query1, query2, query3], + })) + + const query1Data = queryResults()[0].data() + const query2Data = queryResults()[1].data() + const query3Data = queryResults()[2].data() + + expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>() + expectTypeOf(query2Data).toEqualTypeOf() + expectTypeOf(query3Data).toEqualTypeOf() + }) + + it('TData should be defined when passed through queryOptions', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: { + wow: true, + }, + }) + const queryResults = injectQueries(() => ({ queries: [options] })) + + const data = queryResults()[0].data() + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into injectQuery', () => { + const query1 = queryOptions({ + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + select: (data) => data > 1, + }) + + const query2 = { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + select: (data: number) => data > 1, + } + + const queryResults = injectQueries(() => ({ queries: [query1, query2] })) + const query1Data = queryResults()[0].data() + const query2Data = queryResults()[1].data() + + expectTypeOf(query1Data).toEqualTypeOf() + expectTypeOf(query2Data).toEqualTypeOf() + }) + + it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => { + return { + wow: true, + } + }, + initialData: () => undefined as { wow: boolean } | undefined, + }, + ], + })) + + const data = queryResults()[0].data() + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + describe('custom injectable', () => { + it('should allow custom hooks using UseQueryOptions', () => { + type Data = string + + const injectCustomQueries = ( + options?: OmitKeyof, 'queryKey' | 'queryFn'>, + ) => { + return injectQueries(() => ({ + queries: [ + { + ...options, + queryKey: ['todos-key'], + queryFn: () => Promise.resolve('data'), + }, + ], + })) + } + + const queryResults = injectCustomQueries() + const data = queryResults()[0].data() + + expectTypeOf(data).toEqualTypeOf() + }) + }) + + it('TData should have correct type when conditional skipToken is passed', () => { + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: ['withSkipToken'], + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), + }, + ], + })) + + const firstResult = queryResults()[0] + + expectTypeOf(firstResult).toEqualTypeOf>() + expectTypeOf(firstResult.data()).toEqualTypeOf() + }) + + it('should return correct data for dynamic queries with mixed result types', () => { + const Queries1 = { + get: () => + queryOptions({ + queryKey: ['key1'], + queryFn: () => Promise.resolve(1), + }), + } + const Queries2 = { + get: () => + queryOptions({ + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }), + } + + const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() })) + const result = injectQueries(() => ({ + queries: [...queries1List, { ...Queries2.get() }], + })) + + expectTypeOf(result).branded.toEqualTypeOf< + Signal< + [ + ...Array>, + CreateQueryResult, + ] + > + >() + }) +}) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts new file mode 100644 index 0000000000..3fb3d5a626 --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render } from '@testing-library/angular' +import { + Component, + effect, + provideZonelessChangeDetection, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { queryKey } from '@tanstack/query-test-utils' +import { QueryClient, provideTanStackQuery } from '..' +import { injectQueries } from '../inject-queries' + +let queryClient: QueryClient + +beforeEach(() => { + queryClient = new QueryClient() + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ], + }) +}) + +describe('injectQueries', () => { + it('should return the correct states', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array>> = [] + + @Component({ + template: ` +
+
+ data1: {{ result()[0].data() ?? 'null' }}, data2: + {{ result()[1].data() ?? 'null' }} +
+
+ `, + }) + class Page { + toString(val: any) { + return String(val) + } + result = injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + await new Promise((r) => setTimeout(r, 10)) + return 1 + }, + }, + { + queryKey: key2, + queryFn: async () => { + await new Promise((r) => setTimeout(r, 100)) + return 2 + }, + }, + ], + })) + + _pushResults = effect(() => { + const snapshot = this.result().map((q) => ({ data: q.data() })) + results.push(snapshot) + }) + } + + const rendered = await render(Page) + + await rendered.findByText('data1: 1, data2: 2') + + expect(results.length).toBe(3) + expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }]) + expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) + expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) + }) +}) diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index 0cbcf3fb6a..2b7707158d 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -13,9 +13,10 @@ import { inject, runInInjectionContext, signal, + untracked, } from '@angular/core' +import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' -import type { Signal } from '@angular/core' import type { DefaultError, OmitKeyof, @@ -24,19 +25,24 @@ import type { QueryFunction, QueryKey, QueryObserverOptions, - QueryObserverResult, ThrowOnError, } from '@tanstack/query-core' +import type { + CreateQueryOptions, + CreateQueryResult, + DefinedCreateQueryResult, +} from './types' +import type { Signal } from '@angular/core' // This defines the `CreateQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`. -// `placeholderData` function does not have a parameter +// `placeholderData` function always gets undefined passed type QueryObserverOptionsForCreateQueries< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = OmitKeyof< - QueryObserverOptions, + CreateQueryOptions, 'placeholderData' > & { placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction @@ -46,9 +52,9 @@ type QueryObserverOptionsForCreateQueries< type MAXIMUM_DEPTH = 20 // Widen the type of the symbol to enable type inference even if skipToken is not immutable. -type SkipTokenForUseQueries = symbol +type SkipTokenForCreateQueries = symbol -type GetOptions = +type GetCreateQueryOptionsForCreateQueries = // Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData } T extends { queryFnData: infer TQueryFnData @@ -71,8 +77,8 @@ type GetOptions = T extends { queryFn?: | QueryFunction - | SkipTokenForUseQueries - select: (data: any) => infer TData + | SkipTokenForCreateQueries + select?: (data: any) => infer TData throwOnError?: ThrowOnError } ? QueryObserverOptionsForCreateQueries< @@ -84,53 +90,71 @@ type GetOptions = : // Fallback QueryObserverOptionsForCreateQueries -type GetResults = +// A defined initialData setting should return a DefinedCreateQueryResult rather than CreateQueryResult +type GetDefinedOrUndefinedQueryResult = T extends { + initialData?: infer TInitialData +} + ? unknown extends TInitialData + ? CreateQueryResult + : TInitialData extends TData + ? DefinedCreateQueryResult + : TInitialData extends () => infer TInitialDataResult + ? unknown extends TInitialDataResult + ? CreateQueryResult + : TInitialDataResult extends TData + ? DefinedCreateQueryResult + : CreateQueryResult + : CreateQueryResult + : CreateQueryResult + +type GetCreateQueryResult = // Part 1: responsible for mapping explicit type parameter to function result, if object T extends { queryFnData: any; error?: infer TError; data: infer TData } - ? QueryObserverResult + ? GetDefinedOrUndefinedQueryResult : T extends { queryFnData: infer TQueryFnData; error?: infer TError } - ? QueryObserverResult + ? GetDefinedOrUndefinedQueryResult : T extends { data: infer TData; error?: infer TError } - ? QueryObserverResult + ? GetDefinedOrUndefinedQueryResult : // Part 2: responsible for mapping explicit type parameter to function result, if tuple T extends [any, infer TError, infer TData] - ? QueryObserverResult + ? GetDefinedOrUndefinedQueryResult : T extends [infer TQueryFnData, infer TError] - ? QueryObserverResult + ? GetDefinedOrUndefinedQueryResult : T extends [infer TQueryFnData] - ? QueryObserverResult + ? GetDefinedOrUndefinedQueryResult : // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided T extends { queryFn?: | QueryFunction - | SkipTokenForUseQueries - select: (data: any) => infer TData + | SkipTokenForCreateQueries + select?: (data: any) => infer TData throwOnError?: ThrowOnError } - ? QueryObserverResult< + ? GetDefinedOrUndefinedQueryResult< + T, unknown extends TData ? TQueryFnData : TData, unknown extends TError ? DefaultError : TError > : // Fallback - QueryObserverResult + CreateQueryResult /** * QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param */ export type QueriesOptions< T extends Array, - TResult extends Array = [], + TResults extends Array = [], TDepth extends ReadonlyArray = [], > = TDepth['length'] extends MAXIMUM_DEPTH ? Array : T extends [] ? [] : T extends [infer Head] - ? [...TResult, GetOptions] - : T extends [infer Head, ...infer Tail] + ? [...TResults, GetCreateQueryOptionsForCreateQueries] + : T extends [infer Head, ...infer Tails] ? QueriesOptions< - [...Tail], - [...TResult, GetOptions], + [...Tails], + [...TResults, GetCreateQueryOptionsForCreateQueries], [...TDepth, 1] > : ReadonlyArray extends T @@ -161,55 +185,44 @@ export type QueriesOptions< */ export type QueriesResults< T extends Array, - TResult extends Array = [], + TResults extends Array = [], TDepth extends ReadonlyArray = [], > = TDepth['length'] extends MAXIMUM_DEPTH - ? Array + ? Array : T extends [] ? [] : T extends [infer Head] - ? [...TResult, GetResults] - : T extends [infer Head, ...infer Tail] + ? [...TResults, GetCreateQueryResult] + : T extends [infer Head, ...infer Tails] ? QueriesResults< - [...Tail], - [...TResult, GetResults], + [...Tails], + [...TResults, GetCreateQueryResult], [...TDepth, 1] > - : T extends Array< - QueryObserverOptionsForCreateQueries< - infer TQueryFnData, - infer TError, - infer TData, - any - > - > - ? // Dynamic-size (homogenous) CreateQueryOptions array: map directly to array of results - Array< - QueryObserverResult< - unknown extends TData ? TQueryFnData : TData, - unknown extends TError ? DefaultError : TError - > - > - : // Fallback - Array + : { [K in keyof T]: GetCreateQueryResult } + +export interface InjectQueriesOptions< + T extends Array, + TCombinedResult = QueriesResults, +> { + queries: + | readonly [...QueriesOptions] + | readonly [ + ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, + ] + combine?: (result: QueriesResults) => TCombinedResult +} /** - * @param root0 - * @param root0.queries - * @param root0.combine - * @param injector + * @param optionsFn - A function that returns queries' options. + * @param injector - The Angular injector to use. + * @public */ export function injectQueries< T extends Array, TCombinedResult = QueriesResults, >( - { - queries, - ...options - }: { - queries: Signal<[...QueriesOptions]> - combine?: (result: QueriesResults) => TCombinedResult - }, + optionsFn: () => InjectQueriesOptions, injector?: Injector, ): Signal { !injector && assertInInjectionContext(injectQueries) @@ -219,9 +232,21 @@ export function injectQueries< const queryClient = inject(QueryClient) const isRestoring = injectIsRestoring() + /** + * Signal that has the default options from query client applied + * computed() is used so signals can be inserted into the options + * making it reactive. Wrapping options in a function ensures embedded expressions + * are preserved and can keep being applied after signal changes + */ + const optionsSignal = computed(() => { + return optionsFn() + }) + const defaultedQueries = computed(() => { - return queries().map((opts) => { - const defaultedOptions = queryClient.defaultQueryOptions(opts) + return optionsSignal().queries.map((opts) => { + const defaultedOptions = queryClient.defaultQueryOptions( + opts as QueryObserverOptions, + ) // Make sure the results are already in fetching state before subscribing or updating options defaultedOptions._optimisticResults = isRestoring() ? 'isRestoring' @@ -231,37 +256,76 @@ export function injectQueries< }) }) - const observer = new QueriesObserver( - queryClient, - defaultedQueries(), - options as QueriesObserverOptions, + const observerSignal = (() => { + let instance: QueriesObserver | null = null + + return computed(() => { + return (instance ||= new QueriesObserver( + queryClient, + defaultedQueries(), + optionsSignal() as QueriesObserverOptions, + )) + }) + })() + + const optimisticResultSignal = computed(() => + observerSignal().getOptimisticResult( + defaultedQueries(), + (optionsSignal() as QueriesObserverOptions).combine, + ), ) // Do not notify on updates because of changes in the options because // these changes should already be reflected in the optimistic result. effect(() => { - observer.setQueries( + observerSignal().setQueries( defaultedQueries(), - options as QueriesObserverOptions, + optionsSignal() as QueriesObserverOptions, ) }) - const [, getCombinedResult] = observer.getOptimisticResult( - defaultedQueries(), - (options as QueriesObserverOptions).combine, - ) + const optimisticCombinedResultSignal = computed(() => { + const [_optimisticResult, getCombinedResult, trackResult] = + optimisticResultSignal() + return getCombinedResult(trackResult()) + }) - const result = signal(getCombinedResult() as any) + const resultFromSubscriberSignal = signal(null) effect(() => { - const unsubscribe = isRestoring() - ? () => undefined - : ngZone.runOutsideAngular(() => - observer.subscribe(notifyManager.batchCalls(result.set)), - ) - destroyRef.onDestroy(unsubscribe) + const observer = observerSignal() + const [_optimisticResult, getCombinedResult] = optimisticResultSignal() + + untracked(() => { + const unsubscribe = isRestoring() + ? () => undefined + : ngZone.runOutsideAngular(() => + observer.subscribe( + notifyManager.batchCalls((state) => { + resultFromSubscriberSignal.set(getCombinedResult(state)) + }), + ), + ) + + destroyRef.onDestroy(unsubscribe) + }) + }) + + const resultSignal = computed(() => { + const subscriberResult = resultFromSubscriberSignal() + const optimisticResult = optimisticCombinedResultSignal() + return subscriberResult ?? optimisticResult }) - return result - }) + return computed(() => { + const result = resultSignal() + const { combine } = optionsSignal() + + return combine + ? result + : (result as QueriesResults).map((query) => + signalProxy(signal(query)), + ) + }) + }) as unknown as Signal } diff --git a/packages/angular-query-persist-client/package.json b/packages/angular-query-persist-client/package.json index 3f455949cf..07e6961197 100644 --- a/packages/angular-query-persist-client/package.json +++ b/packages/angular-query-persist-client/package.json @@ -64,7 +64,7 @@ "@angular/platform-browser": "^20.0.0", "@tanstack/angular-query-experimental": "workspace:*", "@tanstack/query-test-utils": "workspace:*", - "@testing-library/angular": "^17.3.7", + "@testing-library/angular": "^18.0.0", "@testing-library/dom": "^10.4.0", "eslint-plugin-jsdoc": "^50.5.0", "npm-run-all2": "^5.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17e4257268..95b6170724 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2260,6 +2260,9 @@ importers: '@tanstack/query-test-utils': specifier: workspace:* version: link:../query-test-utils + '@testing-library/angular': + specifier: ^18.0.0 + version: 18.0.0(b638270d50b9f611fb362719c9f1adf5) eslint-plugin-jsdoc: specifier: ^50.5.0 version: 50.5.0(eslint@9.29.0(jiti@2.4.2)) @@ -2309,8 +2312,8 @@ importers: specifier: workspace:* version: link:../query-test-utils '@testing-library/angular': - specifier: ^17.3.7 - version: 17.3.7(44e06ac0fb247039d3919c909462d711) + specifier: ^18.0.0 + version: 18.0.0(b638270d50b9f611fb362719c9f1adf5) '@testing-library/dom': specifier: ^10.4.0 version: 10.4.0 @@ -6594,14 +6597,13 @@ packages: resolution: {integrity: sha512-WpL1C9iR5/U7g3GpvHIssN5QvKnDnWhW05BQhaD6bAqoPCkQyBepxUF8ZRO4IGZRGVAZeMVqTbUA05BAQH/88g==} engines: {node: '>=18'} - '@testing-library/angular@17.3.7': - resolution: {integrity: sha512-99Wf/06CCyBP3rmIu+WacUTGZMDKTQR12phe1lUMrknwxHLFUf5jn230L/mW4XIZ+ThDJ/4D6OzhVskbOYDqig==} + '@testing-library/angular@18.0.0': + resolution: {integrity: sha512-0seNMa4ql2I3VD7CtnI9i4sFgxEgRES+EtGid8H4MTuOK/dlj457mVk8tWdFjPQPC/cPromcUNw0is1ogO3DSA==} peerDependencies: - '@angular/animations': '>= 17.0.0' - '@angular/common': '>= 17.0.0' - '@angular/core': '>= 17.0.0' - '@angular/platform-browser': '>= 17.0.0' - '@angular/router': '>= 17.0.0' + '@angular/common': '>= 20.0.0' + '@angular/core': '>= 20.0.0' + '@angular/platform-browser': '>= 20.0.0' + '@angular/router': '>= 20.0.0' '@testing-library/dom': ^10.0.0 '@testing-library/dom@10.4.0': @@ -19888,9 +19890,8 @@ snapshots: - typescript - vite - '@testing-library/angular@17.3.7(44e06ac0fb247039d3919c909462d711)': + '@testing-library/angular@18.0.0(b638270d50b9f611fb362719c9f1adf5)': dependencies: - '@angular/animations': 20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) '@angular/platform-browser': 20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))