diff --git a/README.md b/README.md index 834b778..d8e10ca 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,49 @@ export class TodosService { } ``` +For methods that require a `queryFn` parameter like +[ensureQueryData](https://tanstack.com/query/latest/docs/react/reference/QueryClient#queryclientensurequerydata), [fetchQuery](https://tanstack.com/query/latest/docs/react/reference/QueryClient#queryclientfetchquery), [prefetchQuery](), [fetchInfiniteQuery]() and [prefetchInfiniteQuery]() it's possible to use both Promises and Observables. + +```ts +import { injectQueryClient } from '@ngneat/query'; + +@Injectable({ providedIn: 'root' }) +export class TodosService { + #queryClient = injectQueryClient(); + + getQueryObservable() { + return { + queryKey: ['todos'] as const, + queryFn: () => { + return this.http.get( + 'https://jsonplaceholder.typicode.com/todos', + ); + } + }; + } + + getQueryPromise() { + return { + queryKey: ['todos'] as const, + queryFn: () => { + return lastValueFrom(this.http.get( + 'https://jsonplaceholder.typicode.com/todos', + )); + } + }; + } + + ensureQueryDataFromPromise(): Promise { + return this.#queryClient.ensureQueryData(this.getQueryFromPromise()); + } + + ensureQueryDataFromObservable(): Promise { + return this.#queryClient.ensureQueryData(this.getQueryFromObservable()); + } +} +``` + + > The function should run inside an injection context ### Query diff --git a/package-lock.json b/package-lock.json index a8f503e..d2806d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24554,7 +24554,7 @@ }, "query": { "name": "@ngneat/query", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.8", "license": "MIT", "dependencies": { "tslib": "^2.3.0" diff --git a/query/src/index.ts b/query/src/index.ts index 2f4e1d6..f71356b 100644 --- a/query/src/index.ts +++ b/query/src/index.ts @@ -1,18 +1,22 @@ -export { injectQueryClient, provideQueryClient } from './lib/query-client'; -export { injectQuery } from './lib/query'; -export { injectMutation } from './lib/mutation'; +export { injectInfiniteQuery } from './lib/infinite-query'; export { injectIsFetching } from './lib/is-fetching'; export { injectIsMutating } from './lib/is-mutating'; -export { injectInfiniteQuery } from './lib/infinite-query'; - +export { injectMutation } from './lib/mutation'; +export { injectQuery } from './lib/query'; export { - toPromise, - createSuccessObserverResult, - createPendingObserverResult, -} from './lib/utils'; -export * from './lib/operators'; + injectQueryClient, + provideQueryClient +} from './lib/query-client'; + export * from '@tanstack/query-core'; -export { ObservableQueryResult, SignalQueryResult } from './lib/types'; -export { queryOptions } from './lib/query-options'; +export * from './lib/operators'; export { provideQueryClientOptions } from './lib/query-client-options'; +export { queryOptions } from './lib/query-options'; export { intersectResults } from './lib/signals'; +export { ObservableQueryResult, SignalQueryResult } from './lib/types'; +export { + createPendingObserverResult, + createSuccessObserverResult, + toPromise +} from './lib/utils'; + diff --git a/query/src/lib/base-query.ts b/query/src/lib/base-query.ts index c0f15aa..a3b6cc7 100644 --- a/query/src/lib/base-query.ts +++ b/query/src/lib/base-query.ts @@ -1,3 +1,5 @@ +import { Injector, Signal, assertInInjectionContext } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { DefaultError, QueryClient, @@ -8,15 +10,8 @@ import { WithRequired, notifyManager, } from '@tanstack/query-core'; -import { Observable, isObservable, shareReplay } from 'rxjs'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { - Injector, - Signal, - assertInInjectionContext, - runInInjectionContext, -} from '@angular/core'; -import { toPromise } from './utils'; +import { Observable, shareReplay } from 'rxjs'; +import { normalizeOptions } from './query-options'; export type QueryFunctionWithObservable< T = unknown, @@ -36,8 +31,16 @@ interface _CreateBaseQueryOptions< TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, + TPageParam = never, > extends WithRequired< - QueryObserverOptions, + QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, 'queryKey' >, Options {} @@ -48,8 +51,16 @@ export type CreateBaseQueryOptions< TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, + TPageParam = never, > = Omit< - _CreateBaseQueryOptions, + _CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, 'queryFn' > & { queryFn: QueryFunctionWithObservable; @@ -177,47 +188,3 @@ export function createBaseQuery< }, }; } - -function normalizeOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey extends QueryKey, ->( - client: QueryClient, - options: QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - >, - injector: Injector, -) { - const defaultedOptions = client.defaultQueryOptions( - options as unknown as QueryObserverOptions, - ); - defaultedOptions._optimisticResults = 'optimistic'; - - const originalQueryFn = defaultedOptions.queryFn; - - if (originalQueryFn) { - defaultedOptions.queryFn = function (ctx: QueryFunctionContext) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const _this = this; - - return runInInjectionContext(injector, () => { - const value = originalQueryFn.call(_this, ctx); - - if (isObservable(value)) { - return toPromise({ source: value, signal: ctx.signal }); - } - - return value; - }); - }; - } - - return defaultedOptions; -} diff --git a/query/src/lib/infinite-query.ts b/query/src/lib/infinite-query.ts index 2c84e76..a1ba2d9 100644 --- a/query/src/lib/infinite-query.ts +++ b/query/src/lib/infinite-query.ts @@ -10,11 +10,11 @@ import { injectQueryClient } from './query-client'; import { DefaultError, InfiniteData, - QueryKey, - QueryObserver, InfiniteQueryObserver, InfiniteQueryObserverOptions, InfiniteQueryObserverResult, + QueryKey, + QueryObserver, WithRequired, } from '@tanstack/query-core'; import { @@ -44,7 +44,7 @@ interface _CreateInfiniteQueryOptions< >, Options {} -type CreateInfiniteQueryOptions< +export type CreateInfiniteQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, diff --git a/query/src/lib/query-client.ts b/query/src/lib/query-client.ts index def2ab4..3793cc5 100644 --- a/query/src/lib/query-client.ts +++ b/query/src/lib/query-client.ts @@ -3,17 +3,30 @@ import { inject, Injectable, InjectionToken, + Injector, OnDestroy, PLATFORM_ID, Provider, } from '@angular/core'; -import { QueryClient as _QueryClient } from '@tanstack/query-core'; +import { + QueryClient as _QueryClient, + DefaultError, + FetchInfiniteQueryOptions, + FetchQueryOptions, + InfiniteData, + InfiniteQueryObserverOptions, + QueryKey, + QueryObserverOptions, +} from '@tanstack/query-core'; +import { CreateBaseQueryOptions } from './base-query'; +import { CreateInfiniteQueryOptions } from './infinite-query'; import { QUERY_CLIENT_OPTIONS } from './query-client-options'; +import { normalizeOptions } from './query-options'; -const QueryClient = new InjectionToken<_QueryClient>('QueryClient', { +const QueryClientToken = new InjectionToken('QueryClient', { providedIn: 'root', factory() { - return new _QueryClient(inject(QUERY_CLIENT_OPTIONS)); + return new QueryClient(inject(QUERY_CLIENT_OPTIONS)); }, }); @@ -21,7 +34,7 @@ const QueryClient = new InjectionToken<_QueryClient>('QueryClient', { providedIn: 'root', }) class QueryClientMount implements OnDestroy { - instance = inject(QueryClient); + instance = inject(QueryClientToken); constructor() { this.instance.mount(); @@ -32,7 +45,7 @@ class QueryClientMount implements OnDestroy { } } -const QueryClientService = new InjectionToken<_QueryClient>( +const QueryClientService = new InjectionToken( 'QueryClientService', { providedIn: 'root', @@ -41,15 +54,15 @@ const QueryClientService = new InjectionToken<_QueryClient>( inject(QueryClientMount); } - return inject(QueryClient); + return inject(QueryClientToken); }, }, ); /** @public */ -export function provideQueryClient(queryClient: _QueryClient): Provider { +export function provideQueryClient(queryClient: QueryClient): Provider { return { - provide: QueryClient, + provide: QueryClientToken, useValue: queryClient, }; } @@ -58,3 +71,390 @@ export function provideQueryClient(queryClient: _QueryClient): Provider { export function injectQueryClient() { return inject(QueryClientService); } + +/** should be exported for @test */ +export class QueryClient extends _QueryClient { + #injector = inject(Injector); + + /** + * + * Asynchronous function that can be used to get an existing query's cached data. + * If the query does not exist, queryClient.fetchQuery will be called and its results + * returned. + * + * @example + * + * queryClient = injectQueryClient(); + * + * const data = await queryClient.ensureQueryData({ queryKey, queryFn }) + * + */ + override ensureQueryData< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + ): Promise; + override ensureQueryData< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: FetchQueryOptions, + ): Promise; + override ensureQueryData< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + ): Promise { + const defaultedOptions = normalizeOptions( + this, + options as QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + this.#injector, + ) as unknown as FetchQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + never + >; + return super.ensureQueryData(defaultedOptions); + } + + /** + * + * Asynchronous method that can be used to fetch and cache a query. + * It will either resolve with the data or throw with the error. + * Use the prefetchQuery method if you just want to fetch a query without + * needing the result. + * If the query exists and the data is not invalidated or older than the given + * staleTime, then the data from the cache will be returned. + * Otherwise it will try to fetch the latest data. + * + * @example + * + * queryClient = injectQueryClient(); + * + * const data = await queryClient.fetchQuery({ queryKey, queryFn }) + * + */ + override fetchQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + ): Promise; + override fetchQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: FetchQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + ): Promise; + override fetchQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + ): Promise { + const defaultedOptions = normalizeOptions( + this, + options as QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + this.#injector, + ) as unknown as FetchQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >; + return super.fetchQuery(defaultedOptions); + } + + /** + * + * Asynchronous method that can be used to prefetch a query before + * it is needed or rendered with useQuery and friends. + * The method works the same as fetchQuery except that it will not + * throw or return any data. + * + * @example + * + * queryClient = injectQueryClient(); + * + * await queryClient.prefetchQuery({ queryKey, queryFn }) + * + */ + override prefetchQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + ): Promise; + override prefetchQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: FetchQueryOptions, + ): Promise; + override prefetchQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + ): Promise { + const defaultedOptions = normalizeOptions( + this, + options as QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + this.#injector, + ) as unknown as FetchQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + never + >; + return super.prefetchQuery(defaultedOptions); + } + + /** + * + * Similar to fetchQuery but can be used to fetch and cache an infinite query + * + * @example + * + * queryClient = injectQueryClient(); + * + * const data = await queryClient.fetchInfiniteQuery({ queryKey, queryFn, initialPageParam, getPreviousPageParam, getNextPageParam }) }) + * + */ + override fetchInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + ): Promise>; + override fetchInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: FetchInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + ): Promise>; + override fetchInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + ): Promise> { + const defaultedOptions = normalizeOptions( + this, + options as InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TQueryFnData, + TQueryFnData, + TQueryKey, + TPageParam + >, + this.#injector, + ) as unknown as FetchInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >; + return super.fetchInfiniteQuery(defaultedOptions); + } + + /** + * + * Similar to prefetchQuery but can be used to prefetch and cache an infinite query. + * + * @example + * + * queryClient = injectQueryClient(); + * + * await queryClient.prefetchInfiniteQuery({ queryKey, queryFn, initialPageParam, getPreviousPageParam, getNextPageParam }) + * + */ + override prefetchInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + ): Promise; + override prefetchInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: FetchInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + ): Promise; + override prefetchInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, + ): Promise { + const defaultedOptions = normalizeOptions( + this, + options as InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TQueryFnData, + TQueryFnData, + TQueryKey, + TPageParam + >, + this.#injector, + ) as unknown as FetchInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >; + return super.prefetchInfiniteQuery(defaultedOptions); + } +} diff --git a/query/src/lib/query-options.ts b/query/src/lib/query-options.ts index 93bd58c..14f48c0 100644 --- a/query/src/lib/query-options.ts +++ b/query/src/lib/query-options.ts @@ -1,5 +1,16 @@ -import type { DataTag, DefaultError, QueryKey } from '@tanstack/query-core'; +import { Injector, runInInjectionContext } from '@angular/core'; +import type { + DataTag, + DefaultError, + QueryClient, + QueryFunctionContext, + QueryKey, + QueryObserverOptions, +} from '@tanstack/query-core'; +import { isObservable } from 'rxjs'; import { CreateBaseQueryOptions } from './base-query'; +import { CreateInfiniteQueryOptions } from './infinite-query'; +import { toPromise } from './utils'; export type UndefinedInitialDataOptions< TQueryFnData = unknown, @@ -45,7 +56,6 @@ export function queryOptions< ): UndefinedInitialDataOptions & { queryKey: DataTag; }; - export function queryOptions< TQueryFnData = unknown, TError = DefaultError, @@ -56,7 +66,77 @@ export function queryOptions< ): DefinedInitialDataOptions & { queryKey: DataTag; }; - +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +>( + options: CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam + >, +): CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TQueryFnData, + TQueryFnData, + TQueryKey, + TPageParam +> & { + queryKey: DataTag; +}; export function queryOptions(options: unknown) { return options; } + +export function normalizeOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, + TPageParam = never, +>( + client: QueryClient, + options: QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, + injector: Injector, +) { + const defaultedOptions = client.defaultQueryOptions( + options as unknown as QueryObserverOptions, + ); + defaultedOptions._optimisticResults = 'optimistic'; + + const originalQueryFn = defaultedOptions.queryFn; + + if (originalQueryFn) { + defaultedOptions.queryFn = function (ctx: QueryFunctionContext) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const _this = this; + + return runInInjectionContext(injector, () => { + const value = originalQueryFn.call(_this, ctx); + + if (isObservable(value)) { + return toPromise({ source: value, signal: ctx.signal }); + } + + return value; + }); + }; + } + + return defaultedOptions; +} diff --git a/query/src/tests/query-client.spec.ts b/query/src/tests/query-client.spec.ts new file mode 100644 index 0000000..197ebdd --- /dev/null +++ b/query/src/tests/query-client.spec.ts @@ -0,0 +1,137 @@ +import { TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; +import { InfiniteData } from '@tanstack/query-core'; +import { expectTypeOf } from 'expect-type'; +import { QueryClient, injectQueryClient } from '../lib/query-client'; +import { + Posts, + PostsService, + Todo, + TodosService, + getObservablePostsQuery, + getObservableTodosQuery, + getPromisePostsQuery, + getPromiseTodosQuery, +} from './test-helper'; + +describe('QueryClient', () => { + let queryClient: QueryClient; + let todoService: TodosService; + let postsService: PostsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TodosService, PostsService], + }); + + todoService = TestBed.inject(TodosService); + postsService = TestBed.inject(PostsService); + TestBed.runInInjectionContext(() => { + queryClient = injectQueryClient(); + }); + }); + + it('fetchQuery should work with observable queryFn', fakeAsync(() => { + queryClient.fetchQuery(getObservableTodosQuery()).then((v) => { + expectTypeOf(v).toEqualTypeOf(); + }); + + tick(1000); + flush(); + })); + it('fetchQuery should work with promise queryFn', fakeAsync(() => { + queryClient.fetchQuery(getPromiseTodosQuery()).then((v) => { + expectTypeOf(v).toEqualTypeOf(); + }); + + tick(1000); + flush(); + })); + + it('prefetchQuery should work with observable queryFn', fakeAsync(() => { + queryClient.prefetchQuery(getObservableTodosQuery()); + + tick(1000); + + const data = todoService.getCachedTodos(); + expectTypeOf(data).toEqualTypeOf(); + + flush(); + })); + it('prefetchQuery should work with promise queryFn', fakeAsync(() => { + queryClient.prefetchQuery(getPromiseTodosQuery()); + + tick(1000); + + const data = todoService.getCachedTodos(); + expectTypeOf(data).toEqualTypeOf(); + + flush(); + })); + + it('ensureQueryData should work with observable queryFn', fakeAsync(() => { + queryClient.ensureQueryData(getObservableTodosQuery()).then((v) => { + expectTypeOf(v).toEqualTypeOf(); + }); + + tick(1000); + flush(); + })); + it('ensureQueryData should work with promise queryFn', fakeAsync(() => { + queryClient.ensureQueryData(getPromiseTodosQuery()).then((v) => { + expectTypeOf(v).toEqualTypeOf(); + }); + + tick(1000); + flush(); + })); + + it('fetchInfiniteQuery should work with observable queryFn', fakeAsync(() => { + queryClient.fetchInfiniteQuery(getObservablePostsQuery()).then((v) => { + expectTypeOf(v).toEqualTypeOf>(); + }); + + tick(1000); + flush(); + })); + it('fetchInfiniteQuery should work with promise queryFn', fakeAsync(() => { + queryClient.fetchInfiniteQuery(getPromisePostsQuery()).then((v) => { + expectTypeOf(v).toEqualTypeOf>(); + }); + + tick(1000); + flush(); + })); + + it('prefetchInfiniteQuery should work with observable queryFn', fakeAsync(() => { + queryClient.prefetchInfiniteQuery(getObservablePostsQuery()); + + tick(1000); + + const data = postsService.getCachedPosts(); + expectTypeOf(data).toEqualTypeOf(); + + flush(); + })); + it('prefetchInfiniteQuery should work with promise queryFn', fakeAsync(() => { + queryClient.prefetchInfiniteQuery(getPromisePostsQuery()); + + tick(5000); + + const data = postsService.getCachedPosts(); + expectTypeOf(data).toEqualTypeOf(); + + flush(); + })); + + it('should be typed', () => { + expectTypeOf(todoService.getCachedTodos()).toEqualTypeOf< + Todo[] | undefined + >(); + }); + + it('should be typed', () => { + expectTypeOf(postsService.getCachedPosts()).toEqualTypeOf< + Posts | undefined + >(); + }); +}); diff --git a/query/src/tests/query.spec.ts b/query/src/tests/query.spec.ts index 3a44aaf..5a935cd 100644 --- a/query/src/tests/query.spec.ts +++ b/query/src/tests/query.spec.ts @@ -1,49 +1,7 @@ -import { - Injectable, - Injector, - effect, - runInInjectionContext, -} from '@angular/core'; -import { injectQuery } from '../lib/query'; -import { injectQueryClient } from '../lib/query-client'; -import { queryOptions } from '../lib/query-options'; +import { Injector, effect, runInInjectionContext } from '@angular/core'; +import { TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; import { expectTypeOf } from 'expect-type'; -import { map, timer } from 'rxjs'; -import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; - -interface Todo { - id: number; -} - -@Injectable({ providedIn: 'root' }) -class TodosService { - #client = injectQueryClient(); - #query = injectQuery(); - - #getTodosOptions = queryOptions({ - queryKey: ['todos'] as const, - queryFn: () => { - return timer(1000).pipe( - map(() => { - return [ - { - id: 1, - }, - { id: 2 }, - ] as Todo[]; - }), - ); - }, - }); - - getTodos() { - return this.#query(this.#getTodosOptions); - } - - getCachedTodos() { - return this.#client.getQueryData(this.#getTodosOptions.queryKey); - } -} +import { Todo, TodosService } from './test-helper'; describe('query', () => { let service: TodosService; @@ -97,8 +55,4 @@ describe('query', () => { expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledWith('success'); })); - - it('should be typed', () => { - expectTypeOf(service.getCachedTodos()).toEqualTypeOf(); - }); }); diff --git a/query/src/tests/test-helper.ts b/query/src/tests/test-helper.ts new file mode 100644 index 0000000..42da064 --- /dev/null +++ b/query/src/tests/test-helper.ts @@ -0,0 +1,130 @@ +import { Injectable } from '@angular/core'; +import { Observable, lastValueFrom, map, timer } from 'rxjs'; +import { injectInfiniteQuery } from '../lib/infinite-query'; +import { injectQuery } from '../lib/query'; +import { injectQueryClient } from '../lib/query-client'; +import { queryOptions } from '../lib/query-options'; + +export interface Todo { + id: number; +} + +export const getObservableTodosQuery = () => ({ + queryKey: ['todos'] as const, + queryFn: () => { + return timer(1000).pipe( + map(() => { + return [ + { + id: 1, + }, + { id: 2 }, + ] as Todo[]; + }), + ); + }, +}); +export const getPromiseTodosQuery = () => ({ + queryKey: ['todos'] as const, + queryFn: () => { + return lastValueFrom( + timer(1000).pipe( + map(() => { + return [ + { + id: 1, + }, + { id: 2 }, + ] as Todo[]; + }), + ), + ); + }, +}); + +@Injectable({ providedIn: 'root' }) +export class TodosService { + #client = injectQueryClient(); + #query = injectQuery(); + + #getTodosOptions = queryOptions(getObservableTodosQuery()); + + getTodos() { + return this.#query(this.#getTodosOptions); + } + + getCachedTodos() { + return this.#client.getQueryData(this.#getTodosOptions.queryKey); + } +} + +interface Post { + id: number; + name: string; +} + +export interface Posts { + data: Post[]; + nextId: number | null; + previousId: number | null; +} + +export const getObservablePostsQuery = () => ({ + queryKey: ['posts'], + queryFn: (x: { pageParam: number }) => { + return getProjects(x.pageParam); + }, + initialPageParam: 0, + getPreviousPageParam: (firstPage: Posts) => firstPage.previousId, + getNextPageParam: (lastPage: Posts) => lastPage.nextId, +}); + +export const getPromisePostsQuery = () => ({ + queryKey: ['posts'], + queryFn: (x: { pageParam: number }) => { + return lastValueFrom(getProjects(x.pageParam)); + }, + initialPageParam: 0, + getPreviousPageParam: (firstPage: Posts) => firstPage.previousId, + getNextPageParam: (lastPage: Posts) => lastPage.nextId, +}); + +@Injectable({ providedIn: 'root' }) +export class PostsService { + #query = injectInfiniteQuery(); + #client = injectQueryClient(); + + #postsQueryOptions = queryOptions(getObservablePostsQuery()); + + getPosts() { + return this.#query(this.#postsQueryOptions); + } + + getCachedPosts() { + return this.#client.getQueryData(this.#postsQueryOptions.queryKey); + } +} + +function getProjects(c: number) { + return new Observable((observer) => { + const cursor = c || 0; + const pageSize = 5; + + const data = Array(pageSize) + .fill(0) + .map((_, i) => { + return { + name: 'Post ' + (i + cursor) + ` (server time: ${Date.now()})`, + id: i + cursor, + }; + }); + + const nextId = cursor < 20 ? data[data.length - 1].id + 1 : null; + const previousId = cursor > -20 ? data[0].id - pageSize : null; + + setTimeout(() => { + observer.next({ data, nextId, previousId }); + observer.complete(); + }, 1000); + }); +} diff --git a/src/app/app.component.html b/src/app/app.component.html index fea5abf..a1a708f 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -64,6 +64,11 @@

@ngneat/query Playground

>Mutation +
  • + Prefetching +
  • diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dfb97c8..4905ee5 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,10 +1,12 @@ import { Route } from '@angular/router'; import { TodosPageComponent } from './basic-page/todos-page.component'; +import { DynamicPageComponent } from './dynamic-page/dynamic-page.component'; import { PostsPageComponent } from './infinite-scroll-page/posts-page.component'; -import { PaginationPageComponent } from './pagination-page/pagination-page.component'; -import { MutationPageComponent } from './mutation-page/mutation-page.component'; import { IntersectingPageComponent } from './intersecting-page/intersecting-page.component'; -import { DynamicPageComponent } from './dynamic-page/dynamic-page.component'; +import { MutationPageComponent } from './mutation-page/mutation-page.component'; +import { PaginationPageComponent } from './pagination-page/pagination-page.component'; +import { PrefetchPageComponent } from './prefetch-page/prefetch-page.component'; +import { resolveTodos } from './prefetch-page/resolve'; export const appRoutes: Route[] = [ { @@ -36,4 +38,9 @@ export const appRoutes: Route[] = [ path: 'mutation', component: MutationPageComponent, }, + { + path: 'prefetch', + component: PrefetchPageComponent, + resolve: { todos: resolveTodos }, + }, ]; diff --git a/src/app/prefetch-page/prefetch-page.component.html b/src/app/prefetch-page/prefetch-page.component.html new file mode 100644 index 0000000..139bd90 --- /dev/null +++ b/src/app/prefetch-page/prefetch-page.component.html @@ -0,0 +1,22 @@ +
    +

    Resolver Example

    +

    + In this example, the data is being prefetched on the route resolver and + being passed as input to the component. The results are then cached and + you'll see them instantaneously while they are also refetched invisibly in + the background on consequently navigations. +

    + +

    + To see the result changing, open the devtools in the + bottom-right corner, from there you can trigger a refetch of the + query, invalidate it or even trigger the loading and error state. Feel free + to play around with it. +

    + +
    + @for (t of todos.slice(0, 10); track t.id) { +

    {{ t.title }}

    + } +
    +
    diff --git a/src/app/prefetch-page/prefetch-page.component.ts b/src/app/prefetch-page/prefetch-page.component.ts new file mode 100644 index 0000000..13622d2 --- /dev/null +++ b/src/app/prefetch-page/prefetch-page.component.ts @@ -0,0 +1,15 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { Todo } from '../services/todos.service'; + +@Component({ + selector: 'query-prefetch-page', + standalone: true, + imports: [CommonModule], + templateUrl: './prefetch-page.component.html', + styles: [], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PrefetchPageComponent { + @Input() todos: Todo[] = []; +} diff --git a/src/app/prefetch-page/resolve.ts b/src/app/prefetch-page/resolve.ts new file mode 100644 index 0000000..0387f3f --- /dev/null +++ b/src/app/prefetch-page/resolve.ts @@ -0,0 +1,10 @@ +import { ResolveFn } from '@angular/router'; +import { injectQueryClient } from '@ngneat/query'; +import { Todo, getTodosQuery } from '../services/todos.service'; + +export const resolveTodos: ResolveFn = async (): Promise => { + const client = injectQueryClient(); + const query = getTodosQuery(); + + return await client.ensureQueryData(query); +}; diff --git a/src/app/services/todos.service.ts b/src/app/services/todos.service.ts index 16ebfe7..d8f7e58 100644 --- a/src/app/services/todos.service.ts +++ b/src/app/services/todos.service.ts @@ -7,22 +7,28 @@ import { queryOptions, } from '@ngneat/query'; -interface Todo { +export interface Todo { id: number; title: string; } -export function getTodos({ injector }: { injector: Injector }) { - const query = injectQuery({ injector }); - - return query({ +export function getTodosQuery() { + return { queryKey: ['getTodos'] as const, - injector, queryFn: () => { return inject(HttpClient).get( 'https://jsonplaceholder.typicode.com/todos', ); }, + }; +} + +export function getTodos({ injector }: { injector: Injector }) { + const query = injectQuery({ injector }); + + return query({ + ...getTodosQuery(), + injector, }); } @@ -33,14 +39,7 @@ export class TodosService { #mutation = injectMutation(); #http = inject(HttpClient); - #getTodosOptions = queryOptions({ - queryKey: ['todos'] as const, - queryFn: () => { - return this.#http.get( - 'https://jsonplaceholder.typicode.com/todos', - ); - }, - }); + #getTodosOptions = queryOptions(getTodosQuery()); getTodos() { return this.#query(this.#getTodosOptions);