diff --git a/.changeset/deep-crews-open.md b/.changeset/deep-crews-open.md new file mode 100644 index 0000000000..b46a786d5a --- /dev/null +++ b/.changeset/deep-crews-open.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': minor +--- + +require Angular v19+ and use Angular component effect scheduling diff --git a/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md b/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md index 0784526cb5..7bd3764e0e 100644 --- a/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md +++ b/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md @@ -36,8 +36,6 @@ class ExampleComponent { ``` > Since Angular is moving towards RxJS as an optional dependency, it's expected that `HttpClient` will also support promises in the future. -> -> Support for observables in TanStack Query for Angular is planned. ## Comparison table diff --git a/docs/framework/angular/guides/caching.md b/docs/framework/angular/guides/caching.md index 32c6e6d58d..5de89421a5 100644 --- a/docs/framework/angular/guides/caching.md +++ b/docs/framework/angular/guides/caching.md @@ -27,7 +27,7 @@ Let's assume we are using the default `gcTime` of **5 minutes** and the default - When the request completes successfully, the cache's data under the `['todos']` key is updated with the new data, and both instances are updated with the new data. - Both instances of the `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` query are destroyed and no longer in use. - Since there are no more active instances of this query, a garbage collection timeout is set using `gcTime` to delete and garbage collect the query (defaults to **5 minutes**). -- Before the cache timeout has completed, another instance of `injectQuery(() => ({ queryKey: ['todos'], queyFn: fetchTodos })` mounts. The query immediately returns the available cached data while the `fetchTodos` function is being run in the background. When it completes successfully, it will populate the cache with fresh data. +- Before the cache timeout has completed, another instance of `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` mounts. The query immediately returns the available cached data while the `fetchTodos` function is being run in the background. When it completes successfully, it will populate the cache with fresh data. - The final instance of `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` gets destroyed. - No more instances of `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` appear within **5 minutes**. - The cached data under the `['todos']` key is deleted and garbage collected. diff --git a/docs/framework/angular/guides/default-query-function.md b/docs/framework/angular/guides/default-query-function.md index 75e0fa3c48..4db44c9828 100644 --- a/docs/framework/angular/guides/default-query-function.md +++ b/docs/framework/angular/guides/default-query-function.md @@ -28,7 +28,10 @@ bootstrapApplication(MyAppComponent, { providers: [provideTanStackQuery(queryClient)], }) -export class PostsComponent { +@Component({ + // ... +}) +class PostsComponent { // All you have to do now is pass a key! postsQuery = injectQuery>(() => ({ queryKey: ['/posts'], @@ -36,11 +39,16 @@ export class PostsComponent { // ... } -export class PostComponent { +@Component({ + // ... +}) +class PostComponent { + postId = input(0) + // You can even leave out the queryFn and just go straight into options postQuery = injectQuery(() => ({ - enabled: this.postIdSignal() > 0, - queryKey: [`/posts/${this.postIdSignal()}`], + enabled: this.postId() > 0, + queryKey: [`/posts/${this.postId()}`], })) // ... } diff --git a/docs/framework/angular/guides/dependent-queries.md b/docs/framework/angular/guides/dependent-queries.md index 38afbd491f..2fee39bafc 100644 --- a/docs/framework/angular/guides/dependent-queries.md +++ b/docs/framework/angular/guides/dependent-queries.md @@ -10,14 +10,14 @@ replace: { 'useQuery': 'injectQuery', 'useQueries': 'injectQueries' } ```ts // Get the user userQuery = injectQuery(() => ({ - queryKey: ['user', email], - queryFn: getUserByEmail, + queryKey: ['user', this.email()], + queryFn: this.getUserByEmail, })) // Then get the user's projects projectsQuery = injectQuery(() => ({ queryKey: ['projects', this.userQuery.data()?.id], - queryFn: getProjectsByUser, + queryFn: this.getProjectsByUser, // The query will not execute until the user id exists enabled: !!this.userQuery.data()?.id, })) @@ -26,8 +26,25 @@ projectsQuery = injectQuery(() => ({ [//]: # 'Example' [//]: # 'Example2' +Dynamic parallel query - `injectQueries` can depend on a previous query also, here's how to achieve this: + +> IMPORTANT: `injectQueries` is experimental and is provided in it's own entry point + ```ts -// injectQueries is under development for Angular Query +// Get the users ids +userIds = injectQuery(() => ({ + queryKey: ['users'], + queryFn: getUserData, + select: (users) => users.map((user) => user.id), +})) + +// Then get the users messages +userQueries = injectQueries(() => ({ + queries: (this.userIds() ?? []).map((userId) => ({ + queryKey: ['user', userId], + queryFn: () => getUserById(userId), + })), +})) ``` [//]: # 'Example2' diff --git a/docs/framework/angular/guides/disabling-queries.md b/docs/framework/angular/guides/disabling-queries.md index 35da0225de..2977491196 100644 --- a/docs/framework/angular/guides/disabling-queries.md +++ b/docs/framework/angular/guides/disabling-queries.md @@ -13,9 +13,9 @@ replace: { 'useQuery': 'injectQuery' } template: `
- @if (query.data()) { + @if (query.data(); as data) {
    - @for (todo of query.data(); track todo.id) { + @for (todo of data; track todo.id) {
  • {{ todo.title }}
  • }
@@ -70,7 +70,7 @@ export class TodosComponent { [//]: # 'Example3' ```angular-ts -import { skipToken, injectQuery } from '@tanstack/query-angular' +import { skipToken, injectQuery } from '@tanstack/angular-query-experimental' @Component({ selector: 'todos', diff --git a/docs/framework/angular/guides/does-this-replace-client-state.md b/docs/framework/angular/guides/does-this-replace-client-state.md index 3872115c94..0c16fb3568 100644 --- a/docs/framework/angular/guides/does-this-replace-client-state.md +++ b/docs/framework/angular/guides/does-this-replace-client-state.md @@ -7,5 +7,7 @@ replace: 'useQuery': 'injectQuery', 'useMutation': 'injectMutation', 'hook': 'function', + 'Redux, MobX or': 'NgRx Store or', + 'Redux, MobX, Zustand': 'NgRx Store, custom services with RxJS', } --- diff --git a/docs/framework/angular/guides/important-defaults.md b/docs/framework/angular/guides/important-defaults.md index 886792038b..b8af966642 100644 --- a/docs/framework/angular/guides/important-defaults.md +++ b/docs/framework/angular/guides/important-defaults.md @@ -4,7 +4,6 @@ title: Important Defaults ref: docs/framework/react/guides/important-defaults.md replace: { - 'React': 'Angular', 'react-query': 'angular-query', 'useQuery': 'injectQuery', 'useInfiniteQuery': 'injectInfiniteQuery', diff --git a/docs/framework/angular/guides/infinite-queries.md b/docs/framework/angular/guides/infinite-queries.md index 9fdde83e26..35371a5ff4 100644 --- a/docs/framework/angular/guides/infinite-queries.md +++ b/docs/framework/angular/guides/infinite-queries.md @@ -75,6 +75,8 @@ export class Example { template: ` `, }) export class Example { + projectsService = inject(ProjectsService) + query = injectInfiniteQuery(() => ({ queryKey: ['projects'], queryFn: async ({ pageParam }) => { diff --git a/docs/framework/angular/guides/initial-query-data.md b/docs/framework/angular/guides/initial-query-data.md index 28673844be..7cd6472c4f 100644 --- a/docs/framework/angular/guides/initial-query-data.md +++ b/docs/framework/angular/guides/initial-query-data.md @@ -81,7 +81,7 @@ result = injectQuery(() => ({ ```ts result = injectQuery(() => ({ queryKey: ['todo', this.todoId()], - queryFn: () => fetch('/todos'), + queryFn: () => fetch(`/todos/${this.todoId()}`), initialData: () => { // Use a todo from the 'todos' query as the initial data for this todo query return this.queryClient @@ -99,9 +99,11 @@ result = injectQuery(() => ({ queryKey: ['todos', this.todoId()], queryFn: () => fetch(`/todos/${this.todoId()}`), initialData: () => - queryClient.getQueryData(['todos'])?.find((d) => d.id === this.todoId()), + this.queryClient + .getQueryData(['todos']) + ?.find((d) => d.id === this.todoId()), initialDataUpdatedAt: () => - queryClient.getQueryState(['todos'])?.dataUpdatedAt, + this.queryClient.getQueryState(['todos'])?.dataUpdatedAt, })) ``` @@ -114,7 +116,7 @@ result = injectQuery(() => ({ queryFn: () => fetch(`/todos/${this.todoId()}`), initialData: () => { // Get the query state - const state = queryClient.getQueryState(['todos']) + const state = this.queryClient.getQueryState(['todos']) // If the query exists and has data that is no older than 10 seconds... if (state && Date.now() - state.dataUpdatedAt <= 10 * 1000) { diff --git a/docs/framework/angular/guides/invalidations-from-mutations.md b/docs/framework/angular/guides/invalidations-from-mutations.md index 18f84e9ec3..0c300a716d 100644 --- a/docs/framework/angular/guides/invalidations-from-mutations.md +++ b/docs/framework/angular/guides/invalidations-from-mutations.md @@ -2,7 +2,12 @@ id: invalidations-from-mutations title: Invalidations from Mutations ref: docs/framework/react/guides/invalidations-from-mutations.md -replace: { 'useMutation': 'injectMutation', 'hook': 'function' } +replace: + { + 'react-query': 'angular-query-experimental', + 'useMutation': 'injectMutation', + 'hook': 'function', + } --- [//]: # 'Example' @@ -22,6 +27,9 @@ import { QueryClient, } from '@tanstack/angular-query-experimental' +@Component({ + // ... +}) export class TodosComponent { queryClient = inject(QueryClient) diff --git a/docs/framework/angular/guides/mutations.md b/docs/framework/angular/guides/mutations.md index f511808231..b769ad8ee1 100644 --- a/docs/framework/angular/guides/mutations.md +++ b/docs/framework/angular/guides/mutations.md @@ -243,12 +243,15 @@ queryClient.setMutationDefaults(['addTodo'], { retry: 3, }) -class someComponent { +@Component({ + // ... +}) +class SomeComponent { // Start mutation in some component: mutation = injectMutation(() => ({ mutationKey: ['addTodo'] })) someMethod() { - mutation.mutate({ title: 'title' }) + this.mutation.mutate({ title: 'title' }) } } diff --git a/docs/framework/angular/guides/paginated-queries.md b/docs/framework/angular/guides/paginated-queries.md index 4baa3d4cfb..2880ed26bf 100644 --- a/docs/framework/angular/guides/paginated-queries.md +++ b/docs/framework/angular/guides/paginated-queries.md @@ -35,9 +35,9 @@ const result = injectQuery(() => ({ instantaneously while they are also re-fetched invisibly in the background.

- @if (query.status() === 'pending') { + @if (query.isPending()) {
Loading...
- } @else if (query.status() === 'error') { + } @else if (query.isError()) {
Error: {{ query.error().message }}
} @else { @@ -68,6 +68,10 @@ const result = injectQuery(() => ({
`, }) + +@Component({ + // ... +}) export class PaginationExampleComponent { page = signal(0) queryClient = inject(QueryClient) @@ -83,7 +87,7 @@ export class PaginationExampleComponent { effect(() => { // Prefetch the next page! if (!this.query.isPlaceholderData() && this.query.data()?.hasMore) { - this.#queryClient.prefetchQuery({ + this.queryClient.prefetchQuery({ queryKey: ['projects', this.page() + 1], queryFn: () => lastValueFrom(fetchProjects(this.page() + 1)), }) diff --git a/docs/framework/angular/guides/parallel-queries.md b/docs/framework/angular/guides/parallel-queries.md index f88756a2a8..5894171ad8 100644 --- a/docs/framework/angular/guides/parallel-queries.md +++ b/docs/framework/angular/guides/parallel-queries.md @@ -17,6 +17,9 @@ replace: [//]: # 'Example' ```ts +@Component({ + // ... +}) export class AppComponent { // The following queries will execute in parallel usersQuery = injectQuery(() => ({ queryKey: ['users'], queryFn: fetchUsers })) @@ -38,11 +41,15 @@ TanStack Query provides `injectQueries`, which you can use to dynamically execut [//]: # 'DynamicParallelIntro' [//]: # 'Example2' +> IMPORTANT: `injectQueries` is experimental and is provided in it's own entry point + ```ts +@Component({ + // ... +}) export class AppComponent { users = signal>([]) - // Please note injectQueries is under development and this code does not work yet userQueries = injectQueries(() => ({ queries: users().map((user) => { return { diff --git a/docs/framework/angular/guides/placeholder-query-data.md b/docs/framework/angular/guides/placeholder-query-data.md index ba92fbc4ed..2298d7bd2c 100644 --- a/docs/framework/angular/guides/placeholder-query-data.md +++ b/docs/framework/angular/guides/placeholder-query-data.md @@ -7,6 +7,9 @@ ref: docs/framework/react/guides/placeholder-query-data.md [//]: # 'ExampleValue' ```ts +@Component({ + // ... +}) class TodosComponent { result = injectQuery(() => ({ queryKey: ['todos'], @@ -22,10 +25,15 @@ class TodosComponent { [//]: # 'ExampleFunction' ```ts -class TodosComponent { +@Component({ + // ... +}) +export class TodosComponent { + todoId = signal(1) + result = injectQuery(() => ({ - queryKey: ['todos', id()], - queryFn: () => fetch(`/todos/${id}`), + queryKey: ['todos', this.todoId()], + queryFn: () => fetch(`/todos/${this.todoId()}`), placeholderData: (previousData, previousQuery) => previousData, })) } @@ -35,6 +43,9 @@ class TodosComponent { [//]: # 'ExampleCache' ```ts +@Component({ + // ... +}) export class BlogPostComponent { postId = input.required() queryClient = inject(QueryClient) diff --git a/docs/framework/angular/guides/queries.md b/docs/framework/angular/guides/queries.md index 336da2e171..8192f927f0 100644 --- a/docs/framework/angular/guides/queries.md +++ b/docs/framework/angular/guides/queries.md @@ -19,6 +19,9 @@ replace: ```ts import { injectQuery } from '@tanstack/angular-query-experimental' +@Component({ + // ... +}) export class TodosComponent { info = injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodoList })) } @@ -61,38 +64,7 @@ export class PostsComponent { ``` [//]: # 'Example3' - -If booleans aren't your thing, you can always use the `status` state as well: - [//]: # 'Example4' - -```angular-ts -@Component({ - selector: 'todos', - template: ` - @switch (todos.status()) { - @case ('pending') { - Loading... - } - @case ('error') { - Error: {{ todos.error()?.message }} - } - - @default { -
    - @for (todo of todos.data(); track todo.id) { -
  • {{ todo.title }}
  • - } @empty { -
  • No todos found
  • - } -
- } - } - `, -}) -class TodosComponent {} -``` - [//]: # 'Example4' [//]: # 'Materials' [//]: # 'Materials' diff --git a/docs/framework/angular/guides/query-functions.md b/docs/framework/angular/guides/query-functions.md index ae5f9e6c99..c8c3bd992a 100644 --- a/docs/framework/angular/guides/query-functions.md +++ b/docs/framework/angular/guides/query-functions.md @@ -8,16 +8,19 @@ ref: docs/framework/react/guides/query-functions.md ```ts injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchAllTodos })) -injectQuery(() => ({ queryKey: ['todos', todoId], queryFn: () => fetchTodoById(todoId) }) injectQuery(() => ({ - queryKey: ['todos', todoId], + queryKey: ['todos', todoId()], + queryFn: () => fetchTodoById(todoId()), +})) +injectQuery(() => ({ + queryKey: ['todos', todoId()], queryFn: async () => { - const data = await fetchTodoById(todoId) + const data = await fetchTodoById(todoId()) return data }, })) injectQuery(() => ({ - queryKey: ['todos', todoId], + queryKey: ['todos', todoId()], queryFn: ({ queryKey }) => fetchTodoById(queryKey[1]), })) ``` @@ -26,8 +29,10 @@ injectQuery(() => ({ [//]: # 'Example2' ```ts +todoId = signal(1) + todos = injectQuery(() => ({ - queryKey: ['todos', todoId()], + queryKey: ['todos', this.todoId()], queryFn: async () => { if (somethingGoesWrong) { throw new Error('Oh no!') @@ -45,10 +50,12 @@ todos = injectQuery(() => ({ [//]: # 'Example3' ```ts +todoId = signal(1) + todos = injectQuery(() => ({ - queryKey: ['todos', todoId()], + queryKey: ['todos', this.todoId()], queryFn: async () => { - const response = await fetch('/todos/' + todoId) + const response = await fetch('/todos/' + this.todoId()) if (!response.ok) { throw new Error('Network response was not ok') } @@ -61,8 +68,11 @@ todos = injectQuery(() => ({ [//]: # 'Example4' ```ts +status = signal('active') +page = signal(1) + result = injectQuery(() => ({ - queryKey: ['todos', { status: status(), page: page() }], + queryKey: ['todos', { status: this.status(), page: this.page() }], queryFn: fetchTodoList, })) diff --git a/docs/framework/angular/guides/query-invalidation.md b/docs/framework/angular/guides/query-invalidation.md index 9cd8ea5809..e174d0a4b4 100644 --- a/docs/framework/angular/guides/query-invalidation.md +++ b/docs/framework/angular/guides/query-invalidation.md @@ -8,8 +8,12 @@ replace: { 'useQuery': 'injectQuery', 'hooks': 'functions' } [//]: # 'Example2' ```ts +import { Component, inject } from '@angular/core' import { injectQuery, QueryClient } from '@tanstack/angular-query-experimental' +@Component({ + // ... +}) class QueryInvalidationExample { queryClient = inject(QueryClient) @@ -72,7 +76,7 @@ todoListQuery = injectQuery(() => ({ })) // However, the following query below will NOT be invalidated -const todoListQuery = injectQuery(() => ({ +todoListQuery = injectQuery(() => ({ queryKey: ['todos', { type: 'done' }], queryFn: fetchTodoList, })) diff --git a/docs/framework/angular/guides/query-options.md b/docs/framework/angular/guides/query-options.md index d63753bbfc..6d6ad6b3e9 100644 --- a/docs/framework/angular/guides/query-options.md +++ b/docs/framework/angular/guides/query-options.md @@ -31,26 +31,26 @@ export class QueriesService { // usage: -postId = input.required({ - transform: numberAttribute, -}) +postId = input.required({ transform: numberAttribute }) queries = inject(QueriesService) +queryClient = inject(QueryClient) postQuery = injectQuery(() => this.queries.post(this.postId())) -queryClient.prefetchQuery(this.queries.post(23)) -queryClient.setQueryData(this.queries.post(42).queryKey, newPost) +someMethod() { + this.queryClient.prefetchQuery(this.queries.post(23)) + this.queryClient.setQueryData(this.queries.post(42).queryKey, newPost) +} ``` [//]: # 'Example1' [//]: # 'Example2' ```ts -// Type inference still works, so query.data will be the return type of select instead of queryFn queries = inject(QueriesService) query = injectQuery(() => ({ - ...groupOptions(1), + ...this.queries.post(1), select: (data) => data.title, })) ``` diff --git a/docs/framework/angular/guides/query-retries.md b/docs/framework/angular/guides/query-retries.md index 45228d10bb..c6c73fb280 100644 --- a/docs/framework/angular/guides/query-retries.md +++ b/docs/framework/angular/guides/query-retries.md @@ -31,10 +31,10 @@ const result = injectQuery(() => ({ ```ts // Configure for all queries import { - QueryCache, QueryClient, - QueryClientProvider, + provideTanStackQuery, } from '@tanstack/angular-query-experimental' +import { bootstrapApplication } from '@angular/platform-browser' const queryClient = new QueryClient({ defaultOptions: { @@ -51,7 +51,7 @@ bootstrapApplication(AppComponent, { [//]: # 'Example2' -Though it is not recommended, you can obviously override the `retryDelay` function/integer in both the Provider and individual query options. If set to an integer instead of a function the delay will always be the same amount of time: +Though it is not recommended, you can obviously override the `retryDelay` function/integer in both the QueryClient default options and individual query options. If set to an integer instead of a function the delay will always be the same amount of time: [//]: # 'Example3' diff --git a/docs/framework/angular/guides/testing.md b/docs/framework/angular/guides/testing.md index 7648d7f6b3..3ffb9588b8 100644 --- a/docs/framework/angular/guides/testing.md +++ b/docs/framework/angular/guides/testing.md @@ -9,8 +9,6 @@ TanStack Query's `inject*` functions integrate with [`PendingTasks`](https://ang This means tests and SSR can wait until mutations and queries resolve. In unit tests you can use `ApplicationRef.whenStable()` or `fixture.whenStable()` to await query completion. This works for both Zone.js and Zoneless setups. -> This integration requires Angular 19 or later. Earlier versions of Angular do not support `PendingTasks`. - ## TestBed setup Create a fresh `QueryClient` for every spec and provide it with `provideTanStackQuery` or `provideQueryClient`. This keeps caches isolated and lets you change default options per test: @@ -31,7 +29,7 @@ TestBed.configureTestingModule({ > If your applications actual TanStack Query config is used in unit tests, make sure `withDevtools` is not accidentally included in test providers. This can cause slow tests. It is best to keep test and production configs separate. -If you share helpers, remember to call `queryClient.clear()` (or build a new instance) in `afterEach` so data from one test never bleeds into another. +If you share helpers, remember to call `queryClient.clear()` (or build a new instance) in `afterEach` so data from one test never bleeds into another. Prefer creating a fresh `QueryClient` per test: clearing only removes cached data, not custom defaults or listeners, so a reused client can leak configuration changes between specs and make failures harder to reason about. A new client keeps setup explicit and avoids any “invisible globals” influencing results. ## First query test diff --git a/docs/framework/angular/installation.md b/docs/framework/angular/installation.md index dffc092e7c..14b48432f8 100644 --- a/docs/framework/angular/installation.md +++ b/docs/framework/angular/installation.md @@ -7,7 +7,7 @@ title: Installation ### NPM -_Angular Query is compatible with Angular v16 and higher_ +_Angular Query is compatible with Angular v19 and higher_ ```bash npm i @tanstack/angular-query-experimental diff --git a/docs/framework/angular/overview.md b/docs/framework/angular/overview.md index be68c08e5a..2eba20cab0 100644 --- a/docs/framework/angular/overview.md +++ b/docs/framework/angular/overview.md @@ -13,7 +13,7 @@ We are in the process of getting to a stable API for TanStack Query on Angular. ## Supported Angular Versions -TanStack Query is compatible with Angular v16 and higher. +TanStack Query is compatible with Angular v19 and higher. TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes **fetching, caching, synchronizing and updating server state** in your web applications a breeze. @@ -73,11 +73,10 @@ import { lastValueFrom } from 'rxjs' template: ` @if (query.isPending()) { Loading... - } - @if (query.error()) { + } @else if (query.isError()) { An error has occurred: {{ query.error().message }} - } - @if (query.data(); as data) { + } @else if (query.isSuccess()) { + @let data = query.data();

{{ data.name }}

{{ data.description }}

👀 {{ data.subscribers_count }} diff --git a/docs/framework/angular/quick-start.md b/docs/framework/angular/quick-start.md index b711f92b2b..41b246498d 100644 --- a/docs/framework/angular/quick-start.md +++ b/docs/framework/angular/quick-start.md @@ -35,7 +35,7 @@ import { @NgModule({ declarations: [AppComponent], imports: [BrowserModule], - providers: [provideTanStackQuery(new QueryClient())], + providers: [provideTanStackQuery(new QueryClient()), provideHttpClient()], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/docs/framework/angular/reference/functions/injectQuery.md b/docs/framework/angular/reference/functions/injectQuery.md index 8fa6832b09..e53a95962a 100644 --- a/docs/framework/angular/reference/functions/injectQuery.md +++ b/docs/framework/angular/reference/functions/injectQuery.md @@ -9,11 +9,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ) })) } ``` @@ -60,11 +63,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ), })) } ``` @@ -141,11 +147,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ), })) } ``` @@ -222,11 +231,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ), })) } ``` diff --git a/docs/framework/angular/typescript.md b/docs/framework/angular/typescript.md index 0aa2ff2638..0dea29de35 100644 --- a/docs/framework/angular/typescript.md +++ b/docs/framework/angular/typescript.md @@ -12,6 +12,7 @@ replace: 'React Query': 'TanStack Query', '`success`': '`isSuccess()`', 'function:': 'function.', + 'separate function': 'separate function or a service', } --- @@ -52,7 +53,7 @@ class MyComponent { [//]: # 'TypeInference2' [//]: # 'TypeInference3' -In this example we pass Group[] to the type parameter of HttpClient's `get` method. +In this example we pass `Group[]` to the type parameter of HttpClient's `get` method. ```angular-ts @Component({ @@ -70,6 +71,7 @@ class MyComponent { ``` [//]: # 'TypeInference3' +[//]: # 'TypeInference4' [//]: # 'TypeNarrowing' ```angular-ts @@ -90,8 +92,9 @@ class MyComponent { } ``` -> TypeScript currently does not support discriminated unions on object methods. Narrowing on signal fields on objects such as query results only works on signals returning a boolean. Prefer using `isSuccess()` and similar boolean status signals over `status() === 'success'`. +> TypeScript currently does not support discriminated unions on object methods. Narrowing on signal fields on objects such as query results only works on signals returning a boolean. Prefer using `isSuccess()`, `isError()` and `isPending()` over `status() === 'success'`. +[//]: # 'TypeInference4' [//]: # 'TypeNarrowing' [//]: # 'TypingError' @@ -153,8 +156,7 @@ import '@tanstack/angular-query-experimental' declare module '@tanstack/angular-query-experimental' { interface Register { - // Use unknown so call sites must narrow explicitly. - defaultError: unknown + defaultError: AxiosError } } @@ -165,7 +167,7 @@ const query = injectQuery(() => ({ computed(() => { const error = query.error() - // ^? error: unknown | null + // ^? error: AxiosError | null }) ``` diff --git a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts index 71c141e3e4..3232f64942 100644 --- a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts +++ b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts @@ -30,30 +30,25 @@ export class ExampleComponent { })) readonly nextButtonDisabled = computed( - () => !this.#hasNextPage() || this.#isFetchingNextPage(), + () => !this.query.hasNextPage() || this.query.isFetchingNextPage(), ) readonly nextButtonText = computed(() => - this.#isFetchingNextPage() + this.query.isFetchingNextPage() ? 'Loading more...' - : this.#hasNextPage() + : this.query.hasNextPage() ? 'Load newer' : 'Nothing more to load', ) readonly previousButtonDisabled = computed( - () => !this.#hasPreviousPage() || this.#isFetchingNextPage(), + () => !this.query.hasPreviousPage() || this.query.isFetchingPreviousPage(), ) readonly previousButtonText = computed(() => - this.#isFetchingPreviousPage() + this.query.isFetchingPreviousPage() ? 'Loading more...' - : this.#hasPreviousPage() + : this.query.hasPreviousPage() ? 'Load Older' : 'Nothing more to load', ) - - readonly #hasPreviousPage = this.query.hasPreviousPage - readonly #hasNextPage = this.query.hasNextPage - readonly #isFetchingPreviousPage = this.query.isFetchingPreviousPage - readonly #isFetchingNextPage = this.query.isFetchingNextPage } diff --git a/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts b/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts index 2b0b4cc1c4..b32a0f50dc 100644 --- a/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts +++ b/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts @@ -36,7 +36,7 @@ import { TasksService } from '../services/tasks.service'
    - @for (task of tasks.data(); track task) { + @for (task of tasks.data(); track $index) {
  • {{ task }}
  • }
diff --git a/packages/angular-query-experimental/README.md b/packages/angular-query-experimental/README.md index 6ed2dfa05a..7a60650d6d 100644 --- a/packages/angular-query-experimental/README.md +++ b/packages/angular-query-experimental/README.md @@ -29,7 +29,7 @@ Visit https://tanstack.com/query/latest/docs/framework/angular/overview # Quick Start -> The Angular adapter for TanStack Query requires Angular 16 or higher. +> The Angular adapter for TanStack Query requires Angular 19 or higher. 1. Install `angular-query` @@ -78,9 +78,10 @@ import { @NgModule({ declarations: [AppComponent], imports: [BrowserModule], - providers: [provideTanStackQuery(new QueryClient())], + providers: [provideTanStackQuery(new QueryClient()), provideHttpClient()], bootstrap: [AppComponent], }) +export class AppModule {} ``` 3. Inject query diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index a122c4bcb7..a97a10d278 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -31,11 +31,6 @@ "compile": "tsc --build", "test:eslint": "eslint --concurrency=auto ./src", "test:types": "npm-run-all --serial test:types:*", - "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js --build", - "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js --build", - "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js --build", - "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build", - "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build", "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build", "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build", @@ -93,6 +88,7 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", + "@angular/platform-server": "^20.0.0", "@tanstack/query-test-utils": "workspace:*", "@testing-library/angular": "^18.0.0", "npm-run-all2": "^5.0.0", @@ -105,8 +101,8 @@ "@tanstack/query-devtools": "workspace:*" }, "peerDependencies": { - "@angular/common": ">=16.0.0", - "@angular/core": ">=16.0.0" + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0" }, "publishConfig": { "directory": "dist", diff --git a/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts b/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts index 7368deb32e..835cb01ab3 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts @@ -1,13 +1,9 @@ -import { - ElementRef, - provideZonelessChangeDetection, - signal, -} from '@angular/core' +import { ElementRef, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient } from '@tanstack/query-core' -import { provideTanStackQuery } from '../providers' import { injectDevtoolsPanel } from '../devtools-panel' +import { setupTanStackQueryTestBed } from './test-utils' const mockDevtoolsPanelInstance = { mount: vi.fn(), @@ -40,12 +36,8 @@ describe('injectDevtoolsPanel', () => { beforeEach(() => { queryClient = new QueryClient() mockElementRef = new ElementRef(document.createElement('div')) - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - { provide: ElementRef, useValue: signal(mockElementRef) }, - ], + setupTanStackQueryTestBed(queryClient, { + providers: [{ provide: ElementRef, useValue: signal(mockElementRef) }], }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts index 7ec133adfb..c34c580ddc 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts @@ -1,42 +1,97 @@ -import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expectTypeOf, test, vi } from 'vitest' -import { provideZonelessChangeDetection } from '@angular/core' -import { sleep } from '@tanstack/query-test-utils' -import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' +import { describe, expectTypeOf, it, test } from 'vitest' +import { injectInfiniteQuery } from '..' +import type { Signal } from '@angular/core' import type { InfiniteData } from '@tanstack/query-core' describe('injectInfiniteQuery', () => { - let queryClient: QueryClient - - beforeEach(() => { - queryClient = new QueryClient() - vi.useFakeTimers() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], + describe('Discriminated union return type', () => { + test('data should be possibly undefined by default', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + })) + + expectTypeOf(query.data).toEqualTypeOf< + Signal | Signal> + >() }) - }) - afterEach(() => { - vi.useRealTimers() - }) + test('data should be defined when query is success', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + })) + + if (query.isSuccess()) { + expectTypeOf(query.data).toEqualTypeOf< + Signal> + >() + } + }) + + test('error should be null when query is success', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + })) - test('should narrow type after isSuccess', () => { - const query = TestBed.runInInjectionContext(() => { - return injectInfiniteQuery(() => ({ + if (query.isSuccess()) { + expectTypeOf(query.error).toEqualTypeOf>() + } + }) + + test('data should be undefined when query is pending', () => { + const query = injectInfiniteQuery(() => ({ queryKey: ['infiniteQuery'], queryFn: ({ pageParam }) => - sleep(0).then(() => 'data on page ' + pageParam), + Promise.resolve('data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, })) + + if (query.isPending()) { + expectTypeOf(query.data).toEqualTypeOf>() + } }) - if (query.isSuccess()) { - const data = query.data() - expectTypeOf(data).toEqualTypeOf>() - } + test('error should be defined when query is error', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + })) + + if (query.isError()) { + expectTypeOf(query.error).toEqualTypeOf>() + } + }) + }) + + it('should provide the correct types to the select function', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + select: (data) => { + expectTypeOf(data).toEqualTypeOf>() + return data + }, + })) + + expectTypeOf(query.data).toEqualTypeOf< + Signal | Signal> + >() }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 7873d5261c..f587f6a2fe 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -1,9 +1,9 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { ChangeDetectionStrategy, Component, Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' -import { expectSignals } from './test-utils' +import { QueryClient, injectInfiniteQuery } from '..' +import { expectSignals, setupTanStackQueryTestBed } from './test-utils' describe('injectInfiniteQuery', () => { let queryClient: QueryClient @@ -11,12 +11,7 @@ describe('injectInfiniteQuery', () => { beforeEach(() => { queryClient = new QueryClient() vi.useFakeTimers() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -24,15 +19,24 @@ describe('injectInfiniteQuery', () => { }) test('should properly execute infinite query', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectInfiniteQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectInfiniteQuery(() => ({ queryKey: ['infiniteQuery'], queryFn: ({ pageParam }) => sleep(10).then(() => 'data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expectSignals(query, { data: undefined, @@ -76,6 +80,9 @@ describe('injectInfiniteQuery', () => { }) test('can be used outside injection context when passing an injector', () => { + const injector = TestBed.inject(Injector) + + // Call injectInfiniteQuery directly outside any component const query = injectInfiniteQuery( () => ({ queryKey: ['manualInjector'], @@ -85,10 +92,12 @@ describe('injectInfiniteQuery', () => { getNextPageParam: () => 12, }), { - injector: TestBed.inject(Injector), + injector: injector, }, ) + TestBed.tick() + expect(query.status()).toBe('pending') }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts index 329ef6d9e3..a7461dbc26 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts @@ -1,13 +1,9 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { - QueryClient, - injectIsFetching, - injectQuery, - provideTanStackQuery, -} from '..' +import { QueryClient, injectIsFetching, injectQuery } from '..' +import { setupTanStackQueryTestBed } from './test-utils' describe('injectIsFetching', () => { let queryClient: QueryClient @@ -16,12 +12,7 @@ describe('injectIsFetching', () => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts index 5a4694cb85..6d30b988f4 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts @@ -1,13 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { TestBed } from '@angular/core/testing' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { - QueryClient, - injectIsMutating, - injectMutation, - provideTanStackQuery, -} from '..' +import { QueryClient, injectIsMutating, injectMutation } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('injectIsMutating', () => { let queryClient: QueryClient @@ -16,12 +12,7 @@ describe('injectIsMutating', () => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -44,7 +35,7 @@ describe('injectIsMutating', () => { }) expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(11) expect(isMutating()).toBe(0) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts index 8b747f66f6..e8c86c068f 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -1,4 +1,5 @@ import { + ChangeDetectionStrategy, Component, Injector, input, @@ -7,7 +8,6 @@ import { } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { By } from '@angular/platform-browser' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, @@ -15,7 +15,7 @@ import { injectMutationState, provideTanStackQuery, } from '..' -import { setFixtureSignalInputs } from './test-utils' +import { registerSignalInput } from './test-utils' describe('injectMutationState', () => { let queryClient: QueryClient @@ -145,6 +145,7 @@ describe('injectMutationState', () => { {{ mutation.status }} } `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -157,23 +158,35 @@ describe('injectMutationState', () => { })) } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: fakeName }) + registerSignalInput(FakeComponent, 'name') + + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal(fakeName) + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() await vi.advanceTimersByTimeAsync(0) - let spans = debugElement - .queryAll(By.css('span')) - .map((span) => span.nativeNode.textContent) + const readSpans = () => + Array.from( + fixture.nativeElement.querySelectorAll( + 'span', + ) as NodeListOf, + ).map((span) => span.textContent) + + let spans = readSpans() expect(spans).toEqual(['pending', 'pending']) await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - spans = debugElement - .queryAll(By.css('span')) - .map((span) => span.nativeNode.textContent) + spans = readSpans() expect(spans).toEqual(['success', 'error']) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index 2adf0ee808..366f8a4e7d 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -1,5 +1,6 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, Injector, input, @@ -8,10 +9,10 @@ import { } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { By } from '@angular/platform-browser' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, injectMutation, provideTanStackQuery } from '..' -import { expectSignals, setFixtureSignalInputs } from './test-utils' +import { expectSignals, registerSignalInput } from './test-utils' +import { firstValueFrom } from 'rxjs' describe('injectMutation', () => { let queryClient: QueryClient @@ -307,6 +308,7 @@ describe('injectMutation', () => { {{ mutation.data() }} `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -321,19 +323,32 @@ describe('injectMutation', () => { } } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: 'value' }) + registerSignalInput(FakeComponent, 'name') - const button = debugElement.query(By.css('button')) - button.triggerEventHandler('click') + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal('value') + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + + const hostButton = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + hostButton.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - const text = debugElement.query(By.css('span')).nativeElement.textContent - expect(text).toEqual('value') - const mutation = mutationCache.find({ mutationKey: ['fake', 'value'] }) + const span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('value') + const mutation = mutationCache.find({ + mutationKey: ['fake', 'value'], + }) expect(mutation).toBeDefined() expect(mutation!.options.mutationKey).toStrictEqual(['fake', 'value']) }) @@ -347,6 +362,7 @@ describe('injectMutation', () => { {{ mutation.data() }} `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -361,26 +377,43 @@ describe('injectMutation', () => { } } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: 'value' }) + registerSignalInput(FakeComponent, 'name') - const button = debugElement.query(By.css('button')) - const span = debugElement.query(By.css('span')) + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal('value') - button.triggerEventHandler('click') + updateName(value: string): void { + this.name.set(value) + } + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + + let button = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + button.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - expect(span.nativeElement.textContent).toEqual('value') + let span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('value') - setFixtureSignalInputs(fixture, { name: 'updatedValue' }) + fixture.componentInstance.updateName('updatedValue') + fixture.detectChanges() - button.triggerEventHandler('click') + button = fixture.nativeElement.querySelector('button') as HTMLButtonElement + button.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - expect(span.nativeElement.textContent).toEqual('updatedValue') + span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('updatedValue') const mutations = mutationCache.findAll() expect(mutations.length).toBe(2) @@ -625,12 +658,13 @@ describe('injectMutation', () => { const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationFn: async (data: string) => `final: ${data}`, // Synchronous resolution + mutationFn: async (data: string) => { + await sleep(50) + return `final: ${data}` + }, onMutate: async (variables) => { onMutateCalled = true - const previousData = queryClient.getQueryData(testQueryKey) queryClient.setQueryData(testQueryKey, `optimistic: ${variables}`) - return { previousData } }, onSuccess: (data) => { onSuccessCalled = true @@ -639,19 +673,30 @@ describe('injectMutation', () => { })), ) + // Run effects + TestBed.tick() + // Start mutation + expect(queryClient.getQueryData(testQueryKey)).toBe('initial') mutation.mutate('test') - // Synchronize pending effects - TestBed.tick() - - const stablePromise = app.whenStable() // Flush microtasks to allow TanStack Query's scheduled notifications to process await Promise.resolve() - await vi.advanceTimersByTimeAsync(1) - await stablePromise + // Check for optimistic update in the same macrotask expect(onMutateCalled).toBe(true) + expect(queryClient.getQueryData(testQueryKey)).toBe('optimistic: test') + + // Check stability before the mutation completes, waiting got the next macrotask task + await vi.advanceTimersByTimeAsync(0) + expect(mutation.isPending()).toBe(true) + expect(await firstValueFrom(app.isStable)).toBe(false) + + // Wait for the mutation to complete + const stablePromise = app.whenStable() + await vi.advanceTimersByTimeAsync(60) + await stablePromise + expect(onSuccessCalled).toBe(true) expect(mutation.isSuccess()).toBe(true) expect(mutation.data()).toBe('final: test') diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts index 62547fd9e0..378ff1a334 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts @@ -175,3 +175,88 @@ describe('InjectQueries config object overload', () => { >() }) }) + +describe('InjectQueries combine', () => { + it('should provide the correct type for the combine function', () => { + injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + }, + { + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }, + ], + combine: (results) => { + expectTypeOf(results[0].data).toEqualTypeOf() + expectTypeOf(results[0].refetch).toBeCallableWith() + expectTypeOf(results[1].data).toEqualTypeOf() + expectTypeOf(results[1].refetch).toBeCallableWith() + }, + })) + }) + + it('should provide the correct types on the combined result with initial data', () => { + injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + initialData: 1, + }, + ], + combine: (results) => { + expectTypeOf(results[0].data).toEqualTypeOf() + expectTypeOf(results[0].refetch).toBeCallableWith() + }, + })) + }) + + it('should provide the correct result type', () => { + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + }, + { + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }, + ], + combine: (results) => ({ + data: { + 1: results[0].data, + 2: results[1].data, + }, + fn: () => {}, + }), + })) + + expectTypeOf(queryResults).branded.toEqualTypeOf< + Signal<{ + data: { + 1: number | undefined + 2: boolean | undefined + } + fn: () => void + }> + >() + }) + + it('should provide the correct types on the result with initial data', () => { + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + initialData: 1, + }, + ], + })) + + expectTypeOf(queryResults()[0].data()).toEqualTypeOf() + }) +}) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index 3fb3d5a626..4302252bdb 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,25 +1,27 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render } from '@testing-library/angular' import { + ChangeDetectionStrategy, Component, + computed, effect, - provideZonelessChangeDetection, + signal, } from '@angular/core' -import { TestBed } from '@angular/core/testing' import { queryKey } from '@tanstack/query-test-utils' -import { QueryClient, provideTanStackQuery } from '..' +import { QueryClient } from '..' import { injectQueries } from '../inject-queries' +import { setupTanStackQueryTestBed } from './test-utils' let queryClient: QueryClient beforeEach(() => { queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + vi.useFakeTimers({ shouldAdvanceTime: true }) + setupTanStackQueryTestBed(queryClient) +}) + +afterEach(() => { + vi.useRealTimers() }) describe('injectQueries', () => { @@ -32,17 +34,18 @@ describe('injectQueries', () => { template: `
- data1: {{ result()[0].data() ?? 'null' }}, data2: - {{ result()[1].data() ?? 'null' }} + data1: {{ queries()[0].data() ?? 'null' }}, data2: + {{ queries()[1].data() ?? 'null' }}
`, + changeDetection: ChangeDetectionStrategy.OnPush, }) class Page { toString(val: any) { return String(val) } - result = injectQueries(() => ({ + queries = injectQueries(() => ({ queries: [ { queryKey: key1, @@ -62,7 +65,7 @@ describe('injectQueries', () => { })) _pushResults = effect(() => { - const snapshot = this.result().map((q) => ({ data: q.data() })) + const snapshot = this.queries().map((q) => ({ data: q.data() })) results.push(snapshot) }) } @@ -76,4 +79,157 @@ describe('injectQueries', () => { expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) }) + + it('should support combining results', async () => { + const key1 = queryKey() + const key2 = queryKey() + let count = 0 + + const results: Array<{ data: string; refetch: () => void }> = [] + + @Component({ + template: `
data: {{ queries().data }}
`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + queries = injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + await new Promise((r) => setTimeout(r, 10)) + count++ + return count + }, + }, + { + queryKey: key2, + queryFn: async () => { + await new Promise((r) => setTimeout(r, 100)) + count++ + return count + }, + }, + ], + combine: (results) => { + return { + refetch: () => results.forEach((r) => r.refetch()), + data: results.map((r) => r.data).join(','), + } + }, + })) + + _pushResults = effect(() => { + results.push(this.queries()) + }) + } + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + await rendered.findByText('data: 1,2') + expect(instance.queries().data).toBe('1,2') + + instance.queries().refetch() + + await rendered.findByText('data: 3,4') + expect(instance.queries().data).toBe('3,4') + + expect(results).toHaveLength(5) + expect(results[0]).toMatchObject({ + data: ',', + refetch: expect.any(Function), + }) + expect(results[1]).toMatchObject({ + data: '1,', + refetch: expect.any(Function), + }) + expect(results[2]).toMatchObject({ + data: '1,2', + refetch: expect.any(Function), + }) + expect(results[3]).toMatchObject({ + data: '3,2', + refetch: expect.any(Function), + }) + expect(results[4]).toMatchObject({ + data: '3,4', + refetch: expect.any(Function), + }) + }) + + it('should support changes on the queries array', async () => { + const results: Array>> = [] + + @Component({ + template: `
data: {{ mapped() }}
`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + queries = injectQueries(() => ({ + queries: queries().map((q) => ({ + queryKey: ['query', q], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 20 * q)) + return q + }, + })), + })) + + mapped = computed(() => { + const results = this.queries().map((q) => q.data()) + if (results.length === 0) return 'empty' + return results.join(',') + }) + + _pushResults = effect(() => { + const snapshot = this.queries().map((q) => ({ data: q.data() })) + results.push(snapshot) + }) + } + + const queries = signal([1, 2, 4]) + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + + await rendered.findByText('data: 1,2,4') + expect(instance.mapped()).toBe('1,2,4') + + expect(results.length).toBe(4) + expect(results[0]).toMatchObject([ + { data: undefined }, + { data: undefined }, + { data: undefined }, + ]) + expect(results[1]).toMatchObject([ + { data: 1 }, + { data: undefined }, + { data: undefined }, + ]) + expect(results[2]).toMatchObject([ + { data: 1 }, + { data: 2 }, + { data: undefined }, + ]) + expect(results[3]).toMatchObject([{ data: 1 }, { data: 2 }, { data: 4 }]) + + queries.set([3, 4]) + await rendered.findByText('data: 3,4') + expect(instance.mapped()).toBe('3,4') + + // findByText causes another change detection cycle + expect(results.length).toBe(7) + expect(results[4]).toMatchObject([{ data: 1 }, { data: 2 }, { data: 4 }]) + expect(results[5]).toMatchObject([{ data: undefined }, { data: 4 }]) + expect(results[6]).toMatchObject([{ data: 3 }, { data: 4 }]) + + queries.set([]) + await rendered.findByText('data: empty') + expect(instance.mapped()).toBe('empty') + + // findByText causes another change detection cycle + expect(results.length).toBe(9) + expect(results[7]).toMatchObject([{ data: 3 }, { data: 4 }]) + expect(results[8]).toMatchObject([]) + }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts index 541ad65f14..353890bff4 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts @@ -33,6 +33,18 @@ describe('initialData', () => { expectTypeOf(data).toEqualTypeOf>() }) + it('should support selection function with select', () => { + const options = injectQuery(() => ({ + queryKey: ['key'], + queryFn: () => '1', + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return parseInt(data) + }, + })) + expectTypeOf(options.data).toEqualTypeOf>() + }) + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { const options = queryOptions({ queryKey: ['key'], diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 2f541788ab..5ad9472714 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -1,7 +1,9 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, Injector, + NgZone, computed, effect, input, @@ -26,7 +28,7 @@ import { import { queryKey, sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' import { QueryCache, QueryClient, injectQuery, provideTanStackQuery } from '..' -import { setSignalInputs } from './test-utils' +import { registerSignalInput } from './test-utils' import type { CreateQueryOptions, OmitKeyof, QueryFunction } from '..' describe('injectQuery', () => { @@ -50,102 +52,190 @@ describe('injectQuery', () => { test('should return the correct types', () => { const key = queryKey() - // unspecified query function should default to unknown - const noQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + // unspecified query function should default to unknown + noQueryFn = injectQuery(() => ({ queryKey: key, - })), - ) - expectTypeOf(noQueryFn.data()).toEqualTypeOf() - expectTypeOf(noQueryFn.error()).toEqualTypeOf() + })) - // it should infer the result type from the query function - const fromQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should infer the result type from the query function + fromQueryFn = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(fromQueryFn.data()).toEqualTypeOf() - expectTypeOf(fromQueryFn.error()).toEqualTypeOf() + })) - // it should be possible to specify the result type - const withResult = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify the result type + withResult = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(withResult.data()).toEqualTypeOf() - expectTypeOf(withResult.error()).toEqualTypeOf() + })) - // it should be possible to specify the error type - type CustomErrorType = { message: string } - const withError = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify the error type + withError = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(withError.data()).toEqualTypeOf() - expectTypeOf(withError.error()).toEqualTypeOf() + })) - // it should infer the result type from the configuration - const withResultInfer = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should infer the result type from the configuration + withResultInfer = injectQuery(() => ({ queryKey: key, queryFn: () => true, - })), - ) - expectTypeOf(withResultInfer.data()).toEqualTypeOf() - expectTypeOf(withResultInfer.error()).toEqualTypeOf() + })) - // it should be possible to specify a union type as result type - const unionTypeSync = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify a union type as result type + unionTypeSync = injectQuery(() => ({ queryKey: key, queryFn: () => (Math.random() > 0.5 ? ('a' as const) : ('b' as const)), - })), - ) - expectTypeOf(unionTypeSync.data()).toEqualTypeOf<'a' | 'b' | undefined>() - const unionTypeAsync = TestBed.runInInjectionContext(() => - injectQuery<'a' | 'b'>(() => ({ + })) + + unionTypeAsync = injectQuery<'a' | 'b'>(() => ({ queryKey: key, queryFn: () => Promise.resolve(Math.random() > 0.5 ? 'a' : 'b'), - })), - ) - expectTypeOf(unionTypeAsync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + })) - // it should error when the query function result does not match with the specified type - TestBed.runInInjectionContext(() => - // @ts-expect-error - injectQuery(() => ({ queryKey: key, queryFn: () => 'test' })), - ) + // it should infer the result type from a generic query function + fromGenericQueryFn = (() => { + function queryFn(): Promise { + return Promise.resolve({} as T) + } + return injectQuery(() => ({ + queryKey: key, + queryFn: () => queryFn(), + })) + })() - // it should infer the result type from a generic query function - /** - * - */ - function queryFn(): Promise { - return Promise.resolve({} as T) - } + // todo use query options? + fromGenericOptionsQueryFn = (() => { + function queryFn(): Promise { + return Promise.resolve({} as T) + } + return injectQuery(() => ({ + queryKey: key, + queryFn: () => queryFn(), + })) + })() + + fromMyDataArrayKeyQueryFn = (() => { + type MyData = number + type MyQueryKey = readonly ['my-data', number] + const getMyDataArrayKey: QueryFunction = ({ + queryKey: [, n], + }) => { + return n + 42 + } + return injectQuery(() => ({ + queryKey: ['my-data', 100] as const, + queryFn: getMyDataArrayKey, + })) + })() - const fromGenericQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should handle query-functions that return Promise + fromPromiseAnyQueryFn = injectQuery(() => ({ queryKey: key, - queryFn: () => queryFn(), - })), - ) + queryFn: () => fetch('return Promise').then((resp) => resp.json()), + })) + + fromGetMyDataStringKeyQueryFn = (() => { + type MyData = number + const getMyDataStringKey: QueryFunction = (context) => { + expectTypeOf(context.queryKey).toEqualTypeOf<['1']>() + return Number(context.queryKey[0]) + 42 + } + return injectQuery(() => ({ + queryKey: ['1'] as ['1'], + queryFn: getMyDataStringKey, + })) + })() + + // Wrapped queries + fromWrappedQuery = (() => { + const createWrappedQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: (obj: TQueryKey[1], token: string) => Promise, + options?: OmitKeyof< + CreateQueryOptions, + 'queryKey' | 'queryFn' | 'initialData', + 'safely' + >, + ) => + injectQuery(() => ({ + queryKey: qk, + queryFn: () => fetcher(qk[1], 'token'), + ...options, + })) + return createWrappedQuery([''], () => Promise.resolve('1')) + })() + + fromWrappedFuncStyleQuery = (() => { + const createWrappedFuncStyleQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: () => Promise, + options?: OmitKeyof< + CreateQueryOptions, + 'queryKey' | 'queryFn' | 'initialData', + 'safely' + >, + ) => injectQuery(() => ({ queryKey: qk, queryFn: fetcher, ...options })) + return createWrappedFuncStyleQuery([''], () => Promise.resolve(true)) + })() + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { + noQueryFn, + fromQueryFn, + withResult, + withError, + withResultInfer, + unionTypeSync, + unionTypeAsync, + fromGenericQueryFn, + fromGenericOptionsQueryFn, + fromMyDataArrayKeyQueryFn, + fromPromiseAnyQueryFn, + fromGetMyDataStringKeyQueryFn, + fromWrappedQuery, + fromWrappedFuncStyleQuery, + } = fixture.componentInstance + + expectTypeOf(noQueryFn.data()).toEqualTypeOf() + expectTypeOf(noQueryFn.error()).toEqualTypeOf() + + expectTypeOf(fromQueryFn.data()).toEqualTypeOf() + expectTypeOf(fromQueryFn.error()).toEqualTypeOf() + + expectTypeOf(withResult.data()).toEqualTypeOf() + expectTypeOf(withResult.error()).toEqualTypeOf() + + expectTypeOf(withError.data()).toEqualTypeOf() + expectTypeOf(withError.error()).toEqualTypeOf<{ message: string } | null>() + + expectTypeOf(withResultInfer.data()).toEqualTypeOf() + expectTypeOf(withResultInfer.error()).toEqualTypeOf() + + expectTypeOf(unionTypeSync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(unionTypeAsync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(fromGenericQueryFn.data()).toEqualTypeOf() expectTypeOf(fromGenericQueryFn.error()).toEqualTypeOf() - // todo use query options? - const fromGenericOptionsQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => queryFn(), - })), - ) expectTypeOf(fromGenericOptionsQueryFn.data()).toEqualTypeOf< string | undefined >() @@ -153,124 +243,38 @@ describe('injectQuery', () => { fromGenericOptionsQueryFn.error(), ).toEqualTypeOf() - type MyData = number - type MyQueryKey = readonly ['my-data', number] - - const getMyDataArrayKey: QueryFunction = ({ - queryKey: [, n], - }) => { - return n + 42 - } - - const fromMyDataArrayKeyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: ['my-data', 100] as const, - queryFn: getMyDataArrayKey, - })), - ) expectTypeOf(fromMyDataArrayKeyQueryFn.data()).toEqualTypeOf< number | undefined >() - // it should handle query-functions that return Promise - const fromPromiseAnyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => fetch('return Promise').then((resp) => resp.json()), - })), - ) expectTypeOf(fromPromiseAnyQueryFn.data()).toEqualTypeOf() - TestBed.runInInjectionContext(() => - effect(() => { - if (fromPromiseAnyQueryFn.isSuccess()) { - expect(fromMyDataArrayKeyQueryFn.data()).toBe(142) - } - }), - ) - - const getMyDataStringKey: QueryFunction = (context) => { - expectTypeOf(context.queryKey).toEqualTypeOf<['1']>() - return Number(context.queryKey[0]) + 42 - } - - const fromGetMyDataStringKeyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: ['1'] as ['1'], - queryFn: getMyDataStringKey, - })), - ) expectTypeOf(fromGetMyDataStringKeyQueryFn.data()).toEqualTypeOf< number | undefined >() - TestBed.runInInjectionContext(() => - effect(() => { - if (fromGetMyDataStringKeyQueryFn.isSuccess()) { - expect(fromGetMyDataStringKeyQueryFn.data()).toBe(43) - } - }), - ) - - // handles wrapped queries with custom fetcher passed as inline queryFn - const createWrappedQuery = < - TQueryKey extends [string, Record?], - TQueryFnData, - TError, - TData = TQueryFnData, - >( - qk: TQueryKey, - fetcher: ( - obj: TQueryKey[1], - token: string, - // return type must be wrapped with TQueryFnReturn - ) => Promise, - options?: OmitKeyof< - CreateQueryOptions, - 'queryKey' | 'queryFn' | 'initialData', - 'safely' - >, - ) => - injectQuery(() => ({ - queryKey: qk, - queryFn: () => fetcher(qk[1], 'token'), - ...options, - })) - const fromWrappedQuery = TestBed.runInInjectionContext(() => - createWrappedQuery([''], () => Promise.resolve('1')), - ) expectTypeOf(fromWrappedQuery.data()).toEqualTypeOf() - - // handles wrapped queries with custom fetcher passed directly to createQuery - const createWrappedFuncStyleQuery = < - TQueryKey extends [string, Record?], - TQueryFnData, - TError, - TData = TQueryFnData, - >( - qk: TQueryKey, - fetcher: () => Promise, - options?: OmitKeyof< - CreateQueryOptions, - 'queryKey' | 'queryFn' | 'initialData', - 'safely' - >, - ) => injectQuery(() => ({ queryKey: qk, queryFn: fetcher, ...options })) - const fromWrappedFuncStyleQuery = TestBed.runInInjectionContext(() => - createWrappedFuncStyleQuery([''], () => Promise.resolve(true)), - ) expectTypeOf(fromWrappedFuncStyleQuery.data()).toEqualTypeOf< boolean | undefined >() }) test('should return pending status initially', () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key1'], queryFn: () => sleep(10).then(() => 'Some data'), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') expect(query.isPending()).toBe(true) @@ -280,12 +284,21 @@ describe('injectQuery', () => { }) test('should resolve to success and update signal: injectQuery()', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key2'], queryFn: () => sleep(10).then(() => 'result2'), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(11) expect(query.status()).toBe('success') @@ -297,14 +310,23 @@ describe('injectQuery', () => { }) test('should reject and update signal', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ retry: false, queryKey: ['key3'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(11) expect(query.status()).toBe('error') @@ -321,12 +343,23 @@ describe('injectQuery', () => { const key = signal(['key6', 'key7']) const spy = vi.fn(() => sleep(10).then(() => 'Some data')) - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: key(), - queryFn: spy, - })) + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, }) + class TestComponent { + key = key + spy = spy + query = injectQuery(() => ({ + queryKey: this.key(), + queryFn: this.spy, + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(0) expect(spy).toHaveBeenCalledTimes(1) @@ -335,7 +368,7 @@ describe('injectQuery', () => { expect(query.status()).toBe('success') key.set(['key8']) - TestBed.tick() + fixture.detectChanges() expect(spy).toHaveBeenCalledTimes(2) // should call queryFn with context containing the new queryKey @@ -351,13 +384,24 @@ describe('injectQuery', () => { const spy = vi.fn(() => sleep(10).then(() => 'Some data')) const enabled = signal(false) - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + enabled = enabled + spy = spy + query = injectQuery(() => ({ queryKey: ['key9'], - queryFn: spy, - enabled: enabled(), + queryFn: this.spy, + enabled: this.enabled(), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(spy).not.toHaveBeenCalled() expect(query.status()).toBe('pending') @@ -370,26 +414,33 @@ describe('injectQuery', () => { }) test('should properly execute dependant queries', async () => { - const query1 = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: ['dependant1'], - queryFn: () => sleep(10).then(() => 'Some data'), - })) - }) - const dependentQueryFn = vi .fn() .mockImplementation(() => sleep(1000).then(() => 'Some data')) - const query2 = TestBed.runInInjectionContext(() => { - return injectQuery( + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query1 = injectQuery(() => ({ + queryKey: ['dependant1'], + queryFn: () => sleep(10).then(() => 'Some data'), + })) + + query2 = injectQuery( computed(() => ({ queryKey: ['dependant2'], queryFn: dependentQueryFn, - enabled: !!query1.data(), + enabled: !!this.query1.data(), })), ) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { query1, query2 } = fixture.componentInstance expect(query1.data()).toStrictEqual(undefined) expect(query2.fetchStatus()).toStrictEqual('idle') @@ -414,13 +465,24 @@ describe('injectQuery', () => { const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) const keySignal = signal('key11') - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: ['key10', keySignal()], - queryFn: fetchFn, + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + keySignal = keySignal + fetchFn = fetchFn + query = injectQuery(() => ({ + queryKey: ['key10', this.keySignal()], + queryFn: this.fetchFn, enabled: false, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(fetchFn).not.toHaveBeenCalled() @@ -436,6 +498,7 @@ describe('injectQuery', () => { await vi.advanceTimersByTimeAsync(11) keySignal.set('key12') + fixture.detectChanges() query.refetch().then(() => { expect(fetchFn).toHaveBeenCalledTimes(2) @@ -449,18 +512,58 @@ describe('injectQuery', () => { await vi.advanceTimersByTimeAsync(11) }) + test('should support selection function with select', async () => { + const app = TestBed.inject(ApplicationRef) + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ + queryKey: ['key13'], + queryFn: () => [{ id: 1 }, { id: 2 }], + select: (data) => data.map((item) => item.id), + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query + + // Wait for query to complete (even synchronous queryFn needs time to process) + const stablePromise = app.whenStable() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(10) + await stablePromise + + expect(query.status()).toBe('success') + expect(query.data()).toEqual([1, 2]) + }) + describe('throwOnError', () => { test('should evaluate throwOnError when query is expected to throw', async () => { const boundaryFn = vi.fn() - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + boundaryFn = boundaryFn + query = injectQuery(() => ({ queryKey: ['key12'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), retry: false, - throwOnError: boundaryFn, + throwOnError: this.boundaryFn, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() await vi.advanceTimersByTimeAsync(11) expect(boundaryFn).toHaveBeenCalledTimes(1) @@ -473,41 +576,112 @@ describe('injectQuery', () => { }) test('should throw when throwOnError is true', async () => { - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + const zone = TestBed.inject(NgZone) + const zoneErrorPromise = new Promise((resolve) => { + const sub = zone.onError.subscribe((error) => { + sub.unsubscribe() + resolve(error as Error) + }) + }) + let handler: ((error: Error) => void) | null = null + const processErrorPromise = new Promise((resolve) => { + handler = (error: Error) => { + process.off('uncaughtException', handler!) + resolve(error) + } + process.on('uncaughtException', handler) + }) + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key13'], queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: true, })) - }) + } + + TestBed.createComponent(TestComponent).detectChanges() - await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') + try { + await vi.runAllTimersAsync() + await expect(zoneErrorPromise).resolves.toEqual(Error('Some error')) + await expect(processErrorPromise).resolves.toEqual(Error('Some error')) + } finally { + if (handler) { + process.off('uncaughtException', handler) + } + } }) test('should throw when throwOnError function returns true', async () => { - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + const zone = TestBed.inject(NgZone) + const zoneErrorPromise = new Promise((resolve) => { + const sub = zone.onError.subscribe((error) => { + sub.unsubscribe() + resolve(error as Error) + }) + }) + let handler: ((error: Error) => void) | null = null + const processErrorPromise = new Promise((resolve) => { + handler = (error: Error) => { + process.off('uncaughtException', handler!) + resolve(error) + } + process.on('uncaughtException', handler) + }) + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key14'], queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: () => true, })) - }) + } + + TestBed.createComponent(TestComponent).detectChanges() - await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') + try { + await vi.runAllTimersAsync() + await expect(zoneErrorPromise).resolves.toEqual(Error('Some error')) + await expect(processErrorPromise).resolves.toEqual(Error('Some error')) + } finally { + if (handler) { + process.off('uncaughtException', handler) + } + } }) }) test('should set state to error when queryFn returns reject promise', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ retry: false, queryKey: ['key15'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') @@ -520,6 +694,7 @@ describe('injectQuery', () => { @Component({ selector: 'app-fake', template: `{{ query.data() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -530,17 +705,143 @@ describe('injectQuery', () => { })) } - const fixture = TestBed.createComponent(FakeComponent) - setSignalInputs(fixture.componentInstance, { - name: 'signal-input-required-test', + registerSignalInput(FakeComponent, 'name') + + @Component({ + template: ``, + imports: [FakeComponent], }) + class HostComponent { + protected readonly name = signal('signal-input-required-test') + } + const fixture = TestBed.createComponent(HostComponent) fixture.detectChanges() await vi.advanceTimersByTimeAsync(0) - expect(fixture.componentInstance.query.data()).toEqual( - 'signal-input-required-test', + const result = fixture.nativeElement.querySelector('app-fake').textContent + expect(result).toEqual('signal-input-required-test') + }) + + test('should allow reading the query data on effect registered before injection', () => { + const spy = vi.fn() + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + readEffect = effect(() => { + spy(this.query.data()) + }) + + query = injectQuery(() => ({ + queryKey: ['effect-before-injection'], + queryFn: () => sleep(0).then(() => 'Some data'), + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + expect(spy).toHaveBeenCalledWith(undefined) + }) + + test('should render with an initial value for input signal if available before change detection', () => { + const key1 = queryKey() + const key2 = queryKey() + + queryClient.setQueryData(key1, 'value 1') + queryClient.setQueryData(key2, 'value 2') + + @Component({ + selector: 'app-test', + template: '{{ query.data() }}', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + inputKey = input.required<[string]>() + query = injectQuery(() => ({ + queryKey: this.inputKey(), + queryFn: () => sleep(0).then(() => 'Some data'), + })) + } + registerSignalInput(TestComponent, 'inputKey') + + const fixture = TestBed.createComponent(TestComponent) + fixture.componentRef.setInput('inputKey', key1) + + const instance = fixture.componentInstance + const query = instance.query + + expect(() => instance.inputKey()).not.toThrow() + + expect(instance.inputKey()).toEqual(key1) + expect(query.data()).toEqual('value 1') + + fixture.componentRef.setInput('inputKey', key2) + + expect(instance.inputKey()).toEqual(key2) + expect(query.data()).toEqual('value 2') + }) + + test('should allow reading the query data on component ngOnInit with required signal input', async () => { + const spy = vi.fn() + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + key = input.required<[string]>() + query = injectQuery(() => ({ + queryKey: this.key(), + queryFn: () => Promise.resolve(() => 'Some data'), + })) + + initialStatus!: string + + ngOnInit() { + this.initialStatus = this.query.status() + + // effect should not have been called yet + expect(spy).not.toHaveBeenCalled() + } + + _spyEffect = effect(() => { + spy() + }) + } + + registerSignalInput(TestComponent, 'key') + + const fixture = TestBed.createComponent(TestComponent) + fixture.componentRef.setInput('key', ['ngOnInitTest']) + + fixture.detectChanges() + expect(spy).toHaveBeenCalled() + + const instance = fixture.componentInstance + expect(instance.initialStatus).toEqual('pending') + }) + + test('should update query data on the same macrotask when query data changes', async () => { + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: ['test'], + initialData: 'initial data', + })), ) + + // Run effects + TestBed.tick() + + expect(query.data()).toBe('initial data') + queryClient.setQueryData(['test'], 'new data') + + // Flush microtasks + await Promise.resolve() + + expect(query.data()).toBe('new data') }) describe('injection context', () => { @@ -554,15 +855,28 @@ describe('injectQuery', () => { }) test('can be used outside injection context when passing an injector', () => { - const query = injectQuery( - () => ({ - queryKey: ['manualInjector'], - queryFn: () => sleep(0).then(() => 'Some data'), - }), - { - injector: TestBed.inject(Injector), - }, - ) + const injector = TestBed.inject(Injector) + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery( + () => ({ + queryKey: ['manualInjector'], + queryFn: () => sleep(0).then(() => 'Some data'), + }), + { + injector: injector, + }, + ) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') }) @@ -570,22 +884,30 @@ describe('injectQuery', () => { test('should complete queries before whenStable() resolves', async () => { const app = TestBed.inject(ApplicationRef) - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['pendingTasksTest'], queryFn: async () => { await sleep(50) return 'test data' }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() - const stablePromise = app.whenStable() await vi.advanceTimersByTimeAsync(60) - await stablePromise + await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('test data') @@ -606,14 +928,25 @@ describe('injectQuery', () => { const httpClient = TestBed.inject(HttpClient) const httpTestingController = TestBed.inject(HttpTestingController) - // Create a query using HttpClient - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + httpClient = httpClient + query = injectQuery(() => ({ queryKey: ['httpClientTest'], queryFn: () => - lastValueFrom(httpClient.get<{ message: string }>('/api/test')), - })), - ) + lastValueFrom( + this.httpClient.get<{ message: string }>('/api/test'), + ), + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Schedule the HTTP response setTimeout(() => { @@ -625,9 +958,8 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') // Advance timers and wait for Angular to be "stable" - const stablePromise = app.whenStable() await vi.advanceTimersByTimeAsync(20) - await stablePromise + await app.whenStable() // Query should be complete after whenStable() thanks to PendingTasks integration expect(query.status()).toBe('success') @@ -646,28 +978,36 @@ describe('injectQuery', () => { }) const app = TestBed.inject(ApplicationRef) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: ['sync-stale'], staleTime: 1000, queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } - // Synchronize pending effects - TestBed.tick() + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query const stablePromise = app.whenStable() + await vi.advanceTimersToNextTimerAsync() await stablePromise expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) await query.refetch() await Promise.resolve() @@ -676,7 +1016,7 @@ describe('injectQuery', () => { expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-2') - expect(callCount).toBe(2) + expect(component.callCount).toBe(2) }) test('should handle enabled/disabled transitions with synchronous queryFn', async () => { @@ -690,34 +1030,48 @@ describe('injectQuery', () => { const app = TestBed.inject(ApplicationRef) const enabledSignal = signal(false) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + enabledSignal = enabledSignal + callCount = 0 + query = injectQuery(() => ({ queryKey: ['sync-enabled'], - enabled: enabledSignal(), + enabled: this.enabledSignal(), queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query // Initially disabled - TestBed.tick() + await vi.advanceTimersByTimeAsync(0) await app.whenStable() expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() - expect(callCount).toBe(0) + expect(component.callCount).toBe(0) // Enable the query enabledSignal.set(true) - TestBed.tick() + fixture.detectChanges() + + const stablePromise = app.whenStable() + await vi.advanceTimersToNextTimerAsync() + await stablePromise - await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) }) test('should handle query invalidation with synchronous data', async () => { @@ -731,39 +1085,47 @@ describe('injectQuery', () => { const app = TestBed.inject(ApplicationRef) const testKey = ['sync-invalidate'] - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: testKey, queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } - // Synchronize pending effects - TestBed.tick() + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query + + const stablePromise = app.whenStable() + await vi.advanceTimersToNextTimerAsync() + await stablePromise - await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) // Invalidate the query queryClient.invalidateQueries({ queryKey: testKey }) - TestBed.tick() // Wait for the invalidation to trigger a refetch await Promise.resolve() await vi.advanceTimersByTimeAsync(10) - TestBed.tick() await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-2') - expect(callCount).toBe(2) + expect(component.callCount).toBe(2) }) }) }) diff --git a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts index ab040037d5..553df12d36 100644 --- a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts +++ b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { provideZonelessChangeDetection } from '@angular/core' import { TestBed } from '@angular/core/testing' import { QueryClient } from '@tanstack/query-core' import { sleep } from '@tanstack/query-test-utils' @@ -8,8 +7,8 @@ import { injectMutation, injectMutationState, mutationOptions, - provideTanStackQuery, } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('mutationOptions', () => { let queryClient: QueryClient @@ -17,12 +16,7 @@ describe('mutationOptions', () => { beforeEach(() => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -61,7 +55,7 @@ describe('mutationOptions', () => { mutation.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -81,7 +75,7 @@ describe('mutationOptions', () => { mutation.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -109,7 +103,7 @@ describe('mutationOptions', () => { mutation1.mutate() mutation2.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(2) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -137,7 +131,7 @@ describe('mutationOptions', () => { mutation1.mutate() mutation2.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts new file mode 100644 index 0000000000..40c972bc6f --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts @@ -0,0 +1,72 @@ +import { + ChangeDetectionStrategy, + Component, + destroyPlatform, + provideZonelessChangeDetection, +} from '@angular/core' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { + provideServerRendering, + renderApplication, +} from '@angular/platform-server' +import { bootstrapApplication } from '@angular/platform-browser' + +import { sleep } from '@tanstack/query-test-utils' +import { QueryClient } from '@tanstack/query-core' +import { injectQuery } from '../inject-query' +import { provideTanStackQuery } from '../providers' + +describe('PendingTasks SSR', () => { + beforeEach(() => { + destroyPlatform() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + @Component({ + selector: 'app-root', + template: '{{ query.data() }}', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ + queryKey: ['ssr-test'], + queryFn: async () => { + await sleep(1000) + return 'data-fetched-on-ssr' + }, + })) + } + + test('should wait for stability of queries', async () => { + const htmlPromise = renderApplication( + () => + bootstrapApplication(TestComponent, { + providers: [ + provideServerRendering(), + provideZonelessChangeDetection(), + provideTanStackQuery( + new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }), + ), + ], + }), + { + url: '/', + document: + '', + }, + ) + + await vi.runAllTimersAsync() + const html = await htmlPromise + + expect(html).toContain('data-fetched-on-ssr') + }) +}) diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts index 92f70aed9f..5448bbbcac 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -1,7 +1,7 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, - provideZonelessChangeDetection, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { HttpClient, provideHttpClient } from '@angular/common/http' @@ -12,13 +12,8 @@ import { import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' -import { - QueryClient, - injectMutation, - injectQuery, - onlineManager, - provideTanStackQuery, -} from '..' +import { QueryClient, injectMutation, injectQuery, onlineManager } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('PendingTasks Integration', () => { let queryClient: QueryClient @@ -37,12 +32,7 @@ describe('PendingTasks Integration', () => { }, }) - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -55,12 +45,21 @@ describe('PendingTasks Integration', () => { test('should handle synchronous queryFn with whenStable()', async () => { const app = TestBed.inject(ApplicationRef) - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['sync'], queryFn: () => 'instant-data', // Resolves synchronously - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Should start as pending even with synchronous data expect(query.status()).toBe('pending') @@ -140,7 +139,6 @@ describe('PendingTasks Integration', () => { ) mutation.mutate() - TestBed.tick() const stablePromise = app.whenStable() @@ -183,18 +181,27 @@ describe('PendingTasks Integration', () => { test('should handle rapid refetches without task leaks', async () => { const app = TestBed.inject(ApplicationRef) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: ['rapid-refetch'], queryFn: async () => { - callCount++ + this.callCount++ await sleep(10) - return `data-${callCount}` + return `data-${this.callCount}` }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Trigger multiple rapid refetches query.refetch() @@ -209,6 +216,54 @@ describe('PendingTasks Integration', () => { expect(query.data()).toMatch(/^data-\d+$/) }) + test('should keep PendingTasks active when query starts offline (never reaches fetching)', async () => { + const app = TestBed.inject(ApplicationRef) + + onlineManager.setOnline(false) + + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: ['start-offline'], + networkMode: 'online', // Default: won't fetch while offline + queryFn: async () => { + await sleep(10) + return 'online-data' + }, + })), + ) + + // Allow query to initialize + await Promise.resolve() + await flushQueryUpdates() + + // Query should initialize directly to 'paused' (never goes through 'fetching') + expect(query.status()).toBe('pending') + expect(query.fetchStatus()).toBe('paused') + + const stablePromise = app.whenStable() + let stableResolved = false + void stablePromise.then(() => { + stableResolved = true + }) + + await Promise.resolve() + + // PendingTasks should block stability even though we never hit 'fetching' + expect(stableResolved).toBe(false) + + // Bring the app back online so the query can fetch + onlineManager.setOnline(true) + + await vi.advanceTimersByTimeAsync(20) + await Promise.resolve() + + await stablePromise + + expect(stableResolved).toBe(true) + expect(query.status()).toBe('success') + expect(query.data()).toBe('online-data') + }) + test('should keep PendingTasks active while query retry is paused offline', async () => { const app = TestBed.inject(ApplicationRef) let attempt = 0 @@ -230,7 +285,7 @@ describe('PendingTasks Integration', () => { ) // Allow the initial attempt to start and fail - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() await Promise.resolve() // Wait for the first attempt to complete and start retry delay @@ -279,6 +334,7 @@ describe('PendingTasks Integration', () => { describe('Component Destruction', () => { @Component({ template: '', + changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { query = injectQuery(() => ({ @@ -298,36 +354,42 @@ describe('PendingTasks Integration', () => { } test('should cleanup pending tasks when component with active query is destroyed', async () => { - const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() // Start the query expect(fixture.componentInstance.query.status()).toBe('pending') + expect(fixture.isStable()).toBe(false) // Destroy component while query is running fixture.destroy() // Angular should become stable even though component was destroyed - const stablePromise = app.whenStable() + const stablePromise = fixture.whenStable() await vi.advanceTimersByTimeAsync(150) - - await expect(stablePromise).resolves.toEqual(undefined) + await stablePromise + expect(fixture.isStable()).toBe(true) }) test('should cleanup pending tasks when component with active mutation is destroyed', async () => { - const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() fixture.componentInstance.mutation.mutate('test') + fixture.detectChanges() + expect(fixture.isStable()).toBe(false) // Destroy component while mutation is running fixture.destroy() + fixture.detectChanges() + expect(fixture.isStable()).toBe(true) // Angular should become stable even though component was destroyed - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(150) + const stablePromise = fixture.whenStable() + await vi.advanceTimersByTimeAsync(200) + await stablePromise - await expect(stablePromise).resolves.toEqual(undefined) + expect(fixture.isStable()).toBe(true) }) }) @@ -335,32 +397,37 @@ describe('PendingTasks Integration', () => { test('should handle multiple queries running simultaneously', async () => { const app = TestBed.inject(ApplicationRef) - const query1 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query1 = injectQuery(() => ({ queryKey: ['concurrent-1'], queryFn: async () => { await sleep(30) return 'data-1' }, - })), - ) + })) - const query2 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + query2 = injectQuery(() => ({ queryKey: ['concurrent-2'], queryFn: async () => { await sleep(50) return 'data-2' }, - })), - ) + })) - const query3 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + query3 = injectQuery(() => ({ queryKey: ['concurrent-3'], queryFn: () => 'instant-data', // Synchronous - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { query1, query2, query3 } = fixture.componentInstance // All queries should start expect(query1.status()).toBe('pending') @@ -469,14 +536,8 @@ describe('PendingTasks Integration', () => { describe('HttpClient Integration', () => { beforeEach(() => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - provideHttpClient(), - provideHttpClientTesting(), - ], + setupTanStackQueryTestBed(queryClient, { + providers: [provideHttpClient(), provideHttpClientTesting()], }) }) diff --git a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts index d06aef6723..1e039d33f4 100644 --- a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts +++ b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts @@ -1,10 +1,21 @@ -import { isSignal, signal } from '@angular/core' -import { describe, expect, test } from 'vitest' +import { + ChangeDetectionStrategy, + Component, + computed, + input, + isSignal, + provideZonelessChangeDetection, + signal, + untracked, +} from '@angular/core' +import { beforeEach, describe, expect, test } from 'vitest' +import { TestBed } from '@angular/core/testing' import { signalProxy } from '../signal-proxy' +import { registerSignalInput } from './test-utils' describe('signalProxy', () => { const inputSignal = signal({ fn: () => 'bar', baz: 'qux' }) - const proxy = signalProxy(inputSignal) + const proxy = signalProxy(inputSignal, ['fn']) test('should have computed fields', () => { expect(proxy.baz()).toEqual('qux') @@ -24,4 +35,63 @@ describe('signalProxy', () => { test('supports "Object.keys"', () => { expect(Object.keys(proxy)).toEqual(['fn', 'baz']) }) + + describe('in component fixture', () => { + @Component({ + selector: 'app-test', + template: '{{ proxy.baz() }}', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + number = input.required() + obj = computed(() => ({ + number: this.number(), + fn: () => untracked(this.number) + 1, + })) + proxy = signalProxy(this.obj, ['fn']) + shortNumber = this.proxy.number + shortFn = this.proxy.fn + } + registerSignalInput(TestComponent, 'number') + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()], + }) + }) + + test('should generate fixed fields after initial change detection run', () => { + const fixture = TestBed.createComponent(TestComponent) + const instance = fixture.componentInstance + + expect(() => instance.shortNumber).not.throw() + expect(() => instance.shortNumber()).toThrow() + + fixture.componentRef.setInput('number', 1) + fixture.detectChanges() + + expect(isSignal(instance.proxy.number)).toBe(true) + expect(instance.proxy.number()).toBe(1) + expect(instance.shortNumber).toBe(instance.proxy.number) + + expect(instance.proxy.fn()).toBe(2) + expect(isSignal(instance.proxy.fn)).toBe(false) + expect(instance.shortFn).toBe(instance.proxy.fn) + }) + + test('should reflect updates on the proxy', () => { + const fixture = TestBed.createComponent(TestComponent) + const instance = fixture.componentInstance + fixture.componentRef.setInput('number', 0) + fixture.detectChanges() + + expect(instance.shortNumber()).toBe(0) + expect(instance.shortFn()).toBe(1) + + fixture.componentRef.setInput('number', 1) + + expect(instance.shortNumber()).toBe(1) + expect(instance.shortFn()).toBe(2) + }) + }) }) diff --git a/packages/angular-query-experimental/src/__tests__/test-utils.ts b/packages/angular-query-experimental/src/__tests__/test-utils.ts index 218cdea5f6..39884df261 100644 --- a/packages/angular-query-experimental/src/__tests__/test-utils.ts +++ b/packages/angular-query-experimental/src/__tests__/test-utils.ts @@ -1,8 +1,18 @@ -import { isSignal, untracked } from '@angular/core' -import { SIGNAL, signalSetFn } from '@angular/core/primitives/signals' -import { expect } from 'vitest' -import type { InputSignal, Signal } from '@angular/core' -import type { ComponentFixture } from '@angular/core/testing' +import { + isSignal, + provideZonelessChangeDetection, + untracked, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { expect, vi } from 'vitest' +import { provideTanStackQuery } from '..' +import type { QueryClient } from '@tanstack/query-core' +import type { + EnvironmentProviders, + Provider, + Signal, + Type, +} from '@angular/core' // Evaluate all signals on an object and return the result function evaluateSignals>( @@ -35,43 +45,56 @@ export const expectSignals = >( expect(evaluateSignals(obj)).toMatchObject(expected) } -type ToSignalInputUpdatableMap = { - [K in keyof T as T[K] extends InputSignal - ? K - : never]: T[K] extends InputSignal ? Value : never +/** + * Reset Angular's TestBed and configure the standard TanStack Query providers for tests. + * Pass additional providers (including EnvironmentProviders) via the options argument. + */ +export function setupTanStackQueryTestBed( + queryClient: QueryClient, + options: { providers?: Array } = {}, +) { + TestBed.resetTestingModule() + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ...(options.providers ?? []), + ], + }) } -function componentHasSignalInputProperty( - component: object, - property: TProperty, -): component is { [key in TProperty]: InputSignal } { - return ( - component.hasOwnProperty(property) && (component as any)[property][SIGNAL] - ) +/** + * TanStack Query schedules notifyManager updates with setTimeout(0); when fake timers + * are enabled, advance them so PendingTasks sees the queued work. + */ +export async function flushQueryUpdates() { + await vi.advanceTimersByTimeAsync(0) } +const SIGNAL_BASED_INPUT_FLAG = 1 + /** - * Set required signal input value to component fixture - * @see https://github.com/angular/angular/issues/54013 + * Register a signal-based input on a test-only component/dir so Angular marks the + * `input.required()` member as bound before the initial change detection run. + * + * After migrating to Angular 21 we can use the CLI to compile and run Vitest tests + * and this helper should be obsolete. */ -export function setSignalInputs>( - component: T, - inputs: ToSignalInputUpdatableMap, +export function registerSignalInput( + type: Type, + inputName: keyof T & string, ) { - for (const inputKey in inputs) { - if (componentHasSignalInputProperty(component, inputKey)) { - signalSetFn(component[inputKey][SIGNAL], inputs[inputKey]) - } + const definition = (type as any).ɵcmp ?? (type as any).ɵdir + if (!definition) { + throw new Error(`Component ${type.name} is missing its definition`) } -} -export function setFixtureSignalInputs>( - componentFixture: ComponentFixture, - inputs: ToSignalInputUpdatableMap, - options: { detectChanges: boolean } = { detectChanges: true }, -) { - setSignalInputs(componentFixture.componentInstance, inputs) - if (options.detectChanges) { - componentFixture.detectChanges() + definition.inputs = { + ...(definition.inputs ?? {}), + [inputName]: [inputName, SIGNAL_BASED_INPUT_FLAG, null], + } + definition.declaredInputs = { + ...(definition.declaredInputs ?? {}), + [inputName]: inputName, } } diff --git a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts index 7091f5a0c2..8b1eb454ce 100644 --- a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts +++ b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts @@ -13,6 +13,7 @@ import { } from '@angular/core' import { provideTanStackQuery } from '../providers' import { withDevtools } from '../devtools' +import { flushQueryUpdates } from './test-utils' import type { DevtoolsButtonPosition, DevtoolsErrorType, @@ -134,7 +135,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() TestBed.tick() @@ -166,7 +167,7 @@ describe('withDevtools feature', () => { TestBed.inject(ENVIRONMENT_INITIALIZER) // Destroys injector TestBed.resetTestingModule() - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() await vi.dynamicImportSettled() expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() @@ -186,7 +187,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) @@ -202,7 +203,7 @@ describe('withDevtools feature', () => { ) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) }) @@ -247,7 +248,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -287,7 +288,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -319,7 +320,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -350,7 +351,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -383,7 +384,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -413,7 +414,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockDevtoolsInstance.mount).toHaveBeenCalledTimes(1) expect(mockDevtoolsInstance.unmount).toHaveBeenCalledTimes(0) @@ -439,7 +440,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() @@ -469,7 +470,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() expect(mockDevtoolsInstance.mount).not.toHaveBeenCalled() @@ -526,7 +527,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith(mockService1, mockService2) }) @@ -547,7 +548,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith() }) @@ -577,7 +578,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() const service = TestBed.inject(ReactiveService) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4daede7684..697fe7ae2b 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,10 +1,11 @@ import { + DestroyRef, NgZone, - VERSION, + PendingTasks, computed, effect, inject, - signal, + linkedSignal, untracked, } from '@angular/core' import { @@ -14,9 +15,9 @@ import { } from '@tanstack/query-core' 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 { MethodKeys } from './signal-proxy' import type { + DefaultedQueryObserverOptions, QueryKey, QueryObserver, QueryObserverResult, @@ -27,6 +28,7 @@ import type { CreateBaseQueryOptions } from './types' * Base implementation for `injectQuery` and `injectInfiniteQuery`. * @param optionsFn * @param Observer + * @param excludeFunctions */ export function createBaseQuery< TQueryFnData, @@ -43,11 +45,29 @@ export function createBaseQuery< TQueryKey >, Observer: typeof QueryObserver, + excludeFunctions: ReadonlyArray, ) { const ngZone = inject(NgZone) - const pendingTasks = inject(PENDING_TASKS) + const pendingTasks = inject(PendingTasks) const queryClient = inject(QueryClient) const isRestoring = injectIsRestoring() + const destroyRef = inject(DestroyRef) + + let destroyed = false + let taskCleanupRef: (() => void) | null = null + + const startPendingTask = () => { + if (!taskCleanupRef && !destroyed) { + taskCleanupRef = pendingTasks.add() + } + } + + const stopPendingTask = () => { + if (taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null + } + } /** * Signal that has the default options from query client applied @@ -63,113 +83,118 @@ export function createBaseQuery< return defaultedOptions }) - const observerSignal = (() => { - let instance: QueryObserver< + // Computed without deps to lazy initialize the observer + const observerSignal = computed(() => { + return new Observer(queryClient, untracked(defaultedOptionsSignal)) + }) + + effect(() => { + observerSignal().setOptions(defaultedOptionsSignal()) + }) + + const trackObserverResult = ( + result: QueryObserverResult, + notifyOnChangeProps?: DefaultedQueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey - > | null = null + >['notifyOnChangeProps'], + ) => { + const observer = untracked(observerSignal) + const trackedResult = observer.trackResult(result) + + if (!notifyOnChangeProps) { + autoTrackResultProperties(trackedResult) + } + + return trackedResult + } + + const autoTrackResultProperties = ( + result: QueryObserverResult, + ) => { + for (const key of Object.keys(result) as Array< + keyof QueryObserverResult + >) { + if (key === 'promise') continue + const value = result[key] + if (typeof value === 'function') continue + // Access value once so QueryObserver knows this prop is tracked. + void value + } + } + + const subscribeToObserver = () => { + const observer = untracked(observerSignal) + const initialState = observer.getCurrentResult() + if (initialState.fetchStatus !== 'idle') { + startPendingTask() + } + + return observer.subscribe((state) => { + if (state.fetchStatus !== 'idle') { + startPendingTask() + } else { + stopPendingTask() + } - return computed(() => { - return (instance ||= new Observer(queryClient, defaultedOptionsSignal())) + queueMicrotask(() => { + if (destroyed) return + notifyManager.batch(() => { + ngZone.run(() => { + if ( + state.isError && + !state.isFetching && + shouldThrowError(observer.options.throwOnError, [ + state.error, + observer.getCurrentQuery(), + ]) + ) { + ngZone.onError.emit(state.error) + throw state.error + } + const trackedState = trackObserverResult( + state, + observer.options.notifyOnChangeProps, + ) + resultSignal.set(trackedState) + }) + }) + }) }) - })() + } - const optimisticResultSignal = computed(() => - observerSignal().getOptimisticResult(defaultedOptionsSignal()), - ) - - const resultFromSubscriberSignal = signal | null>(null) - - effect( - (onCleanup) => { - const observer = observerSignal() + const resultSignal = linkedSignal({ + source: defaultedOptionsSignal, + computation: () => { + const observer = untracked(observerSignal) const defaultedOptions = defaultedOptionsSignal() - untracked(() => { - observer.setOptions(defaultedOptions) - }) - onCleanup(() => { - ngZone.run(() => resultFromSubscriberSignal.set(null)) - }) + const result = observer.getOptimisticResult(defaultedOptions) + return trackObserverResult(result, defaultedOptions.notifyOnChangeProps) }, - { - // Set allowSignalWrites to support Angular < v19 - // Set to undefined to avoid warning on newer versions - allowSignalWrites: VERSION.major < '19' || undefined, - }, - ) + }) effect((onCleanup) => { - // observer.trackResult is not used as this optimization is not needed for Angular - const observer = observerSignal() - let pendingTaskRef: PendingTaskRef | null = null - - const unsubscribe = isRestoring() - ? () => undefined - : untracked(() => - ngZone.runOutsideAngular(() => { - return observer.subscribe( - notifyManager.batchCalls((state) => { - ngZone.run(() => { - if (state.fetchStatus === 'fetching' && !pendingTaskRef) { - pendingTaskRef = pendingTasks.add() - } - - if (state.fetchStatus === 'idle' && pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } - - if ( - state.isError && - !state.isFetching && - shouldThrowError(observer.options.throwOnError, [ - state.error, - observer.getCurrentQuery(), - ]) - ) { - ngZone.onError.emit(state.error) - throw state.error - } - resultFromSubscriberSignal.set(state) - }) - }), - ) - }), - ) - + if (isRestoring()) { + return + } + const unsubscribe = untracked(() => subscribeToObserver()) onCleanup(() => { - if (pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } unsubscribe() + stopPendingTask() }) }) + destroyRef.onDestroy(() => { + destroyed = true + stopPendingTask() + }) + return signalProxy( - computed(() => { - const subscriberResult = resultFromSubscriberSignal() - const optimisticResult = optimisticResultSignal() - const result = subscriberResult ?? optimisticResult - - // 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, - } - }), + resultSignal.asReadonly(), + excludeFunctions as Array>>, ) } diff --git a/packages/angular-query-experimental/src/inject-infinite-query.ts b/packages/angular-query-experimental/src/inject-infinite-query.ts index ee6de03240..aa4a26cf4f 100644 --- a/packages/angular-query-experimental/src/inject-infinite-query.ts +++ b/packages/angular-query-experimental/src/inject-infinite-query.ts @@ -6,9 +6,11 @@ import { runInInjectionContext, } from '@angular/core' import { createBaseQuery } from './create-base-query' +import type { MethodKeys } from './signal-proxy' import type { DefaultError, InfiniteData, + InfiniteQueryObserverResult, QueryKey, QueryObserver, } from '@tanstack/query-core' @@ -110,8 +112,20 @@ export function injectInfiniteQuery< * @param options - Additional configuration. * @returns The infinite query result. */ -export function injectInfiniteQuery( - injectInfiniteQueryFn: () => CreateInfiniteQueryOptions, +export function injectInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + injectInfiniteQueryFn: () => CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, options?: InjectInfiniteQueryOptions, ) { !options?.injector && assertInInjectionContext(injectInfiniteQuery) @@ -120,6 +134,13 @@ export function injectInfiniteQuery( createBaseQuery( injectInfiniteQueryFn, InfiniteQueryObserver as typeof QueryObserver, + methodsToExclude, ), ) } + +const methodsToExclude: Array> = [ + 'fetchNextPage', + 'fetchPreviousPage', + 'refetch', +] diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 7eb605047f..4e08431548 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -1,6 +1,7 @@ import { Injector, NgZone, + PendingTasks, assertInInjectionContext, computed, effect, @@ -16,8 +17,6 @@ import { shouldThrowError, } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' -import { PENDING_TASKS } from './pending-tasks-compat' -import type { PendingTaskRef } from './pending-tasks-compat' import type { DefaultError, MutationObserverResult } from '@tanstack/query-core' import type { CreateMutateFunction, @@ -59,7 +58,7 @@ export function injectMutation< !options?.injector && assertInInjectionContext(injectMutation) const injector = options?.injector ?? inject(Injector) const ngZone = injector.get(NgZone) - const pendingTasks = injector.get(PENDING_TASKS) + const pendingTasks = injector.get(PendingTasks) const queryClient = injector.get(QueryClient) /** @@ -82,6 +81,22 @@ export function injectMutation< }) })() + let destroyed = false + let taskCleanupRef: (() => void) | null = null + + const startPendingTask = () => { + if (!taskCleanupRef && !destroyed) { + taskCleanupRef = pendingTasks.add() + } + } + + const stopPendingTask = () => { + if (taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null + } + } + const mutateFnSignal = computed< CreateMutateFunction >(() => { @@ -125,24 +140,19 @@ export function injectMutation< effect( (onCleanup) => { - // observer.trackResult is not used as this optimization is not needed for Angular const observer = observerSignal() - let pendingTaskRef: PendingTaskRef | null = null untracked(() => { const unsubscribe = ngZone.runOutsideAngular(() => observer.subscribe( notifyManager.batchCalls((state) => { ngZone.run(() => { - // Track pending task when mutation is pending - if (state.isPending && !pendingTaskRef) { - pendingTaskRef = pendingTasks.add() - } + if (destroyed) return - // Clear pending task when mutation is no longer pending - if (!state.isPending && pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null + if (state.isPending) { + startPendingTask() + } else { + stopPendingTask() } if ( @@ -159,11 +169,8 @@ export function injectMutation< ), ) onCleanup(() => { - // Clean up any pending task on destroy - if (pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } + destroyed = true + stopPendingTask() unsubscribe() }) }) @@ -186,10 +193,9 @@ export function injectMutation< } }) - return signalProxy(resultSignal) as CreateMutationResult< - TData, - TError, - TVariables, - TOnMutateResult - > + return signalProxy(resultSignal, [ + 'mutate', + 'mutateAsync', + 'reset', + ]) as CreateMutationResult } diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index 2f201799e7..77655d7765 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -19,12 +19,14 @@ import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' import type { DefaultError, + DefinedQueryObserverResult, OmitKeyof, QueriesObserverOptions, QueriesPlaceholderDataFunction, QueryFunction, QueryKey, QueryObserverOptions, + QueryObserverResult, ThrowOnError, } from '@tanstack/query-core' import type { @@ -90,39 +92,42 @@ type GetCreateQueryOptionsForCreateQueries = : // Fallback QueryObserverOptionsForCreateQueries -// A defined initialData setting should return a DefinedCreateQueryResult rather than CreateQueryResult -type GetDefinedOrUndefinedQueryResult = T extends { - initialData?: infer TInitialData -} - ? unknown extends TInitialData - ? CreateQueryResult - : TInitialData extends TData - ? DefinedCreateQueryResult - : TInitialData extends () => infer TInitialDataResult - ? unknown extends TInitialDataResult - ? CreateQueryResult - : TInitialDataResult extends TData - ? DefinedCreateQueryResult - : CreateQueryResult - : CreateQueryResult - : CreateQueryResult +// Generic wrapper that handles initialData logic for any result type pair +type GenericGetDefinedOrUndefinedQueryResult = + T extends { + initialData?: infer TInitialData + } + ? unknown extends TInitialData + ? TUndefined + : TInitialData extends TData + ? TDefined + : TInitialData extends () => infer TInitialDataResult + ? unknown extends TInitialDataResult + ? TUndefined + : TInitialDataResult extends TData + ? TDefined + : TUndefined + : TUndefined + : TUndefined -type GetCreateQueryResult = - // Part 1: responsible for mapping explicit type parameter to function result, if object +// Infer TData and TError from query options +// Shared type between the results with and without the combine function +type InferDataAndError = + // Part 1: explicit type parameter as object { queryFnData, error, data } T extends { queryFnData: any; error?: infer TError; data: infer TData } - ? GetDefinedOrUndefinedQueryResult + ? { data: TData; error: TError } : T extends { queryFnData: infer TQueryFnData; error?: infer TError } - ? GetDefinedOrUndefinedQueryResult + ? { data: TQueryFnData; error: TError } : T extends { data: infer TData; error?: infer TError } - ? GetDefinedOrUndefinedQueryResult - : // Part 2: responsible for mapping explicit type parameter to function result, if tuple + ? { data: TData; error: TError } + : // Part 2: explicit type parameter as tuple [TQueryFnData, TError, TData] T extends [any, infer TError, infer TData] - ? GetDefinedOrUndefinedQueryResult + ? { data: TData; error: TError } : T extends [infer TQueryFnData, infer TError] - ? GetDefinedOrUndefinedQueryResult + ? { data: TQueryFnData; error: TError } : T extends [infer TQueryFnData] - ? GetDefinedOrUndefinedQueryResult - : // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided + ? { data: TQueryFnData; error: unknown } + : // Part 3: infer from queryFn, select, throwOnError T extends { queryFn?: | QueryFunction @@ -130,13 +135,40 @@ type GetCreateQueryResult = select?: (data: any) => infer TData throwOnError?: ThrowOnError } - ? GetDefinedOrUndefinedQueryResult< - T, - unknown extends TData ? TQueryFnData : TData, - unknown extends TError ? DefaultError : TError - > + ? { + data: unknown extends TData ? TQueryFnData : TData + error: unknown extends TError ? DefaultError : TError + } : // Fallback - CreateQueryResult + { data: unknown; error: DefaultError } + +// Maps query options to Angular's signal-wrapped CreateQueryResult +type GetCreateQueryResult = GenericGetDefinedOrUndefinedQueryResult< + T, + InferDataAndError['data'], + CreateQueryResult< + InferDataAndError['data'], + InferDataAndError['error'] + >, + DefinedCreateQueryResult< + InferDataAndError['data'], + InferDataAndError['error'] + > +> + +// Maps query options to plain QueryObserverResult for combine function +type GetQueryObserverResult = GenericGetDefinedOrUndefinedQueryResult< + T, + InferDataAndError['data'], + QueryObserverResult< + InferDataAndError['data'], + InferDataAndError['error'] + >, + DefinedQueryObserverResult< + InferDataAndError['data'], + InferDataAndError['error'] + > +> /** * QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param @@ -201,6 +233,25 @@ export type QueriesResults< > : { [K in keyof T]: GetCreateQueryResult } +// Maps query options array to plain QueryObserverResult types for combine function +type RawQueriesResults< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetQueryObserverResult] + : T extends [infer Head, ...infer Tails] + ? RawQueriesResults< + [...Tails], + [...TResults, GetQueryObserverResult], + [...TDepth, 1] + > + : { [K in keyof T]: GetQueryObserverResult } + export interface InjectQueriesOptions< T extends Array, TCombinedResult = QueriesResults, @@ -210,7 +261,7 @@ export interface InjectQueriesOptions< | readonly [ ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, ] - combine?: (result: QueriesResults) => TCombinedResult + combine?: (result: RawQueriesResults) => TCombinedResult } /** @@ -322,8 +373,8 @@ export function injectQueries< return combine ? result - : (result as QueriesResults).map((query) => - signalProxy(signal(query)), + : (result as Array>).map((query) => + signalProxy(signal(query), ['refetch']), ) }) }) as unknown as Signal diff --git a/packages/angular-query-experimental/src/inject-query.ts b/packages/angular-query-experimental/src/inject-query.ts index 1dac0ab694..a1529ee35d 100644 --- a/packages/angular-query-experimental/src/inject-query.ts +++ b/packages/angular-query-experimental/src/inject-query.ts @@ -6,7 +6,12 @@ import { runInInjectionContext, } from '@angular/core' import { createBaseQuery } from './create-base-query' -import type { DefaultError, QueryKey } from '@tanstack/query-core' +import type { MethodKeys } from './signal-proxy' +import type { + DefaultError, + QueryKey, + QueryObserverResult, +} from '@tanstack/query-core' import type { CreateQueryOptions, CreateQueryResult, @@ -212,6 +217,7 @@ export function injectQuery< * ``` * @param injectQueryFn - A function that returns query options. * @param options - Additional configuration + * @param excludeFunctions - Array of function property names to exclude from signal conversion * @returns The query result. * @see https://tanstack.com/query/latest/docs/framework/angular/guides/queries */ @@ -221,6 +227,8 @@ export function injectQuery( ) { !options?.injector && assertInInjectionContext(injectQuery) return runInInjectionContext(options?.injector ?? inject(Injector), () => - createBaseQuery(injectQueryFn, QueryObserver), + createBaseQuery(injectQueryFn, QueryObserver, methodsToExclude), ) as unknown as CreateQueryResult } + +const methodsToExclude: Array> = ['refetch'] diff --git a/packages/angular-query-experimental/src/pending-tasks-compat.ts b/packages/angular-query-experimental/src/pending-tasks-compat.ts deleted file mode 100644 index e156996993..0000000000 --- a/packages/angular-query-experimental/src/pending-tasks-compat.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { InjectionToken, inject } from '@angular/core' -import * as ng from '@angular/core' -import { noop } from '@tanstack/query-core' - -type PendingTasksCompat = { add: () => PendingTaskRef } - -export type PendingTaskRef = () => void - -export const PENDING_TASKS = new InjectionToken( - 'PENDING_TASKS', - { - factory: (): PendingTasksCompat => { - // Access via Reflect so bundlers stay quiet when the token is absent (Angular < 19). - const token = Reflect.get(ng, 'PendingTasks') as unknown as - | Parameters[0] - | undefined - - const svc: PendingTasksCompat | null = token - ? (inject(token, { optional: true }) as PendingTasksCompat | null) - : null - - // Without PendingTasks we fall back to a stable no-op shim. - return { - add: svc ? () => svc.add() : () => noop, - } - }, - }, -) diff --git a/packages/angular-query-experimental/src/query-options.ts b/packages/angular-query-experimental/src/query-options.ts index 069472b903..780e14ce1b 100644 --- a/packages/angular-query-experimental/src/query-options.ts +++ b/packages/angular-query-experimental/src/query-options.ts @@ -4,7 +4,6 @@ import type { InitialDataFunction, NonUndefinedGuard, OmitKeyof, - QueryFunction, QueryKey, SkipToken, } from '@tanstack/query-core' @@ -42,14 +41,10 @@ export type DefinedInitialDataOptions< TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> = Omit< - CreateQueryOptions, - 'queryFn' -> & { +> = CreateQueryOptions & { initialData: | NonUndefinedGuard | (() => NonUndefinedGuard) - queryFn?: QueryFunction } /** diff --git a/packages/angular-query-experimental/src/signal-proxy.ts b/packages/angular-query-experimental/src/signal-proxy.ts index e2a9de345f..fa857dcda2 100644 --- a/packages/angular-query-experimental/src/signal-proxy.ts +++ b/packages/angular-query-experimental/src/signal-proxy.ts @@ -1,46 +1,61 @@ import { computed, untracked } from '@angular/core' import type { Signal } from '@angular/core' -export type MapToSignals = { - [K in keyof T]: T[K] extends Function ? T[K] : Signal +export type MethodKeys = { + [K in keyof T]: T[K] extends (...args: Array) => any ? K : never +}[keyof T] + +export type MapToSignals = never> = { + [K in keyof T]: K extends TExcludeFields ? T[K] : Signal } /** * Exposes fields of an object passed via an Angular `Signal` as `Computed` signals. * Functions on the object are passed through as-is. * @param inputSignal - `Signal` that must return an object. + * @param excludeFields - Array of function property names that should NOT be converted to signals. * @returns A proxy object with the same fields as the input object, but with each field wrapped in a `Computed` signal. */ -export function signalProxy>( - inputSignal: Signal, -) { - const internalState = {} as MapToSignals +export function signalProxy< + TInput extends Record, + const TExcludeFields extends ReadonlyArray> = [], +>(inputSignal: Signal, excludeFields: TExcludeFields) { + const internalState = {} as MapToSignals + const excludeFieldsArray = excludeFields as ReadonlyArray - return new Proxy>(internalState, { - get(target, prop) { - // first check if we have it in our internal state and return it - const computedField = target[prop] - if (computedField) return computedField + return new Proxy>( + internalState, + { + get(target, prop) { + // first check if we have it in our internal state and return it + const computedField = target[prop] + if (computedField) return computedField - // then, check if it's a function on the resultState and return it - const targetField = untracked(inputSignal)[prop] - if (typeof targetField === 'function') return targetField + // if it is an excluded function, return it without tracking + if (excludeFieldsArray.includes(prop as string)) { + const fn = (...args: Parameters) => + untracked(inputSignal)[prop](...args) + // @ts-expect-error + target[prop] = fn + return fn + } - // finally, create a computed field, store it and return it - // @ts-expect-error - return (target[prop] = computed(() => inputSignal()[prop])) - }, - has(_, prop) { - return !!untracked(inputSignal)[prop] - }, - ownKeys() { - return Reflect.ownKeys(untracked(inputSignal)) - }, - getOwnPropertyDescriptor() { - return { - enumerable: true, - configurable: true, - } + // otherwise, make a computed field + // @ts-expect-error + return (target[prop] = computed(() => inputSignal()[prop])) + }, + has(_, prop) { + return !!untracked(inputSignal)[prop] + }, + ownKeys() { + return Reflect.ownKeys(untracked(inputSignal)) + }, + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + } + }, }, - }) + ) } diff --git a/packages/angular-query-experimental/src/types.ts b/packages/angular-query-experimental/src/types.ts index d71bec248f..5c36a6bb27 100644 --- a/packages/angular-query-experimental/src/types.ts +++ b/packages/angular-query-experimental/src/types.ts @@ -16,31 +16,25 @@ import type { QueryObserverResult, } from '@tanstack/query-core' import type { Signal } from '@angular/core' -import type { MapToSignals } from './signal-proxy' +import type { MapToSignals, MethodKeys } from './signal-proxy' -export interface CreateBaseQueryOptions< +export type CreateBaseQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> extends QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey -> {} +> = QueryObserverOptions -export interface CreateQueryOptions< +export type CreateQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> extends OmitKeyof< +> = OmitKeyof< CreateBaseQueryOptions, 'suspense' -> {} +> type CreateStatusBasedQueryResult< TStatus extends QueryObserverResult['status'], @@ -94,7 +88,10 @@ export type CreateBaseQueryResult< TError = DefaultError, TState = QueryObserverResult, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + MethodKeys> + > export type CreateQueryResult< TData = unknown, @@ -106,13 +103,19 @@ export type DefinedCreateQueryResult< TError = DefaultError, TState = DefinedQueryObserverResult, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + MethodKeys> + > export type CreateInfiniteQueryResult< TData = unknown, TError = DefaultError, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + InfiniteQueryObserverResult, + MethodKeys> + > export type DefinedCreateInfiniteQueryResult< TData = unknown, @@ -121,7 +124,10 @@ export type DefinedCreateInfiniteQueryResult< TData, TError >, -> = MapToSignals +> = MapToSignals< + TDefinedInfiniteQueryObserver, + MethodKeys +> export interface CreateMutationOptions< TData = unknown, @@ -270,4 +276,7 @@ export type CreateMutationResult< TOnMutateResult >, > = BaseMutationNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + MethodKeys> + > diff --git a/packages/angular-query-persist-client/package.json b/packages/angular-query-persist-client/package.json index 44a436bc88..0bb74e9a29 100644 --- a/packages/angular-query-persist-client/package.json +++ b/packages/angular-query-persist-client/package.json @@ -20,11 +20,6 @@ "compile": "tsc --build", "test:eslint": "eslint --concurrency=auto ./src", "test:types": "npm-run-all --serial test:types:*", - "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js --build", - "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js --build", - "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js --build", - "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build", - "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build", "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build", "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56dd1ed45..77c5c03e5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,7 +165,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -205,7 +205,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -251,7 +251,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -294,7 +294,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -334,7 +334,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -377,7 +377,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -417,7 +417,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -460,7 +460,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -503,7 +503,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -546,7 +546,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -586,7 +586,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -1991,7 +1991,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -2263,6 +2263,9 @@ importers: '@angular/platform-browser': specifier: ^20.0.0 version: 20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-server': + specifier: ^20.0.0 + version: 20.3.15(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@20.0.0)(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) '@tanstack/query-test-utils': specifier: workspace:* version: link:../query-test-utils @@ -3011,6 +3014,16 @@ packages: '@angular/animations': optional: true + '@angular/platform-server@20.3.15': + resolution: {integrity: sha512-OB3/ztCREeZ0pe2P+43Nah9Xq2Y79fN6mbsOY1JwwYxkM8ZN1WkSP11xlHHwAcoquHP7uFPhXwJqgTHBqGqkcw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 20.3.15 + '@angular/compiler': 20.3.15 + '@angular/core': 20.3.15 + '@angular/platform-browser': 20.3.15 + rxjs: ^6.5.3 || ^7.4.0 + '@angular/router@20.0.0': resolution: {integrity: sha512-RQ7rU4NaZDSvvOfMZQmB50q7de+jrHYb+f0ExLKBvr80B1MK3oc9VvI2BzBkGfM4aGx71MMa0UizjOiT/31kqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -13136,6 +13149,7 @@ packages: react-native-vector-icons@10.1.0: resolution: {integrity: sha512-fdQjCHIdoXmRoTZ5gvN1FmT4sGLQ2wmQiNZHKJQUYnE2tkIwjGnxNch+6Nd4lHAACvMWO7LOzBNot2u/zlOmkw==} + deprecated: react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate hasBin: true react-native-web@0.19.13: @@ -15713,6 +15727,10 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xhr2@0.2.1: + resolution: {integrity: sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==} + engines: {node: '>= 6'} + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -15932,7 +15950,7 @@ snapshots: '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 - '@angular/build@20.0.0(8379d9408f101aa649c5ec9eae189324)': + '@angular/build@20.0.0(e7d71ef5d6f07f95ae57395d944dd75a)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2000.0(chokidar@4.0.3) @@ -15967,6 +15985,7 @@ snapshots: optionalDependencies: '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) '@angular/platform-browser': 20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-server': 20.3.15(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@20.0.0)(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) less: 4.3.0 lmdb: 3.3.0 postcss: 8.5.6 @@ -16059,6 +16078,16 @@ snapshots: optionalDependencies: '@angular/animations': 20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-server@20.3.15(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@20.0.0)(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': 20.0.0 + '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + rxjs: 7.8.2 + tslib: 2.8.1 + xhr2: 0.2.1 + '@angular/router@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': dependencies: '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) @@ -31254,6 +31283,8 @@ snapshots: xdg-basedir@5.1.0: {} + xhr2@0.2.1: {} + xml-name-validator@4.0.0: {} xml-name-validator@5.0.0: {}