Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,42 +1,24 @@
import { TestBed } from '@angular/core/testing'
import { afterEach, beforeEach, describe, expectTypeOf, test, vi } from 'vitest'
import { provideZonelessChangeDetection } from '@angular/core'
import { sleep } from '@tanstack/query-test-utils'
import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..'
import { describe, expectTypeOf, test } from 'vitest'
import { injectInfiniteQuery } from '..'
import type { InfiniteData } from '@tanstack/query-core'

describe('injectInfiniteQuery', () => {
let queryClient: QueryClient

beforeEach(() => {
queryClient = new QueryClient()
vi.useFakeTimers()
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
provideTanStackQuery(queryClient),
],
})
})

afterEach(() => {
vi.useRealTimers()
})

test('should narrow type after isSuccess', () => {
const query = TestBed.runInInjectionContext(() => {
return injectInfiniteQuery(() => ({
queryKey: ['infiniteQuery'],
queryFn: ({ pageParam }) =>
sleep(0).then(() => 'data on page ' + pageParam),
initialPageParam: 0,
getNextPageParam: () => 12,
}))
})
test('should narrow type with isSuccess, isError, isPending', () => {
const query = injectInfiniteQuery(() => ({
queryKey: ['infiniteQuery'],
queryFn: () => Promise.resolve('data'),
initialPageParam: 1,
getNextPageParam: () => 12,
}))

if (query.isSuccess()) {
const data = query.data()
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, unknown>>()
expectTypeOf(query.error()).toEqualTypeOf<null>()
expectTypeOf(query.data()).toEqualTypeOf<InfiniteData<string, unknown>>()
} else if (query.isError()) {
expectTypeOf(query.error()).toEqualTypeOf<Error>()
} else if (query.isPending()) {
expectTypeOf(query.data()).toEqualTypeOf<undefined>()
expectTypeOf(query.error()).toEqualTypeOf<null>()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,29 @@ describe('InjectQueries config object overload', () => {
>
>()
})

it('should return correct data when combine is provided', () => {
const queryResults = injectQueries(() => ({
queries: [
{
queryKey: ['key1'],
queryFn: () => Promise.resolve(1),
},
{
queryKey: ['key2'],
queryFn: () => Promise.resolve('2'),
},
],
combine: (results) => {
return {
data: [results[0].data, results[1].data] as const,
pending: results.some((r) => r.isPending),
}
},
}))

expectTypeOf(queryResults().data[0]).toEqualTypeOf<number | undefined>()
expectTypeOf(queryResults().data[1]).toEqualTypeOf<string | undefined>()
expectTypeOf(queryResults().pending).toEqualTypeOf<boolean>()
})
})
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/angular'
import { ChangeDetectionStrategy, Component, effect } from '@angular/core'
import { queryKey } from '@tanstack/query-test-utils'
import { QueryClient } from '..'
import { injectQueries } from '../inject-queries'
import { setupTanStackQueryTestBed } from './test-utils'

let queryClient: QueryClient
import { TestBed } from '@angular/core/testing'

beforeEach(() => {
queryClient = new QueryClient()
const queryClient = new QueryClient()
setupTanStackQueryTestBed(queryClient)
vi.useFakeTimers({ shouldAdvanceTime: true })
})

afterEach(() => {
vi.useRealTimers()
})

describe('injectQueries', () => {
Expand All @@ -22,10 +26,8 @@ describe('injectQueries', () => {
@Component({
template: `
<div>
<div>
data1: {{ result()[0].data() ?? 'null' }}, data2:
{{ result()[1].data() ?? 'null' }}
</div>
data1: {{ result()[0].data() ?? 'null' }}, data2:
{{ result()[1].data() ?? 'null' }}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down Expand Up @@ -68,4 +70,54 @@ describe('injectQueries', () => {
expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }])
expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }])
})

it('should combine results', async () => {
const key1 = queryKey()
const key2 = queryKey()

const result = TestBed.runInInjectionContext(() =>
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'
},
},
],
combine: (results) => {
return {
data: results.map((r) => r.data),
pending: results.some((r) => r.isPending),
}
},
})),
)

expect(result().data.length).toBe(2)

expect(result().pending).toBe(true)
expect(result().data[0]).toBe(undefined)
expect(result().data[1]).toBe(undefined)

await vi.advanceTimersByTimeAsync(15)

expect(result().pending).toBe(true)
expect(result().data[0]).toBe(1)
expect(result().data[1]).toBe(undefined)

await vi.advanceTimersByTimeAsync(100)

expect(result().pending).toBe(false)
expect(result().data[0]).toBe(1)
expect(result().data[1]).toBe('2')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,31 @@ describe('injectQuery', () => {
expect(result).toEqual('signal-input-required-test')
})

test('should keep query signals in sync when read in the template', async () => {
@Component({
selector: 'app-template-query',
template: `{{ query.data() }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class TemplateQueryComponent {
query = injectQuery(() => ({
queryKey: ['template-query'],
queryFn: () => Promise.resolve([1, 2, 3, 4, 5] as const),
}))
}

const fixture = TestBed.createComponent(TemplateQueryComponent)
fixture.detectChanges()

expect(fixture.isStable()).toBe(false)
const stablePromise = fixture.whenStable()
await vi.advanceTimersByTimeAsync(0)
await stablePromise

expect(fixture.componentInstance.query.isSuccess()).toBe(true)
expect(fixture.componentInstance.query.data()).toEqual([1, 2, 3, 4, 5])
})

describe('injection context', () => {
test('throws NG0203 with descriptive error outside injection context', () => {
expect(() => {
Expand Down Expand Up @@ -849,7 +874,9 @@ describe('injectQuery', () => {
const component = fixture.componentInstance
const query = component.query

await app.whenStable()
const stablePromise = app.whenStable()
await vi.advanceTimersByTimeAsync(10)
await stablePromise
Comment on lines +877 to +879

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem of awaiting app.whenStable() directly is that it could depend on time advancing, so the promise needs to be awaited after advancing time.


expect(query.status()).toBe('success')
expect(query.data()).toBe('sync-data-1')
Expand Down Expand Up @@ -951,7 +978,10 @@ describe('injectQuery', () => {
const component = fixture.componentInstance
const query = component.query

await app.whenStable()
const stablePromise = app.whenStable()
await vi.advanceTimersByTimeAsync(10)
await stablePromise

expect(query.status()).toBe('success')
expect(query.data()).toBe('sync-data-1')
expect(component.callCount).toBe(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,38 +355,42 @@ describe('PendingTasks Integration', () => {
}

test('should cleanup pending tasks when component with active query is destroyed', async () => {
const app = TestBed.inject(ApplicationRef)
const fixture = TestBed.createComponent(TestComponent)
fixture.detectChanges()

// Start the query
expect(fixture.componentInstance.query.status()).toBe('pending')
expect(fixture.isStable()).toBe(false)

// Destroy component while query is running
fixture.destroy()

// Angular should become stable even though component was destroyed
const stablePromise = app.whenStable()
const stablePromise = fixture.whenStable()
await vi.advanceTimersByTimeAsync(150)

await expect(stablePromise).resolves.toEqual(undefined)
await stablePromise
expect(fixture.isStable()).toBe(true)
})

test('should cleanup pending tasks when component with active mutation is destroyed', async () => {
const app = TestBed.inject(ApplicationRef)
const fixture = TestBed.createComponent(TestComponent)
fixture.detectChanges()

fixture.componentInstance.mutation.mutate('test')
fixture.detectChanges()
expect(fixture.isStable()).toBe(false)

// Destroy component while mutation is running
fixture.destroy()
fixture.detectChanges()

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling fixture.detectChanges() after fixture.destroy() is problematic and can lead to errors or unexpected behavior in Angular. Once a fixture is destroyed, change detection should not be triggered on it. This line should be removed.

Suggested change
fixture.detectChanges()

Copilot uses AI. Check for mistakes.
expect(fixture.isStable()).toBe(true)

// Angular should become stable even though component was destroyed
const stablePromise = app.whenStable()
await vi.advanceTimersByTimeAsync(150)
const stablePromise = fixture.whenStable()
await vi.advanceTimersByTimeAsync(200)
await stablePromise

await expect(stablePromise).resolves.toEqual(undefined)
expect(fixture.isStable()).toBe(true)
})
})

Expand Down
5 changes: 5 additions & 0 deletions packages/angular-query-experimental/src/create-base-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ export function createBaseQuery<
throw new Error(OBSERVER_NOT_READY_ERROR)
}

const initialState = observer.getCurrentResult()
if (initialState.fetchStatus !== 'idle') {
startPendingTask()
}

return observer.subscribe((state) => {
if (state.fetchStatus !== 'idle') {
startPendingTask()
Expand Down
4 changes: 4 additions & 0 deletions packages/angular-query-experimental/src/inject-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,16 @@ export function injectMutation<
effect(
(onCleanup) => {
const observer = observerSignal()
let destroyed = false
let taskCleanupRef: (() => void) | null = null

untracked(() => {
const unsubscribe = ngZone.runOutsideAngular(() =>
observer.subscribe(
notifyManager.batchCalls((state) => {
ngZone.run(() => {
if (destroyed) return

// Track pending task when mutation is pending
if (state.isPending && !taskCleanupRef) {
taskCleanupRef = pendingTasks.add()
Comment on lines +136 to 140

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some race condition caused that the subscription to be called after the component was destroyed, starting a pending task without a callback to stop it.

Expand All @@ -158,6 +161,7 @@ export function injectMutation<
)
onCleanup(() => {
// Clean up any pending task on destroy
destroyed = true
if (taskCleanupRef) {
taskCleanupRef()
taskCleanupRef = null
Expand Down
54 changes: 53 additions & 1 deletion packages/angular-query-experimental/src/inject-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
QueryFunction,
QueryKey,
QueryObserverOptions,
QueryObserverResult,
ThrowOnError,
} from '@tanstack/query-core'
import type {
Expand Down Expand Up @@ -138,6 +139,57 @@ type GetCreateQueryResult<T> =
: // Fallback
CreateQueryResult

// For the combine callback - uses core QueryObserverResult (plain values, not signals)
type GetQueryObserverResultForCombine<T> = T extends {
queryFnData: any
error?: infer TError
data: infer TData
}
? QueryObserverResult<TData, TError>
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
? QueryObserverResult<TQueryFnData, TError>
: T extends { data: infer TData; error?: infer TError }
? QueryObserverResult<TData, TError>
: T extends [any, infer TError, infer TData]
? QueryObserverResult<TData, TError>
: T extends [infer TQueryFnData, infer TError]
? QueryObserverResult<TQueryFnData, TError>
: T extends [infer TQueryFnData]
? QueryObserverResult<TQueryFnData>
: T extends {
queryFn?:
| QueryFunction<infer TQueryFnData, any>
| SkipTokenForCreateQueries
select?: (data: any) => infer TData
throwOnError?: ThrowOnError<any, infer TError, any, any>
}
? QueryObserverResult<
unknown extends TData ? TQueryFnData : TData,
unknown extends TError ? DefaultError : TError
>
: QueryObserverResult

/**
* CombineResults reducer recursively maps type param to core QueryObserverResult (for combine callback)
*/
type CombineResults<
T extends Array<any>,
TResults extends Array<any> = [],
TDepth extends ReadonlyArray<number> = [],
> = TDepth['length'] extends MAXIMUM_DEPTH
? Array<QueryObserverResult>
: T extends []
? []
: T extends [infer Head]
? [...TResults, GetQueryObserverResultForCombine<Head>]
: T extends [infer Head, ...infer Tails]
? CombineResults<
[...Tails],
[...TResults, GetQueryObserverResultForCombine<Head>],
[...TDepth, 1]
>
: { [K in keyof T]: GetQueryObserverResultForCombine<T[K]> }

Comment on lines +143 to +192

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opus 4.5. It seems to work as expected, and it matches the structure of the type helpers. I'm not a type-wizard to understand it completely without help.

/**
* QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param
*/
Expand Down Expand Up @@ -210,7 +262,7 @@ export interface InjectQueriesOptions<
| readonly [
...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries<T[K]> },
]
combine?: (result: QueriesResults<T>) => TCombinedResult
combine?: (result: CombineResults<T>) => TCombinedResult
}

/**
Expand Down