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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/angular-query-resource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/angular-query-experimental': minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Use a major changeset 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
-'`@tanstack/angular-query-experimental`': minor
+'`@tanstack/angular-query-experimental`': major

Also applies to: 11-11

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/angular-query-resource.md at line 2, The changeset entry for
`@tanstack/angular-query-experimental` is marked as a minor bump, but it
introduces a breaking change to the peer dependency requirement for Angular
(dropping support for versions below 22). Change the bump level from minor to
major for the `@tanstack/angular-query-experimental` entry to correctly reflect
this breaking change in the changeset metadata. This ensures the version bump
accurately communicates the breaking nature of the peer dependency change to
consumers.

---

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.
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
164 changes: 164 additions & 0 deletions docs/framework/angular/guides/resources.md
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`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

resourceFromSnapshots is an experimental API


`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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Correct incompatible version guidance and the shared-cache call shape.

Line 157 recommends injectQuery for Angular <22, but this package release now targets Angular >=22. Line 161 also shows queryResource(['user', 1]), which does not match the documented API forms in this PR (config object or options-function).

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

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

Suggested change
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()`.
Reach for `injectQuery` when you prefer the existing flat signal-proxy result shape.
## Notes & differences
- **Shared cache.** `queryResource(...)` and `injectQuery(...)` calls that resolve to the same `queryKey` share one cached query.
- **`status` is the resource status.** The TanStack `pending | success | error` status is on `queryStatus()`.
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/framework/angular/guides/resources.md` around lines 157 - 162, Update
line 157 to correct the Angular version guidance for injectQuery since this
package now targets Angular >=22 rather than supporting versions below 22.
Additionally, update the example on line 161 that shows queryResource(['user',
1]) to use the correct documented API form (either a config object or
options-function) instead of the array-based call signature, ensuring it matches
the API patterns documented elsewhere in the PR.

- **`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).
12 changes: 6 additions & 6 deletions packages/angular-query-experimental/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
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
```
Loading