diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index 4f4141919a..c2cecad917 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -54,12 +54,14 @@ "@angular/platform-browser": "^17.0.8", "@angular/platform-browser-dynamic": "^17.0.8", "ng-packagr": "^17.0.3", + "rxjs": "^7.8.1", "typescript": "5.2.2", "zone.js": "^0.14.2" }, "peerDependencies": { "@angular/core": "^17", - "@angular/common": "^17" + "@angular/common": "^17", + "rxjs": "^7" }, "module": "build/fesm2022/tanstack-angular-query-experimental.mjs", "types": "build/index.d.ts", 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 4f8bcdcad7..46b7a36f6a 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 @@ -1,8 +1,16 @@ import { describe, expectTypeOf } from 'vitest' +import { interval, map, take } from 'rxjs' import { injectQuery } from '../inject-query' import { simpleFetcher } from './test-utils' import type { Signal } from '@angular/core' +function simpleObservable() { + return interval(1000).pipe( + map((_, i) => `Some data ${i}`), + take(5), + ) +} + describe('Discriminated union return type', () => { test('data should be possibly undefined by default', () => { const query = injectQuery(() => ({ @@ -56,4 +64,24 @@ describe('Discriminated union return type', () => { expectTypeOf(query.error).toEqualTypeOf>() } }) + + test('data should be infered from a passed in observable', () => { + const query = injectQuery(() => ({ + queryKey: ['key'], + query$: () => simpleObservable(), + })) + + expectTypeOf(query.data).toEqualTypeOf>() + }) + + test('data should still be defined when query is successful', () => { + const query = injectQuery(() => ({ + queryKey: ['key'], + query$: () => simpleObservable(), + })) + + if (query.isSuccess()) { + expectTypeOf(query.data).toEqualTypeOf>() + } + }) }) 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 8d317452f4..d4f89a3569 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -2,6 +2,7 @@ import { computed, signal } from '@angular/core' import { TestBed, fakeAsync, flush, tick } from '@angular/core/testing' import { QueryClient } from '@tanstack/query-core' import { expect, vi } from 'vitest' +import { Subject } from 'rxjs' import { injectQuery } from '../inject-query' import { provideAngularQuery } from '../providers' import { @@ -215,4 +216,51 @@ describe('injectQuery', () => { expect(query.status()).toBe('error') })) + + test('should allow an observable to be passed as queryFn', fakeAsync(() => { + const subject$ = new Subject() + const query = TestBed.runInInjectionContext(() => { + return injectQuery(() => ({ + queryKey: ['key14'], + query$: () => subject$.asObservable(), + })) + }) + + expect(query.status()).toBe('pending') + + flush() + + subject$.next('Some data') + + flush() + expect(query.status()).toBe('success') + expect(query.data()).toBe('Some data') + })) + + test('should allow to error when the stream is emitting after the first time it emits', fakeAsync(() => { + const subject$ = new Subject() + const query = TestBed.runInInjectionContext(() => { + return injectQuery(() => ({ + queryKey: ['key14'], + query$: () => subject$.asObservable(), + })) + }) + + expect(query.status()).toBe('pending') + + flush() + + subject$.next('Some data') + + flush() + expect(query.status()).toBe('success') + expect(query.data()).toBe('Some data') + + subject$.error(new Error('Some error')) + + flush() + + expect(query.status()).toBe('error') + expect(query.error()).toMatchObject({ message: 'Some error' }) + })) }) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index c5a0e11839..b004a62f5d 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -7,9 +7,28 @@ import { signal, } from '@angular/core' import { notifyManager } from '@tanstack/query-core' +import { + EMPTY, + Subject, + catchError, + fromEvent, + lastValueFrom, + shareReplay, + skip, + switchMap, + take, + takeUntil, +} from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { signalProxy } from './signal-proxy' -import type { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core' +import type { + QueryClient, + QueryFunctionContext, + QueryKey, + QueryObserver, +} from '@tanstack/query-core' import type { CreateBaseQueryOptions, CreateBaseQueryResult } from './types' +import type { Subscription } from 'rxjs' /** * Base implementation for `injectQuery` and `injectInfiniteQuery`. @@ -20,6 +39,7 @@ export function createBaseQuery< TData, TQueryData, TQueryKey extends QueryKey, + TPageParam = never, >( options: ( client: QueryClient, @@ -28,7 +48,8 @@ export function createBaseQuery< TError, TData, TQueryData, - TQueryKey + TQueryKey, + TPageParam >, Observer: typeof QueryObserver, queryClient: QueryClient, @@ -36,6 +57,11 @@ export function createBaseQuery< assertInInjectionContext(createBaseQuery) const destroyRef = inject(DestroyRef) + /** + * Subscription to the query$ observable. + */ + let subscription: Subscription | undefined + /** * Signal that has the default options from query client applied * computed() is used so signals can be inserted into the options @@ -43,9 +69,79 @@ export function createBaseQuery< * are preserved and can keep being applied after signal changes */ const defaultedOptionsSignal = computed(() => { - const defaultedOptions = queryClient.defaultQueryOptions( - options(queryClient), - ) + const { query$, ...opts } = options(queryClient) + + // If there is a subscription, unsubscribe from it this is to prevent + // multiple subscriptions on the same computed type + if (subscription) subscription.unsubscribe() + + /** + * Subscribe to the query$ observable and set the query data + * when the observable emits a value. This creates a promise + * on the queryFn and on each new emit it will update the client + * side. + */ + if (query$) { + const trigger$ = new Subject< + QueryFunctionContext + >() + + const obs$ = trigger$.pipe( + switchMap((context) => + query$(context).pipe( + takeUntil( + // If the signal is aborted, abort the observable + fromEvent(context.signal, 'abort'), + ), + ), + ), + shareReplay(1), + takeUntilDestroyed(destroyRef), + ) + + subscription = obs$ + .pipe( + skip(1), + catchError((error: Error) => { + const query = queryClient + .getQueryCache() + .find({ queryKey: opts.queryKey }) + if (query) { + const { state } = query + // Mimic the dispatch code on the error case found in the query-core package + query.setState({ + ...state, + error, + errorUpdateCount: state.errorUpdateCount + 1, + errorUpdatedAt: Date.now(), + fetchFailureCount: state.fetchFailureCount + 1, + fetchFailureReason: error, + fetchStatus: 'idle', + status: 'error', + }) + } + return EMPTY + }), + ) + .subscribe({ + next: (value) => + queryClient.setQueryData(opts.queryKey, value), + }) + + const queryFn = ( + context: QueryFunctionContext, + ) => { + // Trigger the observable with the new context. + const promise = lastValueFrom(obs$.pipe(take(1))) + trigger$.next(context) + return promise + } + + opts.queryFn = queryFn + } + + const defaultedOptions = queryClient.defaultQueryOptions(opts) + defaultedOptions._optimisticResults = 'optimistic' return defaultedOptions }) diff --git a/packages/angular-query-experimental/src/types.ts b/packages/angular-query-experimental/src/types.ts index 77f828e173..b1c0c6a1b2 100644 --- a/packages/angular-query-experimental/src/types.ts +++ b/packages/angular-query-experimental/src/types.ts @@ -1,4 +1,5 @@ import type { Signal } from '@angular/core' +import type { Observable } from 'rxjs' import type { DefaultError, @@ -8,6 +9,7 @@ import type { MutateFunction, MutationObserverOptions, MutationObserverResult, + QueryFunctionContext, QueryKey, QueryObserverOptions, QueryObserverResult, @@ -21,10 +23,22 @@ export interface CreateBaseQueryOptions< TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, + TPageParam = never, > extends WithRequired< - QueryObserverOptions, + QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, 'queryKey' - > {} + > { + query$?: ( + context: QueryFunctionContext, + ) => Observable +} type CreateStatusBasedQueryResult< TStatus extends QueryObserverResult['status'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a84b00250..b1108099eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1439,7 +1439,7 @@ importers: devDependencies: '@angular-devkit/build-angular': specifier: ^17.0.8 - version: 17.0.8(@angular/compiler-cli@17.0.8)(@types/node@18.19.3)(ng-packagr@17.0.3)(typescript@5.2.2) + version: 17.0.8(@angular/compiler-cli@17.0.8)(@types/node@18.19.3)(typescript@5.2.2) '@angular/cli': specifier: ^17.0.8 version: 17.0.8 @@ -1681,6 +1681,9 @@ importers: ng-packagr: specifier: ^17.0.3 version: 17.0.3(@angular/compiler-cli@17.0.8)(tslib@2.6.2)(typescript@5.2.2) + rxjs: + specifier: ^7.8.1 + version: 7.8.1 typescript: specifier: 5.2.2 version: 5.2.2 @@ -2286,6 +2289,129 @@ packages: typescript: 5.2.2 undici: 5.27.2 vite: 4.5.1(@types/node@18.19.3)(less@4.2.0)(sass@1.69.5)(terser@5.24.0) + webpack: 5.89.0(esbuild@0.19.5) + webpack-dev-middleware: 6.1.1(webpack@5.89.0) + webpack-dev-server: 4.15.1(webpack@5.89.0) + webpack-merge: 5.10.0 + webpack-subresource-integrity: 5.1.0(webpack@5.89.0) + optionalDependencies: + esbuild: 0.19.5 + transitivePeerDependencies: + - '@swc/core' + - '@types/express' + - '@types/node' + - bufferutil + - debug + - fibers + - html-webpack-plugin + - lightningcss + - node-sass + - sass-embedded + - stylus + - sugarss + - supports-color + - uglify-js + - utf-8-validate + - webpack-cli + dev: true + + /@angular-devkit/build-angular@17.0.8(@angular/compiler-cli@17.0.8)(@types/node@18.19.3)(typescript@5.2.2): + resolution: {integrity: sha512-u7R5yX92ZxOL/LfxiKGGqlBo86100sJ5Rabavn8DeGtYP8N0qgwCcNwlW2zaMoUlkw2geMnxcxIX5VJI4iFPUA==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + '@angular/compiler-cli': ^17.0.0 + '@angular/localize': ^17.0.0 + '@angular/platform-server': ^17.0.0 + '@angular/service-worker': ^17.0.0 + jest: ^29.5.0 + jest-environment-jsdom: ^29.5.0 + karma: ^6.3.0 + ng-packagr: ^17.0.0 + protractor: ^7.0.0 + tailwindcss: ^2.0.0 || ^3.0.0 + typescript: '>=5.2 <5.3' + peerDependenciesMeta: + '@angular/localize': + optional: true + '@angular/platform-server': + optional: true + '@angular/service-worker': + optional: true + jest: + optional: true + jest-environment-jsdom: + optional: true + karma: + optional: true + ng-packagr: + optional: true + protractor: + optional: true + tailwindcss: + optional: true + dependencies: + '@ampproject/remapping': 2.2.1 + '@angular-devkit/architect': 0.1700.8(chokidar@3.5.3) + '@angular-devkit/build-webpack': 0.1700.8(chokidar@3.5.3)(webpack-dev-server@4.15.1)(webpack@5.89.0) + '@angular-devkit/core': 17.0.8(chokidar@3.5.3) + '@angular/compiler-cli': 17.0.8(@angular/compiler@17.0.8)(typescript@5.2.2) + '@babel/core': 7.23.2 + '@babel/generator': 7.23.0 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/plugin-transform-async-generator-functions': 7.23.2(@babel/core@7.23.2) + '@babel/plugin-transform-async-to-generator': 7.22.5(@babel/core@7.23.2) + '@babel/plugin-transform-runtime': 7.23.2(@babel/core@7.23.2) + '@babel/preset-env': 7.23.2(@babel/core@7.23.2) + '@babel/runtime': 7.23.2 + '@discoveryjs/json-ext': 0.5.7 + '@ngtools/webpack': 17.0.8(@angular/compiler-cli@17.0.8)(typescript@5.2.2)(webpack@5.89.0) + '@vitejs/plugin-basic-ssl': 1.0.1(vite@4.5.1) + ansi-colors: 4.1.3 + autoprefixer: 10.4.16(postcss@8.4.31) + babel-loader: 9.1.3(@babel/core@7.23.2)(webpack@5.89.0) + babel-plugin-istanbul: 6.1.1 + browser-sync: 2.29.3 + browserslist: 4.22.2 + chokidar: 3.5.3 + copy-webpack-plugin: 11.0.0(webpack@5.89.0) + critters: 0.0.20 + css-loader: 6.8.1(webpack@5.89.0) + esbuild-wasm: 0.19.5 + fast-glob: 3.3.1 + http-proxy-middleware: 2.0.6(@types/express@4.17.20) + https-proxy-agent: 7.0.2 + inquirer: 9.2.11 + jsonc-parser: 3.2.0 + karma-source-map-support: 1.4.0 + less: 4.2.0 + less-loader: 11.1.0(less@4.2.0)(webpack@5.89.0) + license-webpack-plugin: 4.0.2(webpack@5.89.0) + loader-utils: 3.2.1 + magic-string: 0.30.5 + mini-css-extract-plugin: 2.7.6(webpack@5.89.0) + mrmime: 1.0.1 + open: 8.4.2 + ora: 5.4.1 + parse5-html-rewriting-stream: 7.0.0 + picomatch: 3.0.1 + piscina: 4.1.0 + postcss: 8.4.31 + postcss-loader: 7.3.3(postcss@8.4.31)(typescript@5.2.2)(webpack@5.89.0) + resolve-url-loader: 5.0.0 + rxjs: 7.8.1 + sass: 1.69.5 + sass-loader: 13.3.2(sass@1.69.5)(webpack@5.89.0) + semver: 7.5.4 + source-map-loader: 4.0.1(webpack@5.89.0) + source-map-support: 0.5.21 + terser: 5.24.0 + text-table: 0.2.0 + tree-kill: 1.2.2 + tslib: 2.6.2 + typescript: 5.2.2 + undici: 5.27.2 + vite: 4.5.1(@types/node@18.19.3)(less@4.2.0)(sass@1.69.5)(terser@5.24.0) webpack: 5.89.0(esbuild@0.19.10) webpack-dev-middleware: 6.1.1(webpack@5.89.0) webpack-dev-server: 4.15.1(webpack@5.89.0) @@ -2321,7 +2447,7 @@ packages: dependencies: '@angular-devkit/architect': 0.1700.8(chokidar@3.5.3) rxjs: 7.8.1 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) webpack-dev-server: 4.15.1(webpack@5.89.0) transitivePeerDependencies: - chokidar @@ -7569,7 +7695,7 @@ packages: dependencies: '@angular/compiler-cli': 17.0.8(@angular/compiler@17.0.8)(typescript@5.2.2) typescript: 5.2.2 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1: @@ -11681,7 +11807,7 @@ packages: '@babel/core': 7.23.2 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /babel-plugin-add-module-exports@0.2.1: @@ -13395,7 +13521,7 @@ packages: normalize-path: 3.0.0 schema-utils: 4.2.0 serialize-javascript: 6.0.1 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /core-js-compat@3.33.0: @@ -13736,7 +13862,7 @@ packages: postcss-modules-values: 4.0.0(postcss@8.4.32) postcss-value-parser: 4.2.0 semver: 7.5.4 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) /css-minimizer-webpack-plugin@3.4.1(esbuild@0.19.10)(webpack@5.89.0): resolution: {integrity: sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==} @@ -20465,7 +20591,7 @@ packages: dependencies: klona: 2.0.6 less: 4.2.0 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /less@4.2.0: @@ -20516,7 +20642,7 @@ packages: webpack-sources: optional: true dependencies: - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) webpack-sources: 3.2.3 dev: true @@ -21570,7 +21696,7 @@ packages: webpack: ^5.0.0 dependencies: schema-utils: 4.2.0 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) /minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -24096,7 +24222,7 @@ packages: jiti: 1.21.0 postcss: 8.4.31 semver: 7.5.4 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) transitivePeerDependencies: - typescript dev: true @@ -26834,7 +26960,7 @@ packages: dependencies: neo-async: 2.6.2 sass: 1.69.5 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /sass@1.69.5: @@ -27544,7 +27670,7 @@ packages: abab: 2.0.6 iconv-lite: 0.6.3 source-map-js: 1.0.2 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /source-map-resolve@0.5.3: @@ -28593,6 +28719,30 @@ packages: terser: 5.24.0 webpack: 5.89.0(esbuild@0.19.10) + /terser-webpack-plugin@5.3.9(esbuild@0.19.5)(webpack@5.89.0): + resolution: {integrity: sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.20 + esbuild: 0.19.5 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.1 + terser: 5.24.0 + webpack: 5.89.0(esbuild@0.19.5) + /terser@4.8.1: resolution: {integrity: sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==} engines: {node: '>=6.0.0'} @@ -30303,7 +30453,7 @@ packages: mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.2.0 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) /webpack-dev-middleware@6.1.1(webpack@5.89.0): resolution: {integrity: sha512-y51HrHaFeeWir0YO4f0g+9GwZawuigzcAdRNon6jErXy/SqV/+O6eaVAzDqE6t3e3NpGeR5CS+cCDaTC+V3yEQ==} @@ -30319,7 +30469,7 @@ packages: mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.2.0 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /webpack-dev-server@3.11.1(webpack@4.44.2): @@ -30413,7 +30563,7 @@ packages: serve-index: 1.9.1(supports-color@6.1.0) sockjs: 0.3.24 spdy: 4.0.2(supports-color@6.1.0) - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) webpack-dev-middleware: 5.3.3(webpack@5.89.0) ws: 8.14.2 transitivePeerDependencies: @@ -30493,7 +30643,7 @@ packages: optional: true dependencies: typed-assert: 1.0.9 - webpack: 5.89.0(esbuild@0.19.10) + webpack: 5.89.0(esbuild@0.19.5) dev: true /webpack-virtual-modules@0.6.1: @@ -30579,6 +30729,45 @@ packages: - esbuild - uglify-js + /webpack@5.89.0(esbuild@0.19.5): + resolution: {integrity: sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.6 + '@types/estree': 1.0.3 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/wasm-edit': 1.11.6 + '@webassemblyjs/wasm-parser': 1.11.6 + acorn: 8.10.0 + acorn-import-assertions: 1.9.0(acorn@8.10.0) + browserslist: 4.22.2 + chrome-trace-event: 1.0.3 + enhanced-resolve: 5.15.0 + es-module-lexer: 1.3.1 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.9(esbuild@0.19.5)(webpack@5.89.0) + watchpack: 2.4.0 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + /websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} engines: {node: '>=0.8.0'}