-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
feat(angular): query resource #10976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>` (`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. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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`). | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| `queryResource` is a resource-shaped alternative to [`injectQuery`](../queries). Instead of returning an object of signals, it returns a real Angular [`Resource<T>`](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()) { | ||||||||||||||||||||||||||
| <p>Loadingβ¦</p> | ||||||||||||||||||||||||||
| } @else if (todos.hasValue()) { | ||||||||||||||||||||||||||
| <ul> | ||||||||||||||||||||||||||
| @for (todo of todos.value(); track todo.id) { | ||||||||||||||||||||||||||
| <li>{{ todo.title }}</li> | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| </ul> | ||||||||||||||||||||||||||
| } @else if (todos.isError()) { | ||||||||||||||||||||||||||
| <p>Error: {{ todos.error()?.message }}</p> | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| `, | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| 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<TData | undefined>` 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<Error \| undefined>` (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()`. | ||||||||||||||||||||||||||
|
Comment on lines
+157
to
+162
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct incompatible version guidance and the shared-cache call shape. Line 157 recommends Suggested doc patch-Reach for `injectQuery` when you are targeting an Angular version below 22, or when you prefer the existing flat signal-proxy result shape.
+Reach for `injectQuery` when you prefer the existing flat signal-proxy result shape.
@@
-- **Shared cache.** `queryResource(['user', 1])` and `injectQuery(() => ({ queryKey: ['user', 1] }))` resolve to the same cached query.
+- **Shared cache.** `queryResource(...)` and `injectQuery(...)` calls that resolve to the same `queryKey` share one cached query.π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||
| - **`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). | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a
majorchangeset for the Angular peer-floor break.This entry documents a breaking install/runtime contract for Angular <22, but the frontmatter is
minor. Please align the bump level with the breaking peer dependency change.Suggested change
Also applies to: 11-11
π€ Prompt for AI Agents