diff --git a/.changeset/angular-query-injectquery-skiptoken-getter-typecheck.md b/.changeset/angular-query-injectquery-skiptoken-getter-typecheck.md new file mode 100644 index 00000000000..c4e64a4c802 --- /dev/null +++ b/.changeset/angular-query-injectquery-skiptoken-getter-typecheck.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': patch +--- + +fix(angular-query-experimental): widen 'SkipToken' to 'symbol' so it type-checks inside a whole-options getter diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts index c0864c0c99e..df8a54f5c1d 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts @@ -1,6 +1,6 @@ import { describe, expectTypeOf, it } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' -import { injectQuery, queryOptions } from '..' +import { injectQuery, queryOptions, skipToken } from '..' import type { Signal } from '@angular/core' describe('injectQuery', () => { @@ -191,4 +191,20 @@ describe('injectQuery', () => { } }) }) + + describe('skipToken', () => { + it('should narrow data to string | undefined for a conditional skipToken inside a whole-options getter', () => { + const key = queryKey() + const postId: number | undefined = 1 + const query = injectQuery(() => ({ + queryKey: [...key, postId], + queryFn: + postId != null + ? () => sleep(0).then(() => `post ${postId}`) + : skipToken, + })) + + expectTypeOf(query.data).toEqualTypeOf>() + }) + }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index af3257f3bbe..f1b55f6c9fe 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -32,6 +32,7 @@ import { injectQuery, provideIsRestoring, provideTanStackQuery, + skipToken, } from '..' import { setSignalInputs } from './test-utils' import type { CreateQueryOptions, OmitKeyof, QueryFunction } from '..' @@ -920,4 +921,48 @@ describe('injectQuery', () => { expect(callCount).toBe(2) }) }) + + describe('skipToken', () => { + it('should not fetch when queryFn is skipToken, and fetch once it is replaced', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'post 1')) + + @Component({ + template: ` +
status: {{ query.status() }}
+
isFetching: {{ query.isFetching() }}
+
data: {{ query.data() ?? 'none' }}
+ `, + }) + class Page { + postId = signal(undefined) + + readonly query = injectQuery(() => ({ + queryKey: [...key, this.postId()], + queryFn: this.postId() != null ? queryFn : skipToken, + })) + } + + const rendered = await render(Page) + + expect(rendered.getByText('status: pending')).toBeInTheDocument() + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(queryFn).not.toHaveBeenCalled() + expect(rendered.getByText('status: pending')).toBeInTheDocument() + expect(rendered.getByText('isFetching: false')).toBeInTheDocument() + + rendered.fixture.componentInstance.postId.set('1') + rendered.fixture.detectChanges() + expect(rendered.getByText('isFetching: true')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(queryFn).toHaveBeenCalledTimes(1) + expect(rendered.getByText('status: success')).toBeInTheDocument() + expect(rendered.getByText('data: post 1')).toBeInTheDocument() + }) + }) }) diff --git a/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts b/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts index c4462afaa34..15fe9f0f0bf 100644 --- a/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/query-options.test-d.ts @@ -8,6 +8,7 @@ import { skipToken, } from '..' import type { Signal } from '@angular/core' +import type { UnusedSkipTokenOptions } from '..' // Regression test for exported queryOptions inference under declaration emit. // TypeScript should be able to name the return type without expanding the @@ -122,6 +123,20 @@ it('should work when passed to query with skipToken', () => { assertType>(data) }) +it('should not allow skipToken on UnusedSkipTokenOptions', () => { + const options: UnusedSkipTokenOptions< + number, + Error, + number, + Array + > = { + queryKey: ['key'], + // @ts-expect-error skipToken should not be assignable here + queryFn: skipToken, + } + expectTypeOf(options.queryKey).toEqualTypeOf>() +}) + it('should tag the queryKey with the result type of the QueryFn', () => { const key = queryKey() const { queryKey: tagged } = queryOptions({ diff --git a/packages/angular-query-experimental/src/inject-query.ts b/packages/angular-query-experimental/src/inject-query.ts index 973538ca053..c3670765eec 100644 --- a/packages/angular-query-experimental/src/inject-query.ts +++ b/packages/angular-query-experimental/src/inject-query.ts @@ -8,6 +8,7 @@ import { import { createBaseQuery } from './create-base-query' import type { DefaultError, QueryKey } from '@tanstack/query-core' import type { + CreateBaseQueryOptions, CreateQueryOptions, CreateQueryResult, DefinedCreateQueryResult, @@ -203,6 +204,9 @@ export function injectQuery( ) { !options?.injector && assertInInjectionContext(injectQuery) return runInInjectionContext(options?.injector ?? inject(Injector), () => - createBaseQuery(injectQueryFn, QueryObserver), + createBaseQuery( + injectQueryFn as () => CreateBaseQueryOptions, + QueryObserver, + ), ) as unknown as CreateQueryResult } diff --git a/packages/angular-query-experimental/src/query-options.ts b/packages/angular-query-experimental/src/query-options.ts index 0de61895e72..9ade14072a2 100644 --- a/packages/angular-query-experimental/src/query-options.ts +++ b/packages/angular-query-experimental/src/query-options.ts @@ -62,10 +62,7 @@ export type UnusedSkipTokenOptions< * fetch that fails with "Missing queryFn" unless `enabled` is `false` or a default query function has been * defined. A default query function only supplies `queryFn`; it doesn't defer the fetch on its own. */ - queryFn?: Exclude< - CreateQueryOptions['queryFn'], - SkipToken | undefined - > + queryFn?: QueryFunction } /** @@ -285,8 +282,12 @@ export function queryOptions< TQueryKey extends QueryKey = QueryKey, >( options: UndefinedInitialDataOptions, -): UndefinedInitialDataOptions & - QueryKeyWithDataTag +): OmitKeyof< + UndefinedInitialDataOptions, + 'queryFn' +> & { + queryFn?: QueryFunction | SkipToken +} & QueryKeyWithDataTag export function queryOptions(options: unknown) { return options diff --git a/packages/angular-query-experimental/src/types.ts b/packages/angular-query-experimental/src/types.ts index b0acd8579fc..9a0d3040c8d 100644 --- a/packages/angular-query-experimental/src/types.ts +++ b/packages/angular-query-experimental/src/types.ts @@ -11,6 +11,7 @@ import type { MutationObserverResult, OmitKeyof, Override, + QueryFunction, QueryKey, QueryObserverOptions, QueryObserverResult, @@ -18,6 +19,9 @@ import type { import type { Signal } from '@angular/core' import type { MapToSignals } from './signal-proxy' +// Widen the type of the symbol to enable type inference even if skipToken is not immutable. +type SkipTokenForCreateQueryOptions = symbol + /** * The options shared across `angular-query-experimental`'s query functions. Extends * {@link QueryObserverOptions} from `@tanstack/query-core` as-is — unlike `react-query`, @@ -55,15 +59,19 @@ export interface CreateBaseQueryOptions< * `select` is used. * @template TQueryKey - The type of your `queryKey`. */ -export interface CreateQueryOptions< +export type CreateQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> extends OmitKeyof< +> = OmitKeyof< CreateBaseQueryOptions, - 'suspense' -> {} + 'suspense' | 'queryFn' +> & { + queryFn?: + | QueryFunction + | SkipTokenForCreateQueryOptions +} type CreateStatusBasedQueryResult< TStatus extends QueryObserverResult['status'],