From 42be767fb6882061edde901ad235a3055ec293b8 Mon Sep 17 00:00:00 2001 From: Enea Jahollari Date: Fri, 19 Jun 2026 11:59:58 +0200 Subject: [PATCH 1/2] feat(angular-query): add resource-shaped APIs (queryResource, infiniteQueryResource, mutationResource) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds resource-shaped counterparts to `injectQuery`, `injectInfiniteQuery`, and `injectMutation`. Each returns a real Angular `Resource` (value/status/error/ isLoading/hasValue/snapshot) plus the TanStack result signals, and is backed by the same `QueryClient`, observers and cache as its `inject*` counterpart — so they dedupe and share data with existing queries. `queryResource` / `infiniteQueryResource` accept both an ergonomic config object (reactive `queryKey` / `enabled` thunks) and an options-function (whole-object reactive, identical to `injectQuery(() => ({ ... }))`). Implementation reuses the existing observer/subscription machinery by extracting `createBaseQueryResult` and `createMutationResult` from `createBaseQuery` / `injectMutation`, then projecting the result signal onto `resourceFromSnapshots`. Requires Angular >= 22 (uses the stable `resourceFromSnapshots` / `ResourceSnapshot` APIs); peer dependency raised accordingly. Co-Authored-By: Claude Opus 4.8 --- .changeset/angular-query-resource.md | 11 + docs/config.json | 4 + docs/framework/angular/guides/resources.md | 164 ++++++++++++++ .../angular-query-experimental/package.json | 12 +- .../src/__tests__/query-resource.test.ts | 214 ++++++++++++++++++ .../src/create-base-query.ts | 69 ++++-- .../angular-query-experimental/src/index.ts | 21 ++ .../src/infinite-query-resource.ts | 134 +++++++++++ .../src/inject-mutation.ts | 53 ++++- .../src/mutation-resource.ts | 145 ++++++++++++ .../src/query-resource.ts | 109 +++++++++ .../resource/create-base-query-resource.ts | 133 +++++++++++ .../src/resource/resource-types.ts | 211 +++++++++++++++++ .../src/resource/to-resource-snapshot.ts | 50 ++++ 14 files changed, 1294 insertions(+), 36 deletions(-) create mode 100644 .changeset/angular-query-resource.md create mode 100644 docs/framework/angular/guides/resources.md create mode 100644 packages/angular-query-experimental/src/__tests__/query-resource.test.ts create mode 100644 packages/angular-query-experimental/src/infinite-query-resource.ts create mode 100644 packages/angular-query-experimental/src/mutation-resource.ts create mode 100644 packages/angular-query-experimental/src/query-resource.ts create mode 100644 packages/angular-query-experimental/src/resource/create-base-query-resource.ts create mode 100644 packages/angular-query-experimental/src/resource/resource-types.ts create mode 100644 packages/angular-query-experimental/src/resource/to-resource-snapshot.ts diff --git a/.changeset/angular-query-resource.md b/.changeset/angular-query-resource.md new file mode 100644 index 00000000000..cc45776a94e --- /dev/null +++ b/.changeset/angular-query-resource.md @@ -0,0 +1,11 @@ +--- +'@tanstack/angular-query-experimental': minor +--- + +feat(angular-query): add resource-shaped APIs `queryResource`, `infiniteQueryResource`, and `mutationResource` + +These are resource-shaped counterparts to `injectQuery`, `injectInfiniteQuery`, and `injectMutation`. Each returns a real Angular `Resource` (`value`/`status`/`error`/`isLoading`/`hasValue`/`snapshot`) in addition to the TanStack result fields, and is backed by the **same** `QueryClient`, observers and cache as its `inject*` counterpart — so they dedupe and share data with existing queries. + +`queryResource` (and the infinite variant) accept both an ergonomic config object (with reactive `queryKey` / `enabled` thunks) and an options-function (whole-object reactive, identical semantics to `injectQuery(() => ({ ... }))`). + +NOTE: these APIs are built on Angular 22's stable resource snapshot APIs (`resourceFromSnapshots`, `ResourceSnapshot`), so this change raises the `@angular/core` / `@angular/common` peer dependency minimum to `>=22`. For consumers on Angular < 22 this is a breaking change; the existing `inject*` APIs are otherwise unchanged. Final release strategy (minor vs. major) is left to maintainers. diff --git a/docs/config.json b/docs/config.json index 2b1561d691a..6567917f6ae 100644 --- a/docs/config.json +++ b/docs/config.json @@ -674,6 +674,10 @@ "label": "Queries", "to": "framework/angular/guides/queries" }, + { + "label": "Resources", + "to": "framework/angular/guides/resources" + }, { "label": "Query Keys", "to": "framework/angular/guides/query-keys" diff --git a/docs/framework/angular/guides/resources.md b/docs/framework/angular/guides/resources.md new file mode 100644 index 00000000000..4398ada357e --- /dev/null +++ b/docs/framework/angular/guides/resources.md @@ -0,0 +1,164 @@ +--- +id: resources +title: Resources +--- + +> IMPORTANT: The resource APIs (`queryResource`, `infiniteQueryResource`, `mutationResource`) require **Angular 22 or newer**, because they are built on the stable [`resource` snapshot APIs](https://angular.dev/guide/signals/resource) (`resourceFromSnapshots`, `ResourceSnapshot`). + +`queryResource` is a resource-shaped alternative to [`injectQuery`](../queries). Instead of returning an object of signals, it returns a real Angular [`Resource`](https://angular.dev/guide/signals/resource), so it composes with everything in Angular that consumes a resource and exposes the familiar `value`/`status`/`error`/`isLoading`/`hasValue`/`snapshot` surface — **plus** all of the TanStack Query result fields. + +It is backed by the **same** `QueryClient`, observers and cache as `injectQuery`. A `queryResource` and an `injectQuery` using the same `queryKey` share one cached query and dedupe their fetches — you can mix and match freely. + +[//]: # 'Example' + +```ts +import { Component, signal } from '@angular/core' +import { queryResource } from '@tanstack/angular-query-experimental' + +@Component({ + template: ` + @if (todos.isLoading()) { +

Loading…

+ } @else if (todos.hasValue()) { +
    + @for (todo of todos.value(); track todo.id) { +
  • {{ todo.title }}
  • + } +
+ } @else if (todos.isError()) { +

Error: {{ todos.error()?.message }}

+ } + `, +}) +export class TodosComponent { + filter = signal('') + + todos = queryResource({ + queryKey: () => ['todos', this.filter()], + queryFn: ({ queryKey }) => fetchTodos(queryKey[1] as string), + enabled: () => this.filter().length > 0, + staleTime: 30_000, + }) +} +``` + +[//]: # 'Example' + +## Two ways to pass options + +A plain object literal evaluates its fields **eagerly, once**. So a config object can only be reactive in the fields you pass as functions. `queryResource` accepts both forms: + +### Config form — `queryResource(config)` + +Ergonomic for the common case. `queryKey` and `enabled` may be reactive thunks; every other field is read once. + +```ts +todos = queryResource({ + queryKey: () => ['todos', this.filter()], // reactive ✅ + queryFn: ({ queryKey }) => fetchTodos(queryKey[1] as string), + enabled: () => !!this.filter(), // reactive ✅ + staleTime: 30_000, // static — read once +}) +``` + +> A field passed as a plain value (e.g. `enabled: this.flag()`) is read once and is **not** reactive. Pass a function to make it reactive, or use the options-function form below. + +### Options-function form — `queryResource(() => config)` + +The whole object is re-evaluated in a reactive context, so **every** embedded signal read is tracked — identical semantics to `injectQuery(() => ({ ... }))`. Use it when you need fields other than `queryKey` / `enabled` to be reactive. + +```ts +todos = queryResource(() => ({ + queryKey: ['todos', this.filter()], + queryFn: () => fetchTodos(this.filter()), + enabled: !!this.filter(), + staleTime: this.ttl(), // reactive in this form +})) +``` + +## The returned handle + +`queryResource` returns an Angular `Resource` plus query extras. + +### Angular `Resource` surface + +| Member | Notes | +| --- | --- | +| `value()` | Resource-strict read. **Throws** in the error state — guard with `hasValue()`. | +| `status()` | Angular `ResourceStatus`: `idle \| loading \| reloading \| resolved \| local \| error`. | +| `error()` | `Signal` (resource contract). | +| `isLoading()` | `true` while loading or reloading. | +| `hasValue()` | Whether a value is currently available. | +| `snapshot()` | The full `ResourceSnapshot`. | + +### Query extras + +| Member | Notes | +| --- | --- | +| `data()` | Last known data — **safe to read in any state** (never throws). Prefer this over `value()`. | +| `queryStatus()` | TanStack status: `pending \| success \| error`. | +| `fetchStatus()` | `idle \| fetching \| paused`. | +| `isPending()` / `isSuccess()` / `isError()` / `isFetching()` / `isStale()` / `isPlaceholderData()` | convenience flags | +| `failureCount()` / `failureReason()` / `dataUpdatedAt()` / `errorUpdatedAt()` | retry + freshness metadata | +| `refetch()` | Manually refetch. | +| `reload()` | Alias for `refetch()` matching the resource API. | +| `set(value)` / `update(fn)` | Optimistically write the cache (through `setQueryData`). | + +> `value()` follows Angular's resource contract and throws when the query is in an error state with no value. For a read that never throws, use `data()`. + +## Infinite queries + +`infiniteQueryResource` is the resource-shaped counterpart of [`injectInfiniteQuery`](../infinite-queries). It adds the infinite-specific fields on top of the base resource surface. + +```ts +feed = infiniteQueryResource({ + queryKey: () => ['feed'], + queryFn: ({ pageParam }) => fetchFeedPage(pageParam), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.page + 1 : undefined, +}) + +// feed.value()?.pages, feed.hasNextPage(), feed.fetchNextPage(), +// feed.isFetchingNextPage(), feed.hasPreviousPage(), feed.fetchPreviousPage() +``` + +## Mutations + +`mutationResource` is the resource-shaped counterpart of [`injectMutation`](../mutations). The resource `value` is the result of the most recent mutation; trigger it with `mutate` / `mutateAsync`. + +```ts +import { inject } from '@angular/core' +import { + QueryClient, + mutationResource, +} from '@tanstack/angular-query-experimental' + +class TodosComponent { + queryClient = inject(QueryClient) + + addTodo = mutationResource({ + mutationFn: (title: string) => api.addTodo(title), + onSuccess: () => + this.queryClient.invalidateQueries({ queryKey: ['todos'] }), + }) + + add(title: string) { + this.addTodo.mutate(title) + // this.addTodo.isPending(), this.addTodo.value(), this.addTodo.error() + } +} +``` + +## When should I use the resource APIs? + +Reach for `queryResource` when you want the result to **be** an Angular resource — to compose with resource-consuming APIs, to use `value()/status()/hasValue()` and `@if (q.hasValue())` ergonomics, or simply to keep a consistent resource mental model across your app. Everything else (caching, deduping, retries, devtools, persistence, invalidation) is unchanged because it is the same `QueryClient` underneath. + +Reach for `injectQuery` when you are targeting an Angular version below 22, or when you prefer the existing flat signal-proxy result shape. + +## Notes & differences + +- **Shared cache.** `queryResource(['user', 1])` and `injectQuery(() => ({ queryKey: ['user', 1] }))` resolve to the same cached query. +- **`status` is the resource status.** The TanStack `pending | success | error` status is on `queryStatus()`. +- **`error` is `Error | undefined`** to satisfy the resource contract. The typed query error is available via `failureReason()`. +- **Optimistic writes** via `set` / `update` go through `setQueryData`, so they surface as `resolved` (not the resource `local` status). diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index a566a21079e..3c390ab54a2 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -88,10 +88,10 @@ "@tanstack/query-core": "workspace:*" }, "devDependencies": { - "@angular/common": "^20.0.0", - "@angular/compiler": "^20.0.0", - "@angular/core": "^20.0.0", - "@angular/platform-browser": "^20.0.0", + "@angular/common": "^22.0.0", + "@angular/compiler": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/platform-browser": "^22.0.0", "@tanstack/query-test-utils": "workspace:*", "@testing-library/angular": "^18.0.0", "npm-run-all2": "^5.0.0", @@ -104,8 +104,8 @@ "@tanstack/query-devtools": "workspace:*" }, "peerDependencies": { - "@angular/common": ">=16.0.0", - "@angular/core": ">=16.0.0" + "@angular/common": ">=22.0.0", + "@angular/core": ">=22.0.0" }, "publishConfig": { "directory": "dist", diff --git a/packages/angular-query-experimental/src/__tests__/query-resource.test.ts b/packages/angular-query-experimental/src/__tests__/query-resource.test.ts new file mode 100644 index 00000000000..ea9ae817b83 --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/query-resource.test.ts @@ -0,0 +1,214 @@ +import { Component, provideZonelessChangeDetection } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/angular' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + injectQuery, + mutationResource, + provideTanStackQuery, + queryResource, +} from '..' + +describe('queryResource', () => { + let queryCache: QueryCache + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryCache = new QueryCache() + queryClient = new QueryClient({ queryCache }) + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ], + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('config form: resolves and exposes the resource + query surface', async () => { + const key = queryKey() + + @Component({ + template: ` +
status: {{ q.status() }}
+
queryStatus: {{ q.queryStatus() }}
+
data: {{ q.data() ?? 'none' }}
+ @if (q.hasValue()) { +
value: {{ q.value() }}
+ } +
isLoading: {{ q.isLoading() }}
+
isSuccess: {{ q.isSuccess() }}
+ `, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: () => sleep(10).then(() => 'result'), + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('status: resolved')).toBeInTheDocument() + expect(rendered.getByText('queryStatus: success')).toBeInTheDocument() + expect(rendered.getByText('data: result')).toBeInTheDocument() + expect(rendered.getByText('value: result')).toBeInTheDocument() + expect(rendered.getByText('isLoading: false')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: true')).toBeInTheDocument() + }) + + it('options-function form resolves', async () => { + const key = queryKey() + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'fn-form'), + })) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('data: fn-form')).toBeInTheDocument() + }) + + it('error state: data() is safe, hasValue() is false, value() throws', async () => { + const key = queryKey() + + @Component({ + template: ` +
queryStatus: {{ q.queryStatus() }}
+
data: {{ q.data() ?? 'none' }}
+
hasValue: {{ q.hasValue() }}
+
error: {{ q.error()?.message ?? 'none' }}
+ `, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('boom'))), + retry: false, + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('queryStatus: error')).toBeInTheDocument() + expect(rendered.getByText('data: none')).toBeInTheDocument() + expect(rendered.getByText('hasValue: false')).toBeInTheDocument() + expect(rendered.getByText('error: boom')).toBeInTheDocument() + expect(() => rendered.fixture.componentInstance.q.value()).toThrow() + }) + + it('shares the cache with injectQuery (dedupes a single fetch)', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'shared')) + + @Component({ + template: ` +
r: {{ r.data() ?? 'none' }}
+
i: {{ i.data() ?? 'none' }}
+ `, + }) + class Page { + readonly r = queryResource({ queryKey: () => key, queryFn }) + readonly i = injectQuery(() => ({ queryKey: key, queryFn })) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('r: shared')).toBeInTheDocument() + expect(rendered.getByText('i: shared')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('set() writes through to the cache', async () => { + const key = queryKey() + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: () => sleep(10).then(() => 'a'), + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('data: a')).toBeInTheDocument() + + rendered.fixture.componentInstance.q.set('b') + rendered.fixture.detectChanges() + + expect(rendered.getByText('data: b')).toBeInTheDocument() + expect(queryClient.getQueryData(key)).toBe('b') + }) +}) + +describe('mutationResource', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient() + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ], + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs imperatively and exposes the result as a resource', async () => { + @Component({ + template: ` +
mutationStatus: {{ m.mutationStatus() }}
+
status: {{ m.status() }}
+
data: {{ m.data() ?? 'none' }}
+ `, + }) + class Page { + readonly m = mutationResource({ + mutationFn: (title: string) => sleep(10).then(() => `saved:${title}`), + }) + } + + const rendered = await render(Page) + expect(rendered.getByText('mutationStatus: idle')).toBeInTheDocument() + expect(rendered.getByText('status: idle')).toBeInTheDocument() + + rendered.fixture.componentInstance.m.mutate('todo') + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('mutationStatus: success')).toBeInTheDocument() + expect(rendered.getByText('status: resolved')).toBeInTheDocument() + expect(rendered.getByText('data: saved:todo')).toBeInTheDocument() + }) +}) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4daede76844..64435bf190d 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -16,6 +16,7 @@ import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' import { PENDING_TASKS } from './pending-tasks-compat' import type { PendingTaskRef } from './pending-tasks-compat' +import type { Signal } from '@angular/core' import type { QueryKey, QueryObserver, @@ -25,10 +26,15 @@ import type { CreateBaseQueryOptions } from './types' /** * Base implementation for `injectQuery` and `injectInfiniteQuery`. + * + * Returns a `Signal` of the latest `QueryObserverResult`. This is the shared engine + * used both by the signal-proxy based APIs (`injectQuery` / `injectInfiniteQuery`) + * and by the resource based APIs (`queryResource` / `infiniteQueryResource`), so all + * of them are driven by the exact same `QueryObserver`, `QueryClient` and cache. * @param optionsFn * @param Observer */ -export function createBaseQuery< +export function createBaseQueryResult< TQueryFnData, TError, TData, @@ -43,7 +49,7 @@ export function createBaseQuery< TQueryKey >, Observer: typeof QueryObserver, -) { +): Signal> { const ngZone = inject(NgZone) const pendingTasks = inject(PENDING_TASKS) const queryClient = inject(QueryClient) @@ -153,23 +159,48 @@ export function createBaseQuery< }) }) - return signalProxy( - computed(() => { - const subscriberResult = resultFromSubscriberSignal() - const optimisticResult = optimisticResultSignal() - const result = subscriberResult ?? optimisticResult + return computed(() => { + const subscriberResult = resultFromSubscriberSignal() + const optimisticResult = optimisticResultSignal() + const result = subscriberResult ?? optimisticResult - // Wrap methods to ensure observer has latest options before execution - const observer = observerSignal() + // Wrap methods to ensure observer has latest options before execution + const observer = observerSignal() - const originalRefetch = result.refetch - return { - ...result, - refetch: ((...args: Parameters) => { - observer.setOptions(defaultedOptionsSignal()) - return originalRefetch(...args) - }) as typeof originalRefetch, - } - }), - ) + const originalRefetch = result.refetch + return { + ...result, + refetch: ((...args: Parameters) => { + observer.setOptions(defaultedOptionsSignal()) + return originalRefetch(...args) + }) as typeof originalRefetch, + } + }) +} + +/** + * Base implementation for `injectQuery` and `injectInfiniteQuery`. + * + * Wraps {@link createBaseQueryResult} in a {@link signalProxy} so each field of the + * result is exposed as its own `Signal`. + * @param optionsFn + * @param Observer + */ +export function createBaseQuery< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + optionsFn: () => CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + Observer: typeof QueryObserver, +) { + return signalProxy(createBaseQueryResult(optionsFn, Observer)) } diff --git a/packages/angular-query-experimental/src/index.ts b/packages/angular-query-experimental/src/index.ts index fc033224e51..5fc7c447c8f 100644 --- a/packages/angular-query-experimental/src/index.ts +++ b/packages/angular-query-experimental/src/index.ts @@ -44,6 +44,27 @@ export type { QueriesOptions, QueriesResults } from './inject-queries' export type { InjectQueryOptions } from './inject-query' export { injectQuery } from './inject-query' +// Resource-shaped APIs (Angular >= 22). These are backed by the same QueryClient, +// observers and cache as their `inject*` counterparts — they only change how the +// result is presented (as an Angular `Resource`). +export { queryResource } from './query-resource' +export { infiniteQueryResource } from './infinite-query-resource' +export { mutationResource } from './mutation-resource' +export { toResourceSnapshot } from './resource/to-resource-snapshot' +export type { + BaseQueryResource, + CreateQueryResourceResult, + CreateInfiniteQueryResourceResult, + MutationResource, + QueryResourceConfig, + QueryResourceOptionsFn, + QueryResourceInjectorOptions, + InfiniteQueryResourceConfig, + InfiniteQueryResourceOptionsFn, + MutationResourceConfig, + MutationResourceOptionsFn, +} from './resource/resource-types' + export { injectQueryClient } from './inject-query-client' export type { diff --git a/packages/angular-query-experimental/src/infinite-query-resource.ts b/packages/angular-query-experimental/src/infinite-query-resource.ts new file mode 100644 index 00000000000..566457d3047 --- /dev/null +++ b/packages/angular-query-experimental/src/infinite-query-resource.ts @@ -0,0 +1,134 @@ +import { + Injector, + assertInInjectionContext, + computed, + inject, + runInInjectionContext, + untracked, +} from '@angular/core' +import { InfiniteQueryObserver } from '@tanstack/query-core' +import { + createBaseQueryResource, + normalizeQueryResourceArg, +} from './resource/create-base-query-resource' +import type { Signal } from '@angular/core' +import type { + DefaultError, + InfiniteData, + InfiniteQueryObserverResult, + QueryKey, + QueryObserver, +} from '@tanstack/query-core' +import type { CreateBaseQueryOptions } from './types' +import type { + CreateInfiniteQueryResourceResult, + InfiniteQueryResourceConfig, + InfiniteQueryResourceOptionsFn, + QueryResourceInjectorOptions, +} from './resource/resource-types' + +/** + * Creates an infinite query whose handle is an Angular `Resource`. + * + * The resource-shaped counterpart of `injectInfiniteQuery`. Backed by the same + * `InfiniteQueryObserver` and cache, so the loaded pages dedupe and persist exactly + * like the signal-proxy based API. Adds the infinite-specific fields + * (`hasNextPage`/`fetchNextPage`/…) on top of the base resource surface. + * + * **Config form.** `queryKey` and `enabled` may be reactive thunks; everything else + * is read once. + * + * ```ts + * feed = infiniteQueryResource({ + * queryKey: () => ['feed'], + * queryFn: ({ pageParam }) => api.getFeed(pageParam), + * initialPageParam: 1, + * getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined), + * }) + * // feed.value()?.pages, feed.hasNextPage(), feed.fetchNextPage() + * ``` + * @param config - The infinite query options as a config object with reactive thunks. + * @param options - Additional configuration such as the `Injector` to use. + * @returns A resource-shaped infinite query handle. + */ +export function infiniteQueryResource< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + config: InfiniteQueryResourceConfig< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + options?: QueryResourceInjectorOptions, +): CreateInfiniteQueryResourceResult + +/** + * Creates an infinite query whose handle is an Angular `Resource`. + * + * **Options-function form.** Whole-object reactive, identical semantics to + * `injectInfiniteQuery(() => ({ ... }))`. + * @param optionsFn - A function that returns the infinite query options. + * @param options - Additional configuration such as the `Injector` to use. + * @returns A resource-shaped infinite query handle. + */ +export function infiniteQueryResource< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + optionsFn: InfiniteQueryResourceOptionsFn< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + options?: QueryResourceInjectorOptions, +): CreateInfiniteQueryResourceResult + +export function infiniteQueryResource( + arg: InfiniteQueryResourceConfig | InfiniteQueryResourceOptionsFn, + options?: QueryResourceInjectorOptions, +): CreateInfiniteQueryResourceResult { + !options?.injector && assertInInjectionContext(infiniteQueryResource) + const injector = options?.injector ?? inject(Injector) + return runInInjectionContext(injector, () => { + const optionsFn = + normalizeQueryResourceArg(arg) as () => CreateBaseQueryOptions + const { result, base } = createBaseQueryResource( + optionsFn, + InfiniteQueryObserver as typeof QueryObserver, + ) + const infiniteResult = result as unknown as Signal< + InfiniteQueryObserverResult + > + + return { + ...base, + hasNextPage: computed(() => infiniteResult().hasNextPage), + hasPreviousPage: computed(() => infiniteResult().hasPreviousPage), + isFetchingNextPage: computed(() => infiniteResult().isFetchingNextPage), + isFetchingPreviousPage: computed( + () => infiniteResult().isFetchingPreviousPage, + ), + isFetchNextPageError: computed( + () => infiniteResult().isFetchNextPageError, + ), + isFetchPreviousPageError: computed( + () => infiniteResult().isFetchPreviousPageError, + ), + fetchNextPage: (pageOptions) => + untracked(() => infiniteResult().fetchNextPage(pageOptions)), + fetchPreviousPage: (pageOptions) => + untracked(() => infiniteResult().fetchPreviousPage(pageOptions)), + } as CreateInfiniteQueryResourceResult + }) +} diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 7eb605047f3..1e14c890974 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -18,8 +18,10 @@ import { import { signalProxy } from './signal-proxy' import { PENDING_TASKS } from './pending-tasks-compat' import type { PendingTaskRef } from './pending-tasks-compat' +import type { Signal } from '@angular/core' import type { DefaultError, MutationObserverResult } from '@tanstack/query-core' import type { + CreateBaseMutationResult, CreateMutateFunction, CreateMutationOptions, CreateMutationResult, @@ -35,14 +37,16 @@ export interface InjectMutationOptions { } /** - * Injects a mutation: an imperative function that can be invoked which typically performs server side effects. + * Shared engine for `injectMutation` and `mutationResource`. * - * Unlike queries, mutations are not run automatically. + * Returns a `Signal` of the latest mutation result (with `mutate` / `mutateAsync` + * attached), driven by the same `MutationObserver` and `MutationCache`. Used both by + * the signal-proxy based `injectMutation` and the resource based `mutationResource`. * @param injectMutationFn - A function that returns mutation options. - * @param options - Additional configuration - * @returns The mutation. + * @param injector - The injection context to run in. + * @returns A signal of the mutation result. */ -export function injectMutation< +export function createMutationResult< TData = unknown, TError = DefaultError, TVariables = void, @@ -54,10 +58,8 @@ export function injectMutation< TVariables, TOnMutateResult >, - options?: InjectMutationOptions, -): CreateMutationResult { - !options?.injector && assertInInjectionContext(injectMutation) - const injector = options?.injector ?? inject(Injector) + injector: Injector, +): Signal> { const ngZone = injector.get(NgZone) const pendingTasks = injector.get(PENDING_TASKS) const queryClient = injector.get(QueryClient) @@ -186,10 +188,39 @@ export function injectMutation< } }) - return signalProxy(resultSignal) as CreateMutationResult< + return resultSignal as Signal< + CreateBaseMutationResult + > +} + +/** + * Injects a mutation: an imperative function that can be invoked which typically performs server side effects. + * + * Unlike queries, mutations are not run automatically. + * @param injectMutationFn - A function that returns mutation options. + * @param options - Additional configuration + * @returns The mutation. + */ +export function injectMutation< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + injectMutationFn: () => CreateMutationOptions< TData, TError, TVariables, TOnMutateResult - > + >, + options?: InjectMutationOptions, +): CreateMutationResult { + !options?.injector && assertInInjectionContext(injectMutation) + const injector = options?.injector ?? inject(Injector) + return signalProxy( + createMutationResult( + injectMutationFn, + injector, + ), + ) as CreateMutationResult } diff --git a/packages/angular-query-experimental/src/mutation-resource.ts b/packages/angular-query-experimental/src/mutation-resource.ts new file mode 100644 index 00000000000..6d5feef5342 --- /dev/null +++ b/packages/angular-query-experimental/src/mutation-resource.ts @@ -0,0 +1,145 @@ +import { + Injector, + assertInInjectionContext, + computed, + inject, + resourceFromSnapshots, + runInInjectionContext, + untracked, +} from '@angular/core' +import { createMutationResult } from './inject-mutation' +import type { ResourceSnapshot } from '@angular/core' +import type { DefaultError } from '@tanstack/query-core' +import type { CreateBaseMutationResult, CreateMutationOptions } from './types' +import type { + MutationResource, + MutationResourceConfig, + MutationResourceOptionsFn, + QueryResourceInjectorOptions, +} from './resource/resource-types' + +/** + * Projects a mutation result onto Angular's `ResourceSnapshot` shape. + * + * | Mutation status | Resource status | + * | --------------- | --------------- | + * | `idle` | `idle` | + * | `pending` | `loading` | + * | `success` | `resolved` | + * | `error` | `error` | + */ +function toMutationSnapshot( + result: CreateBaseMutationResult, +): ResourceSnapshot { + switch (result.status) { + case 'error': + return { status: 'error', error: result.error as Error } + case 'pending': + return { status: 'loading', value: result.data } + case 'success': + return { status: 'resolved', value: result.data } + default: + return { status: 'idle', value: undefined } + } +} + +/** + * Creates a mutation whose handle is an Angular `Resource`. + * + * The resource-shaped counterpart of `injectMutation`. Backed by the same + * `MutationObserver` / `MutationCache`. The resource `value` is the result of the most + * recent mutation; trigger it imperatively with `mutate` / `mutateAsync`. + * + * **Config form (this overload).** + * + * ```ts + * addTodo = mutationResource({ + * mutationFn: (title: string) => api.addTodo(title), + * onSuccess: () => this.queryClient.invalidateQueries({ queryKey: ['todos'] }), + * }) + * // addTodo.mutate('Write docs'); addTodo.isPending(); addTodo.value() + * ``` + * @param config - The mutation options as a config object. + * @param options - Additional configuration such as the `Injector` to use. + * @returns A resource-shaped mutation handle. + */ +export function mutationResource< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + config: MutationResourceConfig, + options?: QueryResourceInjectorOptions, +): MutationResource + +/** + * Creates a mutation whose handle is an Angular `Resource`. + * + * **Options-function form (this overload).** Whole-object reactive, identical + * semantics to `injectMutation(() => ({ ... }))`. + * @param optionsFn - A function that returns the mutation options. + * @param options - Additional configuration such as the `Injector` to use. + * @returns A resource-shaped mutation handle. + */ +export function mutationResource< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + optionsFn: MutationResourceOptionsFn< + TData, + TError, + TVariables, + TOnMutateResult + >, + options?: QueryResourceInjectorOptions, +): MutationResource + +export function mutationResource( + arg: MutationResourceConfig | MutationResourceOptionsFn, + options?: QueryResourceInjectorOptions, +): MutationResource { + !options?.injector && assertInInjectionContext(mutationResource) + const injector = options?.injector ?? inject(Injector) + return runInInjectionContext(injector, () => { + const optionsFn = ( + typeof arg === 'function' ? arg : () => arg + ) as () => CreateMutationOptions + const result = createMutationResult(optionsFn, injector) + const resourceRef = resourceFromSnapshots(() => + toMutationSnapshot(result()), + ) + + return { + // Angular Resource surface. + value: resourceRef.value, + status: resourceRef.status, + error: resourceRef.error, + isLoading: resourceRef.isLoading, + snapshot: resourceRef.snapshot, + hasValue: (() => + resourceRef.hasValue()) as MutationResource['hasValue'], + + // Mutation result fields. + data: computed(() => result().data), + mutationStatus: computed(() => result().status), + variables: computed(() => result().variables), + submittedAt: computed(() => result().submittedAt), + isIdle: computed(() => result().isIdle), + isPending: computed(() => result().isPending), + isSuccess: computed(() => result().isSuccess), + isError: computed(() => result().isError), + failureCount: computed(() => result().failureCount), + failureReason: computed(() => result().failureReason), + + // Actions. + mutate: (variables, mutateOptions) => + untracked(() => result().mutate(variables, mutateOptions)), + mutateAsync: (variables, mutateOptions) => + untracked(() => result().mutateAsync(variables, mutateOptions)), + reset: () => untracked(() => result().reset()), + } as MutationResource + }) +} diff --git a/packages/angular-query-experimental/src/query-resource.ts b/packages/angular-query-experimental/src/query-resource.ts new file mode 100644 index 00000000000..eb8bff8f1aa --- /dev/null +++ b/packages/angular-query-experimental/src/query-resource.ts @@ -0,0 +1,109 @@ +import { + Injector, + assertInInjectionContext, + inject, + runInInjectionContext, +} from '@angular/core' +import { QueryObserver } from '@tanstack/query-core' +import { + createBaseQueryResource, + normalizeQueryResourceArg, +} from './resource/create-base-query-resource' +import type { DefaultError, QueryKey } from '@tanstack/query-core' +import type { CreateBaseQueryOptions } from './types' +import type { + CreateQueryResourceResult, + QueryResourceConfig, + QueryResourceInjectorOptions, + QueryResourceOptionsFn, +} from './resource/resource-types' + +/** + * Creates a query whose handle is an Angular `Resource`. + * + * This is the resource-shaped counterpart of `injectQuery`. It is backed by the same + * `QueryObserver`, `QueryClient` and cache, so it dedupes and shares data with every + * other query (including `injectQuery`) using the same key — it only changes how the + * result is presented: as a real Angular resource (`value`/`status`/`error`/ + * `isLoading`/`hasValue`/`snapshot`) plus the TanStack result signals. + * + * **Config form (this overload).** A plain object whose reactive fields are passed as + * functions. `queryKey` and `enabled` may be thunks; everything else is read once. + * + * ```ts + * class TodosComponent { + * filter = signal('') + * + * todos = queryResource({ + * queryKey: () => ['todos', this.filter()], + * queryFn: ({ queryKey }) => fetchTodos(queryKey[1]), + * enabled: () => !!this.filter(), + * staleTime: 30_000, + * }) + * + * // todos.value() (resource-strict), todos.data() (safe), todos.isLoading(), ... + * // @if (todos.hasValue()) { ... } + * } + * ``` + * @param config - The query options as a config object with reactive thunk fields. + * @param options - Additional configuration such as the `Injector` to use. + * @returns A resource-shaped query handle. + * @see https://tanstack.com/query/latest/docs/framework/angular/guides/queries + */ +export function queryResource< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + config: QueryResourceConfig, + options?: QueryResourceInjectorOptions, +): CreateQueryResourceResult + +/** + * Creates a query whose handle is an Angular `Resource`. + * + * **Options-function form (this overload).** The whole object is re-evaluated in a + * reactive context, so every embedded signal read is tracked — identical semantics to + * `injectQuery(() => ({ ... }))`. Use this when you need fields other than `queryKey` + * / `enabled` to be reactive. + * + * ```ts + * class TodosComponent { + * filter = signal('') + * + * todos = queryResource(() => ({ + * queryKey: ['todos', this.filter()], + * queryFn: () => fetchTodos(this.filter()), + * enabled: !!this.filter(), + * })) + * } + * ``` + * @param optionsFn - A function that returns the query options. + * @param options - Additional configuration such as the `Injector` to use. + * @returns A resource-shaped query handle. + * @see https://tanstack.com/query/latest/docs/framework/angular/guides/queries + */ +export function queryResource< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + optionsFn: QueryResourceOptionsFn, + options?: QueryResourceInjectorOptions, +): CreateQueryResourceResult + +export function queryResource( + arg: QueryResourceConfig | QueryResourceOptionsFn, + options?: QueryResourceInjectorOptions, +): CreateQueryResourceResult { + !options?.injector && assertInInjectionContext(queryResource) + const injector = options?.injector ?? inject(Injector) + return runInInjectionContext(injector, () => { + const optionsFn = + normalizeQueryResourceArg(arg) as () => CreateBaseQueryOptions + return createBaseQueryResource(optionsFn, QueryObserver) + .base as CreateQueryResourceResult + }) +} diff --git a/packages/angular-query-experimental/src/resource/create-base-query-resource.ts b/packages/angular-query-experimental/src/resource/create-base-query-resource.ts new file mode 100644 index 00000000000..d8861f96321 --- /dev/null +++ b/packages/angular-query-experimental/src/resource/create-base-query-resource.ts @@ -0,0 +1,133 @@ +import { computed, inject, resourceFromSnapshots, untracked } from '@angular/core' +import { QueryClient } from '@tanstack/query-core' +import { createBaseQueryResult } from '../create-base-query' +import { toResourceSnapshot } from './to-resource-snapshot' +import type { Signal } from '@angular/core' +import type { + QueryKey, + QueryObserver, + QueryObserverResult, +} from '@tanstack/query-core' +import type { CreateBaseQueryOptions } from '../types' +import type { BaseQueryResource } from './resource-types' + +/** + * Normalize the two accepted argument shapes into the single options-function form + * that {@link createBaseQueryResult} (and `QueryObserver`) expects: + * + * - `() => options` — the power form. Whole-object reactive, returned as-is. + * - `options` — the config form. The `queryKey` and `enabled` fields may be reactive + * thunks; they are resolved inside the returned function so reads register as + * reactive dependencies. Every other field is read once. + */ +export function normalizeQueryResourceArg( + arg: Record | (() => Record), +): () => Record { + if (typeof arg === 'function') { + return arg + } + return () => { + const options = { ...arg } + if (typeof options['queryKey'] === 'function') { + options['queryKey'] = options['queryKey']() + } + // In the config form `enabled` is typed as `boolean | (() => boolean)`; the arity + // check keeps a core-style `(query) => boolean` predicate working as a passthrough. + if ( + typeof options['enabled'] === 'function' && + options['enabled'].length === 0 + ) { + options['enabled'] = options['enabled']() + } + return options + } +} + +/** + * Shared engine for `queryResource` and `infiniteQueryResource`. + * + * Reuses the exact `QueryObserver`/`QueryClient` machinery behind `injectQuery` (via + * {@link createBaseQueryResult}) and projects its result onto a real Angular + * `Resource` through `resourceFromSnapshots`. The query cache stays the single + * source of truth — `queryResource` and `injectQuery` dedupe against each other. + * @param optionsFn - The normalized options function. + * @param Observer - `QueryObserver` or `InfiniteQueryObserver`. + * @returns The assembled base resource handle plus the underlying result signal (used + * by `infiniteQueryResource` to add its extra fields). + */ +export function createBaseQueryResource< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + optionsFn: () => CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + Observer: typeof QueryObserver, +): { + result: Signal> + base: BaseQueryResource +} { + const queryClient = inject(QueryClient) + + const result = createBaseQueryResult< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >(optionsFn, Observer) + + const resourceRef = resourceFromSnapshots(() => + toResourceSnapshot(result()), + ) + + const resolveKey = () => untracked(() => optionsFn().queryKey) as TQueryKey + + const base: BaseQueryResource = { + // Angular Resource surface. + value: resourceRef.value, + status: resourceRef.status, + error: resourceRef.error, + isLoading: resourceRef.isLoading, + snapshot: resourceRef.snapshot, + hasValue: (() => + resourceRef.hasValue()) as BaseQueryResource['hasValue'], + + // TanStack query result fields, projected as signals. + data: computed(() => result().data), + queryStatus: computed(() => result().status), + fetchStatus: computed(() => result().fetchStatus), + isPending: computed(() => result().isPending), + isSuccess: computed(() => result().isSuccess), + isError: computed(() => result().isError), + isFetching: computed(() => result().isFetching), + isStale: computed(() => result().isStale), + isPlaceholderData: computed(() => result().isPlaceholderData), + failureCount: computed(() => result().failureCount), + failureReason: computed(() => result().failureReason), + dataUpdatedAt: computed(() => result().dataUpdatedAt), + errorUpdatedAt: computed(() => result().errorUpdatedAt), + + // Actions. + refetch: (options) => untracked(() => result().refetch(options)), + reload: () => void untracked(() => result().refetch()), + set: (value) => { + queryClient.setQueryData(resolveKey(), value) + }, + update: (updater) => { + queryClient.setQueryData( + resolveKey(), + (previous: TData | undefined) => updater(previous), + ) + }, + } + + return { result, base } +} diff --git a/packages/angular-query-experimental/src/resource/resource-types.ts b/packages/angular-query-experimental/src/resource/resource-types.ts new file mode 100644 index 00000000000..d37c0f8a55f --- /dev/null +++ b/packages/angular-query-experimental/src/resource/resource-types.ts @@ -0,0 +1,211 @@ +import type { Injector, Resource, Signal } from '@angular/core' +import type { + DefaultError, + FetchStatus, + InfiniteQueryObserverResult, + MutationStatus, + QueryKey, + QueryObserverResult, + QueryStatus, +} from '@tanstack/query-core' +import type { + CreateInfiniteQueryOptions, + CreateMutateAsyncFunction, + CreateMutateFunction, + CreateMutationOptions, + CreateQueryOptions, +} from '../types' + +/** + * The Angular `Injector` to use when a resource is created outside an injection + * context. + */ +export interface QueryResourceInjectorOptions { + injector?: Injector +} + +/** + * The handle returned by `queryResource`. + * + * It **is** an Angular read-only `Resource` (so it composes with + * anything that consumes a resource and exposes `value`/`status`/`error`/`isLoading`/ + * `hasValue`/`snapshot`), and additionally exposes the TanStack Query result fields as + * signals plus imperative cache writes. + * + * Naming note: `status` follows Angular's `ResourceStatus` + * (`idle | loading | reloading | resolved | local | error`). The TanStack + * `pending | success | error` status is available as {@link queryStatus}. `error` + * follows the resource contract (`Signal`); the typed query error + * is available via {@link failureReason}. + */ +export interface BaseQueryResource + extends Resource { + /** Last known data — safe to read in any state (never throws), unlike `value`. */ + readonly data: Signal + /** TanStack query status: `pending | success | error`. */ + readonly queryStatus: Signal + /** Fetch lifecycle independent of data: `idle | fetching | paused`. */ + readonly fetchStatus: Signal + readonly isPending: Signal + readonly isSuccess: Signal + readonly isError: Signal + readonly isFetching: Signal + readonly isStale: Signal + readonly isPlaceholderData: Signal + readonly failureCount: Signal + readonly failureReason: Signal + readonly dataUpdatedAt: Signal + readonly errorUpdatedAt: Signal + + /** Manually refetch the query. */ + refetch: QueryObserverResult['refetch'] + /** Alias for {@link refetch} to mirror the Angular resource API. */ + reload: () => void + /** Optimistically overwrite the cached value (writes through `setQueryData`). */ + set: (value: TData) => void + /** Optimistically update the cached value (writes through `setQueryData`). */ + update: (updater: (previous: TData | undefined) => TData) => void +} + +export type CreateQueryResourceResult< + TData = unknown, + TError = DefaultError, +> = BaseQueryResource + +/** The handle returned by `infiniteQueryResource`. */ +export interface CreateInfiniteQueryResourceResult< + TData = unknown, + TError = DefaultError, +> extends BaseQueryResource { + readonly hasNextPage: Signal + readonly hasPreviousPage: Signal + readonly isFetchingNextPage: Signal + readonly isFetchingPreviousPage: Signal + readonly isFetchNextPageError: Signal + readonly isFetchPreviousPageError: Signal + fetchNextPage: InfiniteQueryObserverResult['fetchNextPage'] + fetchPreviousPage: InfiniteQueryObserverResult['fetchPreviousPage'] +} + +/** + * The "config object" form of query options accepted by `queryResource`. + * + * Unlike the options-function form, a plain object literal evaluates its fields + * eagerly, so the reactive fields must be passed as functions: + * - `queryKey` may be a `QueryKey` or a `() => QueryKey` thunk (the common case: + * dependent / parameterised keys). + * - `enabled` may be a `boolean` or a `() => boolean` thunk. + * + * Anything else is read once. For full whole-object reactivity, pass the + * options-function form `queryResource(() => ({ ... }))` instead. + */ +export type QueryResourceConfig< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + CreateQueryOptions, + 'queryKey' | 'enabled' +> & { + queryKey: TQueryKey | (() => TQueryKey) + enabled?: boolean | (() => boolean) +} + +/** The options-function form: whole-object reactive, identical to `injectQuery`. */ +export type QueryResourceOptionsFn< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = () => CreateQueryOptions + +/** The "config object" form of options accepted by `infiniteQueryResource`. */ +export type InfiniteQueryResourceConfig< + TQueryFnData = unknown, + TError = DefaultError, + TData = unknown, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = Omit< + CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + 'queryKey' | 'enabled' +> & { + queryKey: TQueryKey | (() => TQueryKey) + enabled?: boolean | (() => boolean) +} + +/** The options-function form: whole-object reactive, identical to `injectInfiniteQuery`. */ +export type InfiniteQueryResourceOptionsFn< + TQueryFnData = unknown, + TError = DefaultError, + TData = unknown, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = () => CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> + +/** + * The handle returned by `mutationResource`. + * + * It **is** an Angular read-only `Resource` whose `value` is the + * result of the most recent mutation, and additionally exposes the mutation result + * fields as signals plus `mutate` / `mutateAsync` / `reset`. + * + * Naming note: `status` follows Angular's `ResourceStatus`; the TanStack mutation + * status (`idle | pending | success | error`) is available as {@link mutationStatus}. + */ +export interface MutationResource< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends Resource { + /** Result of the most recent mutation — safe to read in any state (never throws). */ + readonly data: Signal + /** TanStack mutation status: `idle | pending | success | error`. */ + readonly mutationStatus: Signal + /** The variables passed to the most recent `mutate` / `mutateAsync` call. */ + readonly variables: Signal + readonly submittedAt: Signal + readonly isIdle: Signal + readonly isPending: Signal + readonly isSuccess: Signal + readonly isError: Signal + readonly failureCount: Signal + readonly failureReason: Signal + + /** Fire-and-forget mutation; errors surface via the `error` / `isError` signals. */ + mutate: CreateMutateFunction + /** Awaitable mutation; rejects on error. */ + mutateAsync: CreateMutateAsyncFunction + /** Reset back to the idle state. */ + reset: () => void +} + +/** The "config object" form of options accepted by `mutationResource`. */ +export type MutationResourceConfig< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = CreateMutationOptions + +/** The options-function form: whole-object reactive, identical to `injectMutation`. */ +export type MutationResourceOptionsFn< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = () => CreateMutationOptions diff --git a/packages/angular-query-experimental/src/resource/to-resource-snapshot.ts b/packages/angular-query-experimental/src/resource/to-resource-snapshot.ts new file mode 100644 index 00000000000..ff9a23018f7 --- /dev/null +++ b/packages/angular-query-experimental/src/resource/to-resource-snapshot.ts @@ -0,0 +1,50 @@ +import type { ResourceSnapshot } from '@angular/core' +import type { QueryObserverResult } from '@tanstack/query-core' + +/** + * Projects a TanStack `QueryObserverResult` onto Angular's `ResourceSnapshot` shape + * so it can drive a real `Resource` through `resourceFromSnapshots`. + * + * The mapping is intentionally lossless for the states Angular's resource model can + * represent and deterministic for the rest: + * + * | TanStack result | Resource status | + * | ------------------------------------------------- | --------------- | + * | `status: 'error'` (no data) | `error` | + * | `status: 'pending'` + `fetchStatus: 'idle'` | `idle` | + * | `status: 'pending'` + fetching/paused | `loading` | + * | `isPlaceholderData` (placeholder / keepPrevious) | `loading` | + * | `status: 'success'` + `fetchStatus: 'fetching'` | `reloading` | + * | `status: 'success'` + idle | `resolved` | + * + * Notes: + * - The `loading`/`reloading` snapshot variants carry a `value`, so placeholder data + * and the previously cached data stay visible while a refetch is in flight — the + * same behaviour as `placeholderData` / `keepPreviousData` in the other APIs. + * - A background refetch that errors while data is still cached keeps surfacing the + * data as `reloading` rather than flipping to `error`, matching how the rest of the + * library preserves the last good data across refetch errors. + */ +export function toResourceSnapshot( + result: QueryObserverResult, +): ResourceSnapshot { + if (result.status === 'error' && result.data === undefined) { + return { status: 'error', error: result.error as Error } + } + + if (result.status === 'pending') { + return result.fetchStatus === 'idle' + ? { status: 'idle', value: undefined } + : { status: 'loading', value: undefined } + } + + // `success` (or `error` with previously cached data still shown). + if (result.isPlaceholderData) { + return { status: 'loading', value: result.data } + } + + return { + status: result.fetchStatus === 'fetching' ? 'reloading' : 'resolved', + value: result.data, + } +} From 648b9fad9a8bc52fa8c28ad070e7bf4e6e93e75c Mon Sep 17 00:00:00 2001 From: Enea Jahollari Date: Fri, 19 Jun 2026 12:07:34 +0200 Subject: [PATCH 2/2] test(angular-query): port resource API unit tests from angular-resource-query Ports the resource-surface specs from the angular-resource-query repo onto the new queryResource / infiniteQueryResource / mutationResource APIs, using the adapter's existing fake-timer + render test harness: - query-resource.test.ts: basics, reactive key + dedup with injectQuery, select, placeholderData, set/update/reload, refetch-error data preservation, failureCount, structural sharing, cancellation, gcTime, networkMode, refetchInterval. - infinite-query-resource.test.ts: first page, fetchNextPage, maxPages, bidirectional. - mutation-resource.test.ts: lifecycle, retry, no-retry default, offline pause, optimistic update + rollback, reset. Assertions reflect TanStack core semantics (notably: a background refetch error sets status to 'error' while preserving cached data). RESOURCE_TESTS_PORTING.md maps every source spec to its location here, including the engine specs already covered by query-core's own suite. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/RESOURCE_TESTS_PORTING.md | 65 ++ .../__tests__/infinite-query-resource.test.ts | 169 +++++ .../src/__tests__/mutation-resource.test.ts | 208 ++++++ .../src/__tests__/query-resource.test.ts | 603 +++++++++++++----- 4 files changed, 901 insertions(+), 144 deletions(-) create mode 100644 packages/angular-query-experimental/src/__tests__/RESOURCE_TESTS_PORTING.md create mode 100644 packages/angular-query-experimental/src/__tests__/infinite-query-resource.test.ts create mode 100644 packages/angular-query-experimental/src/__tests__/mutation-resource.test.ts diff --git a/packages/angular-query-experimental/src/__tests__/RESOURCE_TESTS_PORTING.md b/packages/angular-query-experimental/src/__tests__/RESOURCE_TESTS_PORTING.md new file mode 100644 index 00000000000..83a8a00f51b --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/RESOURCE_TESTS_PORTING.md @@ -0,0 +1,65 @@ +# Resource API test porting — coverage map + +This maps every unit spec in the `angular-resource-query` repo to where its coverage +lives in this repo. The resource APIs (`queryResource` / `infiniteQueryResource` / +`mutationResource`) are a thin projection over TanStack's existing observers and cache, +so specs that exercise the **engine** (caching, retries, hashing, persistence, …) are +already covered far more thoroughly by `@tanstack/query-core`'s own suite and are +**not** re-ported. Specs that exercise the **resource surface** are ported. + +## Ported as resource tests + +| `angular-resource-query` spec | Ported to | Notes | +| --- | --- | --- | +| `query-resource.spec.ts` | `query-resource.test.ts` › *basics* / *reactive key* | dedup with `injectQuery`, reactive key switching | +| `select-placeholder.spec.ts` | `query-resource.test.ts` › *select & placeholderData* | `select` and `placeholderData` flow through core | +| `ref-actions.spec.ts` | `query-resource.test.ts` › *actions* | `set`/`update`/`reload`/`refetch`, refetch-error, `failureCount` | +| `cancellation.spec.ts` | `query-resource.test.ts` › *cancellation & gc* | `queryClient.cancelQueries` aborts the signal | +| `gc.spec.ts` | `query-resource.test.ts` › *cancellation & gc* | `gcTime` disposes the cache entry on unmount | +| `structural-sharing-query.spec.ts` | `query-resource.test.ts` › *structural sharing* | referential stability across refetches | +| `network-mode.spec.ts` | `query-resource.test.ts` › *networkMode* | offline pause / reconnect resume | +| `refetch-interval.spec.ts` | `query-resource.test.ts` › *refetchInterval* | interval polling while mounted | +| `infinite-query.spec.ts` | `infinite-query-resource.test.ts` | first page, `fetchNextPage`, `maxPages` | +| `infinite-bidirectional.spec.ts` | `infinite-query-resource.test.ts` | `fetchPreviousPage`, mixed paging | +| `mutation-resilience.spec.ts` | `mutation-resource.test.ts` | retry, no-retry-by-default, offline pause | + +## Behavioral difference to be aware of + +`ref-actions.spec.ts` asserts that a **background refetch error preserves +`queryStatus: 'success'`**. TanStack core instead sets `status: 'error'` on any fetch +failure even when cached data is preserved (`query-core/src/query.ts`). The ported test +(`query-resource.test.ts` › *preserves cached data when a refetch errors*) therefore +asserts the TanStack semantics: `data()` stays, the resource stays `resolved` (so +`value()` does not throw and `hasValue()` is `true`), while `queryStatus()` is `'error'`, +`isError()` is `true`, and the error is on `failureReason()`. Because an Angular +`ResourceSnapshot` cannot carry both a value and an error, the resource `error()` signal +only reflects a *hard* failure (no cached data); a background error with cached data is +surfaced via `failureReason()` / `isError()` / `queryStatus()`. + +## Covered by existing suites (not re-ported) + +| `angular-resource-query` spec | Already covered by | +| --- | --- | +| `store-imperative.spec.ts`, `store-extras.spec.ts`, `query-store.spec.ts` | `QueryClient` API — `query-core/src/__tests__/queryClient.test.tsx` | +| `cache-callbacks.spec.ts` | `QueryCache` callbacks — `query-core/src/__tests__/queryCache.test.tsx` | +| `mutation-cache.spec.ts` | `query-core/src/__tests__/mutationCache.test.tsx` | +| `focus-reconnect.spec.ts` | `focusManager` / `onlineManager` + `QueryObserver` (shared by the resource layer) — core + `inject-query` tests | +| `hydration.spec.ts` | `query-core/src/__tests__/hydration.test.tsx` | +| `persistence.spec.ts` | `@tanstack/query-persist-client-core` + sync/async storage persister packages | +| `broadcast.spec.ts` | `@tanstack/query-broadcast-client-experimental` | +| `query-key.spec.ts` | `hashKey` / `partialMatchKey` — `query-core/src/__tests__/utils.test.tsx` | +| `query-devtools.spec.ts` | `@tanstack/query-devtools` + adapter `with-devtools` tests | +| `composition.spec.ts` (`selectQuery` / `combineQueries`) | `select` option + `computed()`; multi-query is `injectQueries` | +| `internal/retry.spec.ts` | `query-core/src/__tests__/retryer.test.tsx` | +| `internal/structural-sharing.spec.ts` | `replaceEqualDeep` — `query-core/src/__tests__/utils.test.tsx` | + +## Running + +These tests require **Angular ≥ 22** (the resource APIs use `resourceFromSnapshots`). +From the repo root: + +```bash +pnpm install +pnpm --filter @tanstack/angular-query-experimental test:lib +pnpm --filter @tanstack/angular-query-experimental test:types +``` diff --git a/packages/angular-query-experimental/src/__tests__/infinite-query-resource.test.ts b/packages/angular-query-experimental/src/__tests__/infinite-query-resource.test.ts new file mode 100644 index 00000000000..e41b5d1260a --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/infinite-query-resource.test.ts @@ -0,0 +1,169 @@ +import { Component, provideZonelessChangeDetection } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/angular' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + infiniteQueryResource, + provideTanStackQuery, +} from '..' + +// Ported from angular-resource-query: infinite-query.spec.ts, infinite-bidirectional.spec.ts. +// Unlike the source library, paging here is driven by TanStack's InfiniteQueryObserver +// (fetchNextPage / fetchPreviousPage come straight off the result), and the data is the +// `InfiniteData` shape (`{ pages, pageParams }`). +interface Page { + page: number + hasMore: boolean +} + +const makePage = (page: number): Page => ({ page, hasMore: page < 3 }) + +describe('infiniteQueryResource', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient({ queryCache: new QueryCache() }) + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ], + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('loads the first page and exposes hasNextPage', async () => { + const key = queryKey() + + @Component({ + template: `
pages: {{ feed.data()?.pages?.length ?? 0 }}
`, + }) + class Page1 { + readonly feed = infiniteQueryResource({ + queryKey: () => key, + queryFn: ({ pageParam }) => sleep(10).then(() => makePage(pageParam)), + initialPageParam: 1, + getNextPageParam: (last: Page) => + last.hasMore ? last.page + 1 : undefined, + }) + } + + const rendered = await render(Page1) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + const feed = rendered.fixture.componentInstance.feed + expect(feed.data()?.pages.length).toBe(1) + expect(feed.data()?.pages[0]?.page).toBe(1) + expect(feed.hasNextPage()).toBe(true) + }) + + it('fetchNextPage appends pages until getNextPageParam returns undefined', async () => { + const key = queryKey() + + @Component({ + template: `
pages: {{ feed.data()?.pages?.length ?? 0 }}
`, + }) + class Page2 { + readonly feed = infiniteQueryResource({ + queryKey: () => key, + queryFn: ({ pageParam }) => sleep(10).then(() => makePage(pageParam)), + initialPageParam: 1, + getNextPageParam: (last: Page) => + last.hasMore ? last.page + 1 : undefined, + }) + } + + const rendered = await render(Page2) + await vi.advanceTimersByTimeAsync(11) + const feed = rendered.fixture.componentInstance.feed + + feed.fetchNextPage() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(feed.data()?.pages.length).toBe(2) + expect(feed.hasNextPage()).toBe(true) + + feed.fetchNextPage() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(feed.data()?.pages.map((p) => p.page)).toEqual([1, 2, 3]) + expect(feed.hasNextPage()).toBe(false) + }) + + it('respects maxPages by dropping the oldest page', async () => { + const key = queryKey() + + @Component({ + template: `
pages: {{ feed.data()?.pages?.length ?? 0 }}
`, + }) + class Page3 { + readonly feed = infiniteQueryResource({ + queryKey: () => key, + queryFn: ({ pageParam }) => sleep(10).then(() => makePage(pageParam)), + initialPageParam: 1, + getNextPageParam: (last: Page) => + last.hasMore ? last.page + 1 : undefined, + maxPages: 2, + }) + } + + const rendered = await render(Page3) + await vi.advanceTimersByTimeAsync(11) + const feed = rendered.fixture.componentInstance.feed + + feed.fetchNextPage() + await vi.advanceTimersByTimeAsync(11) + feed.fetchNextPage() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(feed.data()?.pages.length).toBe(2) + expect(feed.data()?.pages.map((p) => p.page)).toEqual([2, 3]) + }) + + it('prepends pages with fetchPreviousPage (bi-directional)', async () => { + const key = queryKey() + + @Component({ + template: `
pages: {{ feed.data()?.pages?.length ?? 0 }}
`, + }) + class PageBidi { + readonly feed = infiniteQueryResource({ + queryKey: () => key, + queryFn: ({ pageParam }) => + sleep(10).then(() => ({ page: pageParam })), + initialPageParam: 5, + getNextPageParam: (last: { page: number }) => + last.page < 7 ? last.page + 1 : undefined, + getPreviousPageParam: (first: { page: number }) => + first.page > 1 ? first.page - 1 : undefined, + }) + } + + const rendered = await render(PageBidi) + await vi.advanceTimersByTimeAsync(11) + const feed = rendered.fixture.componentInstance.feed + rendered.fixture.detectChanges() + expect(feed.data()?.pages.map((p) => p.page)).toEqual([5]) + expect(feed.hasPreviousPage()).toBe(true) + expect(feed.hasNextPage()).toBe(true) + + feed.fetchPreviousPage() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(feed.data()?.pages.map((p) => p.page)).toEqual([4, 5]) + + feed.fetchNextPage() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(feed.data()?.pages.map((p) => p.page)).toEqual([4, 5, 6]) + }) +}) diff --git a/packages/angular-query-experimental/src/__tests__/mutation-resource.test.ts b/packages/angular-query-experimental/src/__tests__/mutation-resource.test.ts new file mode 100644 index 00000000000..90f0dcbe948 --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/mutation-resource.test.ts @@ -0,0 +1,208 @@ +import { Component, provideZonelessChangeDetection } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/angular' +import { sleep } from '@tanstack/query-test-utils' +import { + QueryCache, + QueryClient, + mutationResource, + onlineManager, + provideTanStackQuery, +} from '..' + +// Ported from angular-resource-query: mutation-resilience.spec.ts, plus the +// mutationResource lifecycle and optimistic-update coverage from the README examples. +describe('mutationResource', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient({ queryCache: new QueryCache() }) + onlineManager.setOnline(true) + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ], + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs imperatively and exposes the result as a resource', async () => { + @Component({ + template: ` +
mutationStatus: {{ m.mutationStatus() }}
+
status: {{ m.status() }}
+
data: {{ m.data() ?? 'none' }}
+ `, + }) + class Page { + readonly m = mutationResource({ + mutationFn: (title: string) => sleep(10).then(() => `saved:${title}`), + }) + } + + const rendered = await render(Page) + expect(rendered.getByText('mutationStatus: idle')).toBeInTheDocument() + expect(rendered.getByText('status: idle')).toBeInTheDocument() + + rendered.fixture.componentInstance.m.mutate('todo') + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('mutationStatus: success')).toBeInTheDocument() + expect(rendered.getByText('status: resolved')).toBeInTheDocument() + expect(rendered.getByText('data: saved:todo')).toBeInTheDocument() + }) + + it('retries a failing mutation up to the configured count', async () => { + @Component({ template: `
{{ m.mutationStatus() }}
` }) + class Page { + readonly fn = vi + .fn() + .mockImplementationOnce(() => + sleep(5).then(() => Promise.reject(new Error('transient'))), + ) + .mockImplementationOnce(() => sleep(5).then(() => 'ok')) + readonly m = mutationResource({ + mutationFn: () => this.fn(), + retry: 1, + retryDelay: 0, + }) + } + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + const result = instance.m.mutateAsync() + await vi.advanceTimersByTimeAsync(20) + rendered.fixture.detectChanges() + + await expect(result).resolves.toBe('ok') + expect(instance.fn).toHaveBeenCalledTimes(2) + expect(instance.m.isSuccess()).toBe(true) + }) + + it('does not retry by default and surfaces the error', async () => { + @Component({ template: `
{{ m.mutationStatus() }}
` }) + class Page { + readonly fn = vi + .fn() + .mockImplementation(() => + sleep(5).then(() => Promise.reject(new Error('nope'))), + ) + readonly m = mutationResource({ + mutationFn: () => this.fn(), + }) + } + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + const result = instance.m.mutateAsync() + await vi.advanceTimersByTimeAsync(6) + rendered.fixture.detectChanges() + + await expect(result).rejects.toThrow('nope') + expect(instance.fn).toHaveBeenCalledTimes(1) + expect(instance.m.isError()).toBe(true) + }) + + it('pauses while offline and completes on reconnect (networkMode online)', async () => { + onlineManager.setOnline(false) + + @Component({ template: `
{{ m.mutationStatus() }}
` }) + class Page { + readonly fn = vi.fn().mockImplementation(() => sleep(5).then(() => 'done')) + readonly m = mutationResource({ + mutationFn: () => this.fn(), + networkMode: 'online', + }) + } + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + const settled = instance.m.mutateAsync() + await vi.advanceTimersByTimeAsync(0) + rendered.fixture.detectChanges() + + // Paused offline: still pending, mutationFn not yet called. + expect(instance.m.isPending()).toBe(true) + expect(instance.fn).not.toHaveBeenCalled() + + onlineManager.setOnline(true) + await vi.advanceTimersByTimeAsync(6) + rendered.fixture.detectChanges() + + await settled + expect(instance.fn).toHaveBeenCalledTimes(1) + expect(instance.m.isSuccess()).toBe(true) + }) + + it('supports optimistic updates with rollback on error', async () => { + queryClient.setQueryData(['todos'], ['a']) + + @Component({ template: `
{{ m.mutationStatus() }}
` }) + class Page { + readonly m = mutationResource< + string, + Error, + string, + { previous: Array | undefined } + >({ + mutationFn: () => + sleep(10).then(() => Promise.reject(new Error('fail'))), + onMutate: (title) => { + const previous = queryClient.getQueryData>(['todos']) + queryClient.setQueryData>(['todos'], (old) => [ + ...(old ?? []), + title, + ]) + return { previous } + }, + onError: (_error, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(['todos'], context.previous) + } + }, + retry: false, + }) + } + + const rendered = await render(Page) + rendered.fixture.componentInstance.m.mutate('b') + + // onMutate applied the optimistic value. + await vi.advanceTimersByTimeAsync(0) + expect(queryClient.getQueryData(['todos'])).toEqual(['a', 'b']) + + // mutationFn rejects → onError rolls back. + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(queryClient.getQueryData(['todos'])).toEqual(['a']) + expect(rendered.fixture.componentInstance.m.isError()).toBe(true) + }) + + it('reset() returns the mutation to idle', async () => { + @Component({ template: `
{{ m.mutationStatus() }}
` }) + class Page { + readonly m = mutationResource({ + mutationFn: (title: string) => sleep(10).then(() => `saved:${title}`), + }) + } + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + instance.m.mutate('x') + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(instance.m.isSuccess()).toBe(true) + + instance.m.reset() + rendered.fixture.detectChanges() + expect(instance.m.isIdle()).toBe(true) + expect(instance.m.data()).toBeUndefined() + }) +}) diff --git a/packages/angular-query-experimental/src/__tests__/query-resource.test.ts b/packages/angular-query-experimental/src/__tests__/query-resource.test.ts index ea9ae817b83..477f81bf314 100644 --- a/packages/angular-query-experimental/src/__tests__/query-resource.test.ts +++ b/packages/angular-query-experimental/src/__tests__/query-resource.test.ts @@ -1,4 +1,9 @@ -import { Component, provideZonelessChangeDetection } from '@angular/core' +import { + Component, + EnvironmentInjector, + provideZonelessChangeDetection, + signal, +} from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render } from '@testing-library/angular' @@ -7,11 +12,16 @@ import { QueryCache, QueryClient, injectQuery, - mutationResource, + onlineManager, provideTanStackQuery, queryResource, } from '..' +// Ported from angular-resource-query: query-resource.spec.ts, select-placeholder.spec.ts, +// ref-actions.spec.ts, cancellation.spec.ts, gc.spec.ts, network-mode.spec.ts, +// refetch-interval.spec.ts, structural-sharing-query.spec.ts, store-imperative.spec.ts. +// Assertions reflect TanStack core semantics (e.g. a background refetch error sets +// status to 'error' even when cached data is preserved). describe('queryResource', () => { let queryCache: QueryCache let queryClient: QueryClient @@ -20,6 +30,7 @@ describe('queryResource', () => { vi.useFakeTimers() queryCache = new QueryCache() queryClient = new QueryClient({ queryCache }) + onlineManager.setOnline(true) TestBed.configureTestingModule({ providers: [ provideZonelessChangeDetection(), @@ -32,183 +43,487 @@ describe('queryResource', () => { vi.useRealTimers() }) - it('config form: resolves and exposes the resource + query surface', async () => { - const key = queryKey() - - @Component({ - template: ` -
status: {{ q.status() }}
-
queryStatus: {{ q.queryStatus() }}
-
data: {{ q.data() ?? 'none' }}
- @if (q.hasValue()) { -
value: {{ q.value() }}
- } -
isLoading: {{ q.isLoading() }}
-
isSuccess: {{ q.isSuccess() }}
- `, - }) - class Page { - readonly q = queryResource({ - queryKey: () => key, - queryFn: () => sleep(10).then(() => 'result'), - }) - } - - const rendered = await render(Page) - await vi.advanceTimersByTimeAsync(11) - rendered.fixture.detectChanges() - - expect(rendered.getByText('status: resolved')).toBeInTheDocument() - expect(rendered.getByText('queryStatus: success')).toBeInTheDocument() - expect(rendered.getByText('data: result')).toBeInTheDocument() - expect(rendered.getByText('value: result')).toBeInTheDocument() - expect(rendered.getByText('isLoading: false')).toBeInTheDocument() - expect(rendered.getByText('isSuccess: true')).toBeInTheDocument() - }) + describe('basics', () => { + it('config form: resolves and exposes the resource + query surface', async () => { + const key = queryKey() + + @Component({ + template: ` +
status: {{ q.status() }}
+
queryStatus: {{ q.queryStatus() }}
+
data: {{ q.data() ?? 'none' }}
+ @if (q.hasValue()) { +
value: {{ q.value() }}
+ } +
isLoading: {{ q.isLoading() }}
+
isSuccess: {{ q.isSuccess() }}
+ `, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: () => sleep(10).then(() => 'result'), + }) + } - it('options-function form resolves', async () => { - const key = queryKey() + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() - @Component({ - template: `
data: {{ q.data() ?? 'none' }}
`, + expect(rendered.getByText('status: resolved')).toBeInTheDocument() + expect(rendered.getByText('queryStatus: success')).toBeInTheDocument() + expect(rendered.getByText('data: result')).toBeInTheDocument() + expect(rendered.getByText('value: result')).toBeInTheDocument() + expect(rendered.getByText('isLoading: false')).toBeInTheDocument() + expect(rendered.getByText('isSuccess: true')).toBeInTheDocument() }) - class Page { - readonly q = queryResource(() => ({ - queryKey: key, - queryFn: () => sleep(10).then(() => 'fn-form'), - })) - } - const rendered = await render(Page) - await vi.advanceTimersByTimeAsync(11) - rendered.fixture.detectChanges() + it('options-function form resolves (whole-object reactive)', async () => { + const key = queryKey() + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'fn-form'), + })) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('data: fn-form')).toBeInTheDocument() + }) - expect(rendered.getByText('data: fn-form')).toBeInTheDocument() + it('can be created with an explicit injector', async () => { + const key = queryKey() + const injector = TestBed.inject(EnvironmentInjector) + const q = queryResource( + { + queryKey: () => key, + queryFn: () => sleep(10).then(() => 'with-injector'), + }, + { injector }, + ) + TestBed.tick() + await vi.advanceTimersByTimeAsync(11) + expect(q.data()).toBe('with-injector') + }) }) - it('error state: data() is safe, hasValue() is false, value() throws', async () => { - const key = queryKey() - - @Component({ - template: ` -
queryStatus: {{ q.queryStatus() }}
-
data: {{ q.data() ?? 'none' }}
-
hasValue: {{ q.hasValue() }}
-
error: {{ q.error()?.message ?? 'none' }}
- `, - }) - class Page { - readonly q = queryResource({ - queryKey: () => key, - queryFn: () => - sleep(10).then(() => Promise.reject(new Error('boom'))), - retry: false, + describe('reactive key (config form)', () => { + it('deduplicates: two consumers of the same key fetch once and share data', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'shared')) + + @Component({ + template: ` +
r: {{ r.data() ?? 'none' }}
+
i: {{ i.data() ?? 'none' }}
+ `, }) - } + class Page { + readonly r = queryResource({ queryKey: () => key, queryFn }) + readonly i = injectQuery(() => ({ queryKey: key, queryFn })) + } - const rendered = await render(Page) - await vi.advanceTimersByTimeAsync(11) - rendered.fixture.detectChanges() + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() - expect(rendered.getByText('queryStatus: error')).toBeInTheDocument() - expect(rendered.getByText('data: none')).toBeInTheDocument() - expect(rendered.getByText('hasValue: false')).toBeInTheDocument() - expect(rendered.getByText('error: boom')).toBeInTheDocument() - expect(() => rendered.fixture.componentInstance.q.value()).toThrow() + expect(rendered.getByText('r: shared')).toBeInTheDocument() + expect(rendered.getByText('i: shared')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('refetches when the reactive key changes', async () => { + const queryFn = vi.fn((id: number) => + sleep(10).then(() => `user-${id}`), + ) + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly id = signal(1) + readonly q = queryResource({ + queryKey: () => ['user', this.id()], + queryFn: ({ queryKey }) => queryFn(queryKey[1] as number), + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('data: user-1')).toBeInTheDocument() + + rendered.fixture.componentInstance.id.set(2) + rendered.fixture.detectChanges() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('data: user-2')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(2) + }) }) - it('shares the cache with injectQuery (dedupes a single fetch)', async () => { - const key = queryKey() - const queryFn = vi.fn(() => sleep(10).then(() => 'shared')) + describe('select & placeholderData', () => { + it('select() projects the cached data into a derived view', async () => { + const key = queryKey() + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource< + { first: string; last: string }, + Error, + string + >({ + queryKey: () => key, + queryFn: () => sleep(10).then(() => ({ first: 'Ada', last: 'L' })), + select: (user) => `${user.first} ${user.last}`, + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() - @Component({ - template: ` -
r: {{ r.data() ?? 'none' }}
-
i: {{ i.data() ?? 'none' }}
- `, + expect(rendered.getByText('data: Ada L')).toBeInTheDocument() }) - class Page { - readonly r = queryResource({ queryKey: () => key, queryFn }) - readonly i = injectQuery(() => ({ queryKey: key, queryFn })) - } - const rendered = await render(Page) - await vi.advanceTimersByTimeAsync(11) - rendered.fixture.detectChanges() + it('placeholderData keeps the previous value visible while a new key loads', async () => { + const queryFn = (id: number) => sleep(10).then(() => `item-${id}`) - expect(rendered.getByText('r: shared')).toBeInTheDocument() - expect(rendered.getByText('i: shared')).toBeInTheDocument() - expect(queryFn).toHaveBeenCalledTimes(1) + @Component({ + template: ` +
data: {{ q.data() ?? 'none' }}
+
placeholder: {{ q.isPlaceholderData() }}
+ `, + }) + class Page { + readonly id = signal(1) + readonly q = queryResource({ + queryKey: () => ['ph', this.id()], + queryFn: ({ queryKey }) => queryFn(queryKey[1] as number), + placeholderData: (previous: string | undefined) => previous, + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('data: item-1')).toBeInTheDocument() + + // Switch key: before the new fetch resolves, the previous data stays. + rendered.fixture.componentInstance.id.set(2) + rendered.fixture.detectChanges() + expect(rendered.getByText('data: item-1')).toBeInTheDocument() + expect(rendered.getByText('placeholder: true')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('data: item-2')).toBeInTheDocument() + expect(rendered.getByText('placeholder: false')).toBeInTheDocument() + }) }) - it('set() writes through to the cache', async () => { - const key = queryKey() + describe('actions', () => { + it('set() and update() write the cached value through setQueryData', async () => { + const key = queryKey() - @Component({ - template: `
data: {{ q.data() ?? 'none' }}
`, + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: () => sleep(10).then(() => 'server'), + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(rendered.getByText('data: server')).toBeInTheDocument() + + rendered.fixture.componentInstance.q.set('manual') + rendered.fixture.detectChanges() + expect(rendered.getByText('data: manual')).toBeInTheDocument() + expect(queryClient.getQueryData(key)).toBe('manual') + + rendered.fixture.componentInstance.q.update((value) => `${value}!`) + rendered.fixture.detectChanges() + expect(rendered.getByText('data: manual!')).toBeInTheDocument() }) - class Page { - readonly q = queryResource({ - queryKey: () => key, - queryFn: () => sleep(10).then(() => 'a'), + + it('reload() refetches the query', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'v')) + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, }) - } + class Page { + readonly q = queryResource({ queryKey: () => key, queryFn }) + } - const rendered = await render(Page) - await vi.advanceTimersByTimeAsync(11) - rendered.fixture.detectChanges() - expect(rendered.getByText('data: a')).toBeInTheDocument() + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(queryFn).toHaveBeenCalledTimes(1) - rendered.fixture.componentInstance.q.set('b') - rendered.fixture.detectChanges() + rendered.fixture.componentInstance.q.reload() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(queryFn).toHaveBeenCalledTimes(2) + }) - expect(rendered.getByText('data: b')).toBeInTheDocument() - expect(queryClient.getQueryData(key)).toBe('b') - }) -}) + it('preserves cached data when a refetch errors (TanStack semantics)', async () => { + const key = queryKey() + const queryFn = vi + .fn() + .mockImplementationOnce(() => sleep(10).then(() => 'server')) + .mockImplementationOnce(() => + sleep(10).then(() => Promise.reject(new Error('boom'))), + ) -describe('mutationResource', () => { - let queryClient: QueryClient + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn, + retry: false, + }) + } - beforeEach(() => { - vi.useFakeTimers() - queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + const q = rendered.fixture.componentInstance.q + expect(q.data()).toBe('server') + + await q.refetch() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + // Cached data stays visible (resource value does not throw, hasValue stays true), + // while the failure is surfaced via the TanStack status fields. + expect(q.data()).toBe('server') + expect(q.hasValue()).toBe(true) + expect(q.value()).toBe('server') + expect(q.status()).toBe('resolved') + expect(q.queryStatus()).toBe('error') + expect(q.isError()).toBe(true) + expect(q.failureReason()?.message).toBe('boom') + }) + + it('first-load error: data() is safe, hasValue() is false, value() throws', async () => { + const key = queryKey() + + @Component({ + template: ` +
queryStatus: {{ q.queryStatus() }}
+
data: {{ q.data() ?? 'none' }}
+
hasValue: {{ q.hasValue() }}
+
error: {{ q.error()?.message ?? 'none' }}
+ `, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('boom'))), + retry: false, + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(rendered.getByText('queryStatus: error')).toBeInTheDocument() + expect(rendered.getByText('data: none')).toBeInTheDocument() + expect(rendered.getByText('hasValue: false')).toBeInTheDocument() + expect(rendered.getByText('error: boom')).toBeInTheDocument() + expect(() => rendered.fixture.componentInstance.q.value()).toThrow() + }) + + it('counts consecutive failures via failureCount', async () => { + const key = queryKey() + const failing = vi.fn().mockRejectedValue(new Error('always fails')) + + @Component({ + template: `
fc: {{ q.failureCount() }}
`, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: () => failing(), + retry: 1, + retryDelay: 0, + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(failing).toHaveBeenCalledTimes(2) // initial + 1 retry + const q = rendered.fixture.componentInstance.q + expect(q.failureCount()).toBe(2) + expect(q.isError()).toBe(true) }) }) - afterEach(() => { - vi.useRealTimers() + describe('structural sharing', () => { + it('keeps unchanged subtrees referentially stable across refetches', async () => { + const key = queryKey() + let version = 0 + const queryFn = () => + sleep(10).then(() => ({ user: { id: 1, name: 'Ada' }, version: ++version })) + + @Component({ + template: `
v: {{ q.data()?.version ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource({ queryKey: () => key, queryFn }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + const q = rendered.fixture.componentInstance.q + const firstUser = q.data()?.user + expect(q.data()?.version).toBe(1) + + await q.refetch() + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + + expect(q.data()?.version).toBe(2) // top-level object changed + expect(q.data()?.user).toBe(firstUser) // unchanged subtree kept its identity + }) }) - it('runs imperatively and exposes the result as a resource', async () => { - @Component({ - template: ` -
mutationStatus: {{ m.mutationStatus() }}
-
status: {{ m.status() }}
-
data: {{ m.data() ?? 'none' }}
- `, + describe('cancellation & gc', () => { + it('aborts the in-flight fetch when cancelQueries is called', async () => { + const key = queryKey() + const signals: Array = [] + + @Component({ + template: `
{{ q.fetchStatus() }}
`, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn: ({ signal }) => { + signals.push(signal) + return new Promise(() => {}) + }, + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(0) + rendered.fixture.detectChanges() + expect(signals.length).toBe(1) + expect(signals[0]!.aborted).toBe(false) + + void queryClient.cancelQueries({ queryKey: key }) + await vi.advanceTimersByTimeAsync(0) + expect(signals[0]!.aborted).toBe(true) + }) + + it('disposes an unused query after gcTime and refetches on remount', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'value')) + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource({ queryKey: () => key, queryFn, gcTime: 100 }) + } + + const first = await render(Page) + await vi.advanceTimersByTimeAsync(11) + first.fixture.detectChanges() + expect(queryFn).toHaveBeenCalledTimes(1) + expect(queryCache.find({ queryKey: key })).toBeDefined() + + first.fixture.destroy() + await vi.advanceTimersByTimeAsync(101) + expect(queryCache.find({ queryKey: key })).toBeUndefined() + + const second = await render(Page) + await vi.advanceTimersByTimeAsync(11) + second.fixture.detectChanges() + expect(queryFn).toHaveBeenCalledTimes(2) }) - class Page { - readonly m = mutationResource({ - mutationFn: (title: string) => sleep(10).then(() => `saved:${title}`), + }) + + describe('networkMode', () => { + it("pauses an 'online' query while offline and resumes on reconnect", async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'loaded')) + onlineManager.setOnline(false) + + @Component({ + template: `
{{ q.fetchStatus() }}
`, }) - } + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn, + networkMode: 'online', + retry: false, + }) + } - const rendered = await render(Page) - expect(rendered.getByText('mutationStatus: idle')).toBeInTheDocument() - expect(rendered.getByText('status: idle')).toBeInTheDocument() + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(0) + rendered.fixture.detectChanges() + expect(rendered.fixture.componentInstance.q.fetchStatus()).toBe('paused') + expect(queryFn).not.toHaveBeenCalled() - rendered.fixture.componentInstance.m.mutate('todo') - await vi.advanceTimersByTimeAsync(11) - rendered.fixture.detectChanges() + onlineManager.setOnline(true) + await vi.advanceTimersByTimeAsync(11) + rendered.fixture.detectChanges() + expect(queryFn).toHaveBeenCalledTimes(1) + expect(rendered.fixture.componentInstance.q.data()).toBe('loaded') + }) + }) - expect(rendered.getByText('mutationStatus: success')).toBeInTheDocument() - expect(rendered.getByText('status: resolved')).toBeInTheDocument() - expect(rendered.getByText('data: saved:todo')).toBeInTheDocument() + describe('refetchInterval', () => { + it('refetches repeatedly on the interval while mounted', async () => { + const key = queryKey() + let counter = 0 + const queryFn = vi.fn(() => sleep(5).then(() => ++counter)) + + @Component({ + template: `
data: {{ q.data() ?? 'none' }}
`, + }) + class Page { + readonly q = queryResource({ + queryKey: () => key, + queryFn, + refetchInterval: 30, + refetchIntervalInBackground: true, + }) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(110) + rendered.fixture.detectChanges() + + expect(queryFn.mock.calls.length).toBeGreaterThanOrEqual(2) + }) }) })