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
5 changes: 5 additions & 0 deletions .changeset/angular-query-preaccess-whenstable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/angular-query-experimental': patch
---

Keep Angular's `whenStable()` pending when a query result signal is accessed before render.
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,40 @@ describe('injectQuery', () => {
)
})

it('should keep whenStable pending when a query data signal is captured before render', async () => {
@Component({
selector: 'app-fake',
template: `{{ data()?.title }}`,
})
class FakeComponent {
query = injectQuery(() => ({
queryKey: ['query-data-signal-pre-access'],
queryFn: () => sleep(50).then(() => ({ title: 'query-data' })),
}))

data = this.query.data
}

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

let didStabilize = false
const stablePromise = fixture.whenStable().then(() => {
didStabilize = true
})

await Promise.resolve()
expect(didStabilize).toBe(false)

await vi.advanceTimersByTimeAsync(60)
await stablePromise

expect(fixture.componentInstance.data()).toEqual({ title: 'query-data' })
expect(fixture.componentInstance.query.data()).toEqual({
title: 'query-data',
})
})

describe('isRestoring', () => {
it('should not fetch for the duration of the restoring period when isRestoring is true', async () => {
const key = queryKey()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,18 @@ describe('PendingTasks Integration', () => {
}))
}

@Component({
template: `{{ data()?.title }}`,
})
class NeverResolvesComponent {
query = injectQuery(() => ({
queryKey: ['never-resolve-query'],
queryFn: () => new Promise<{ title: string }>(() => {}),
}))

data = this.query.data
}

it('should cleanup pending tasks when component with active query is destroyed', async () => {
const app = TestBed.inject(ApplicationRef)
const fixture = TestBed.createComponent(TestComponent)
Expand All @@ -314,6 +326,28 @@ describe('PendingTasks Integration', () => {
await expect(stablePromise).resolves.toEqual(undefined)
})

it('should cleanup query promise pending task when component with unresolved query is destroyed', async () => {
const app = TestBed.inject(ApplicationRef)
const fixture = TestBed.createComponent(NeverResolvesComponent)

fixture.detectChanges()

const stableResult = app.whenStable().then(() => true)

fixture.destroy()

const timeoutResult = new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(false)
}, 10)
})

await vi.advanceTimersByTimeAsync(10)

const isStableResolved = await Promise.race([stableResult, timeoutResult])
expect(isStableResolved).toBe(true)
})
Comment on lines +329 to +349
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert instability before destroy so this can't pass as a false positive.

As written, this still passes if whenStable() resolves immediately after detectChanges() and destroy cleanup never does anything. Check that the promise is still pending before fixture.destroy(), then assert it resolves after destroy.

Suggested test tightening
     const app = TestBed.inject(ApplicationRef)
     const fixture = TestBed.createComponent(NeverResolvesComponent)
 
     fixture.detectChanges()
 
-    const stableResult = app.whenStable().then(() => true)
+    let stableResolved = false
+    const stableResult = app.whenStable().then(() => {
+      stableResolved = true
+      return true
+    })
+
+    await Promise.resolve()
+    expect(stableResolved).toBe(false)
 
     fixture.destroy()
 
     const timeoutResult = new Promise<boolean>((resolve) => {
       setTimeout(() => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should cleanup query promise pending task when component with unresolved query is destroyed', async () => {
const app = TestBed.inject(ApplicationRef)
const fixture = TestBed.createComponent(NeverResolvesComponent)
fixture.detectChanges()
const stableResult = app.whenStable().then(() => true)
fixture.destroy()
const timeoutResult = new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(false)
}, 10)
})
await vi.advanceTimersByTimeAsync(10)
const isStableResolved = await Promise.race([stableResult, timeoutResult])
expect(isStableResolved).toBe(true)
})
it('should cleanup query promise pending task when component with unresolved query is destroyed', async () => {
const app = TestBed.inject(ApplicationRef)
const fixture = TestBed.createComponent(NeverResolvesComponent)
fixture.detectChanges()
let stableResolved = false
const stableResult = app.whenStable().then(() => {
stableResolved = true
return true
})
await Promise.resolve()
expect(stableResolved).toBe(false)
fixture.destroy()
const timeoutResult = new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(false)
}, 10)
})
await vi.advanceTimersByTimeAsync(10)
const isStableResolved = await Promise.race([stableResult, timeoutResult])
expect(isStableResolved).toBe(true)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts`
around lines 329 - 349, The test may be a false positive because it never
asserts that app.whenStable() (stableResult) is pending before destroy; update
the test around NeverResolvesComponent to first create stableResult =
app.whenStable() after fixture.detectChanges(), then assert it has not yet
resolved (e.g., race it against a short timeout and expect the timeout to win)
before calling fixture.destroy(), and only then advance timers and assert
stableResult resolves; reference the symbols TestBed.inject(ApplicationRef),
fixture.detectChanges(), stableResult, fixture.destroy(), and
vi.advanceTimersByTimeAsync to find and modify the code paths.


it('should cleanup pending tasks when component with active mutation is destroyed', async () => {
const app = TestBed.inject(ApplicationRef)
const fixture = TestBed.createComponent(TestComponent)
Expand Down
92 changes: 66 additions & 26 deletions packages/angular-query-experimental/src/create-base-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,41 @@ export function createBaseQuery<
defaultedOptions._optimisticResults = isRestoring()
? 'isRestoring'
: 'optimistic'

if (!isRestoring() && typeof defaultedOptions.queryFn === 'function') {
const originalQueryFn = defaultedOptions.queryFn

defaultedOptions.queryFn = (context) => {
const result = originalQueryFn(context)

if (result && typeof result.then === 'function') {
const complete = markPendingQueryFnTask()
void result.then(
complete,
complete,
)
Comment on lines +67 to +75
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Verify query function contexts expose AbortSignal:"
rg -n -C2 'signal:\s*AbortSignal' packages/query-core/src

echo
echo "Inspect the wrapped queryFn and PendingTask cleanup paths:"
sed -n '64,96p' packages/angular-query-experimental/src/create-base-query.ts
sed -n '183,193p' packages/angular-query-experimental/src/create-base-query.ts

Repository: TanStack/query

Length of output: 3458


Complete queryFn PendingTask on AbortSignal abort (not only promise settle)

packages/angular-query-experimental/src/create-base-query.ts wraps defaultedOptions.queryFn so thenables call markPendingQueryFnTask() but only complete() inside result.then(complete, complete). Since @tanstack/query-core provides queryFn context with an AbortSignal, a cancelled/superseded request whose promise never settles will keep the pending task open until Angular cleanup/destroy, which can keep whenStable() waiting. Hook complete() to context.signal abort too.

Suggested fix
       defaultedOptions.queryFn = (context) =&gt; {
         const result = originalQueryFn(context)
 
         if (result &amp;&amp; typeof result.then === 'function') {
           const complete = markPendingQueryFnTask()
+          const onAbort = () =&gt; {
+            complete()
+          }
+
+          context.signal.addEventListener('abort', onAbort, { once: true })
           void result.then(
-            complete,
-            complete,
+            () =&gt; {
+              context.signal.removeEventListener('abort', onAbort)
+              complete()
+            },
+            () =&gt; {
+              context.signal.removeEventListener('abort', onAbort)
+              complete()
+            },
           )
         }
 
         return result
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/angular-query-experimental/src/create-base-query.ts` around lines 67
- 75, The wrapper for defaultedOptions.queryFn currently calls
markPendingQueryFnTask() and only invokes complete() on the promise settle,
leaving the pending task open if the AbortSignal aborts before the promise
settles; update the wrapper around originalQueryFn to also listen to
context.signal (or context.signal.addEventListener('abort', ...)) and call
complete() when the signal aborts, and ensure the abort listener is removed when
the promise settles (or when complete() runs) so markPendingQueryFnTask() is
always completed on either promise settle or signal abort.

}

return result
}
}

return defaultedOptions
})

const pendingTaskRefsFromQueryFn = new Set<PendingTaskRef>()

const markPendingQueryFnTask = () => {
const pendingTaskRef = pendingTasks.add()
const done = () => {
if (pendingTaskRefsFromQueryFn.delete(done)) {
pendingTaskRef()
}
}
pendingTaskRefsFromQueryFn.add(done)
return done
}

const observerSignal = (() => {
let instance: QueryObserver<
TQueryFnData,
Expand Down Expand Up @@ -110,37 +142,41 @@ export function createBaseQuery<
const observer = observerSignal()
let pendingTaskRef: PendingTaskRef | null = null

const updateState = (state: QueryObserverResult<TData, TError>) => {
ngZone.run(() => {
if (state.fetchStatus === 'fetching' && !pendingTaskRef) {
pendingTaskRef = pendingTasks.add()
}

if (state.fetchStatus === 'idle' && pendingTaskRef) {
pendingTaskRef()
pendingTaskRef = null
}

if (
state.isError &&
!state.isFetching &&
shouldThrowError(observer.options.throwOnError, [
state.error,
observer.getCurrentQuery(),
])
) {
ngZone.onError.emit(state.error)
throw state.error
}
resultFromSubscriberSignal.set(state)
})
}

const unsubscribe = isRestoring()
? () => undefined
: untracked(() =>
ngZone.runOutsideAngular(() => {
return observer.subscribe(
notifyManager.batchCalls((state) => {
ngZone.run(() => {
if (state.fetchStatus === 'fetching' && !pendingTaskRef) {
pendingTaskRef = pendingTasks.add()
}

if (state.fetchStatus === 'idle' && pendingTaskRef) {
pendingTaskRef()
pendingTaskRef = null
}

if (
state.isError &&
!state.isFetching &&
shouldThrowError(observer.options.throwOnError, [
state.error,
observer.getCurrentQuery(),
])
) {
ngZone.onError.emit(state.error)
throw state.error
}
resultFromSubscriberSignal.set(state)
})
}),
const unsubscribeObserver = observer.subscribe(
notifyManager.batchCalls(updateState),
)

return unsubscribeObserver
}),
)

Expand All @@ -149,6 +185,10 @@ export function createBaseQuery<
pendingTaskRef()
pendingTaskRef = null
}
for (const pendingTaskRefFromQueryFn of pendingTaskRefsFromQueryFn) {
pendingTaskRefFromQueryFn()
}
pendingTaskRefsFromQueryFn.clear()
unsubscribe()
})
})
Expand Down
47 changes: 47 additions & 0 deletions packages/query-core/src/__tests__/queryClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,53 @@ describe('queryClient', () => {
expect(queryFn2).toHaveBeenCalledTimes(0)
expect(didSkipTokenRun).toBe(false)
})

it('should refetch queries matched by a state-dependent predicate even though reset() mutates state', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi
.fn<(...args: Array<unknown>) => string>()
.mockReturnValue('data')
const queryFn2 = vi
.fn<(...args: Array<unknown>) => string>()
.mockRejectedValue('error')
const observer1 = new QueryObserver(queryClient, {
queryKey: key1,
queryFn: queryFn1,
})
const observer2 = new QueryObserver(queryClient, {
queryKey: key2,
queryFn: queryFn2,
retry: false,
})

observer1.subscribe(() => undefined)
observer2.subscribe(() => undefined)

await vi.waitFor(() => {
expect(queryClient.getQueryState(key1)?.status).toBe('success')
expect(queryClient.getQueryState(key2)?.status).toBe('error')
})
expect(queryFn1).toHaveBeenCalledTimes(1)
expect(queryFn2).toHaveBeenCalledTimes(1)

await queryClient.resetQueries({
predicate: (query) => query.state.status === 'success',
})

expect(queryFn1).toHaveBeenCalledTimes(2)
expect(queryFn2).toHaveBeenCalledTimes(1)

await queryClient.resetQueries({
predicate: (query) => query.state.status === 'error',
})

expect(queryFn1).toHaveBeenCalledTimes(2)
expect(queryFn2).toHaveBeenCalledTimes(2)

observer1.destroy()
observer2.destroy()
})
})

describe('focusManager and onlineManager', () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/query-core/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,13 +260,15 @@ export class QueryClient {
const queryCache = this.#queryCache

return notifyManager.batch(() => {
queryCache.findAll(filters).forEach((query) => {
const matched = queryCache.findAll(filters)
const matchedHashes = new Set(matched.map((query) => query.queryHash))
matched.forEach((query) => {
query.reset()
})
return this.refetchQueries(
{
type: 'active',
...filters,
predicate: (query) => matchedHashes.has(query.queryHash),
},
options,
)
Expand Down