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)
})

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 thread
coderabbitai[bot] marked this conversation as resolved.
}

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