From a83f8adc03e42c149fa2d098d4d4e9522bb1d44e Mon Sep 17 00:00:00 2001 From: diamondniam <1yungdiamond@gmail.com> Date: Fri, 25 Jul 2025 22:36:54 +0400 Subject: [PATCH 1/4] feat(query): add query operator for loading/data/error tracking --- packages/rxjs/src/index.ts | 1 + packages/rxjs/src/internal/operators/query.ts | 33 +++++++++++++++++++ packages/rxjs/src/internal/types.ts | 11 +++++++ 3 files changed, 45 insertions(+) create mode 100644 packages/rxjs/src/internal/operators/query.ts diff --git a/packages/rxjs/src/index.ts b/packages/rxjs/src/index.ts index a401275ffd..b12d09538c 100644 --- a/packages/rxjs/src/index.ts +++ b/packages/rxjs/src/index.ts @@ -140,6 +140,7 @@ export { min } from './internal/operators/min.js'; export { observeOn } from './internal/operators/observeOn.js'; export { onErrorResumeNextWith } from './internal/operators/onErrorResumeNextWith.js'; export { pairwise } from './internal/operators/pairwise.js'; +export { query } from './internal/operators/query.js'; export { raceWith } from './internal/operators/raceWith.js'; export { reduce } from './internal/operators/reduce.js'; export type { RepeatConfig } from './internal/operators/repeat.js'; diff --git a/packages/rxjs/src/internal/operators/query.ts b/packages/rxjs/src/internal/operators/query.ts new file mode 100644 index 0000000000..9c8105c767 --- /dev/null +++ b/packages/rxjs/src/internal/operators/query.ts @@ -0,0 +1,33 @@ +import { Observable, of } from 'rxjs'; +import { QueryResult } from '../types'; +import { startWith, catchError, map } from 'rxjs/operators'; + +/** + * Transforms an observable into a query result observable. + * + * The query result observable will emit one of the following states: + * + * - `isLoading: true, data: null, error: null` when the source observable is + * executing. + * - `isLoading: false, data: T, error: null` when the source observable + * completes successfully. + * - `isLoading: false, data: null, error: Error` when the source observable + * throws an error. + * + * The first state is sent immediately using `startWith`, the others are sent + * when the source observable completes or throws an error. + * + * @param source$ The source observable to transform + * @returns An observable that emits the query result + */ +export function query() { + return (source$: Observable): Observable> => { + return source$.pipe( + map((data) => ({ isLoading: false, data, error: null })), + catchError((error) => + of({ isLoading: false, data: null, error }) + ), + startWith({ isLoading: true, data: null, error: null }) + ) + } +} diff --git a/packages/rxjs/src/internal/types.ts b/packages/rxjs/src/internal/types.ts index bbb680342b..e3cb9063ce 100644 --- a/packages/rxjs/src/internal/types.ts +++ b/packages/rxjs/src/internal/types.ts @@ -72,6 +72,17 @@ export interface TimeInterval { interval: number; } +/** + * The result of a query. + * + * @see {@link query} + */ +export interface QueryResult { + isLoading: boolean; + data: T | null; + error: any; +} + /* SUBSCRIPTION INTERFACES */ export interface Unsubscribable { From 2bc50362243766d54ceddf1e9a1a7281e60917b5 Mon Sep 17 00:00:00 2001 From: diamondniam <1yungdiamond@gmail.com> Date: Sat, 26 Jul 2025 02:26:56 +0400 Subject: [PATCH 2/4] feat(query): tests --- packages/rxjs/spec/operators/query-spec.ts | 60 +++++++++++++++++++ packages/rxjs/src/internal/operators/query.ts | 12 ++-- 2 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 packages/rxjs/spec/operators/query-spec.ts diff --git a/packages/rxjs/spec/operators/query-spec.ts b/packages/rxjs/spec/operators/query-spec.ts new file mode 100644 index 0000000000..c2de883265 --- /dev/null +++ b/packages/rxjs/spec/operators/query-spec.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import { TestScheduler } from 'rxjs/testing'; +import { query } from '../../src/internal/operators/query'; +import { QueryResult } from '../../src/internal/types'; +import { observableMatcher } from '../helpers/observableMatcher'; + +/** @test {query} */ +describe('query operator', () => { + let testScheduler: TestScheduler; + + beforeEach(() => { + testScheduler = new TestScheduler(observableMatcher); + }); + + it('should emit loading and then data', () => { + testScheduler.run(({ cold, expectObservable }) => { + const source$ = cold(' --a|', { a: 'value' }); + const expected = ' i-a|'; + + const expectedValues = { + i: { isLoading: true, data: null, error: null }, + a: { isLoading: false, data: 'value', error: null }, + }; + + const result$ = source$.pipe(query()); + + expectObservable(result$).toBe(expected, expectedValues); + }); + }); + + it('should emit loading and then error', () => { + testScheduler.run(({ cold, expectObservable }) => { + const source$ = cold(' --#', {}, 'BOOM'); + const expected = ' i-(e|)'; + const expectedValues: Record> = { + i: { isLoading: true, data: null, error: null }, + e: { isLoading: false, data: null, error: 'BOOM' }, + }; + + const result$ = source$.pipe(query()); + + expectObservable(result$).toBe(expected, expectedValues); + }); + }); + + it('should complete after emitting data', () => { + testScheduler.run(({ cold, expectObservable }) => { + const source$ = cold(' a---|', { a: 123 }); + const expected = ' (ia)|'; + const expectedValues = { + i: { isLoading: true, data: null, error: null }, + a: { isLoading: false, data: 123, error: null }, + }; + + const result$ = source$.pipe(query()); + + expectObservable(result$).toBe(expected, expectedValues); + }); + }); +}); diff --git a/packages/rxjs/src/internal/operators/query.ts b/packages/rxjs/src/internal/operators/query.ts index 9c8105c767..500ff3ba2e 100644 --- a/packages/rxjs/src/internal/operators/query.ts +++ b/packages/rxjs/src/internal/operators/query.ts @@ -23,11 +23,9 @@ import { startWith, catchError, map } from 'rxjs/operators'; export function query() { return (source$: Observable): Observable> => { return source$.pipe( - map((data) => ({ isLoading: false, data, error: null })), - catchError((error) => - of({ isLoading: false, data: null, error }) - ), - startWith({ isLoading: true, data: null, error: null }) - ) - } + map((data) => ({ isLoading: false, data, error: null })), + catchError((error) => of({ isLoading: false, data: null, error })), + startWith({ isLoading: true, data: null, error: null }) + ); + }; } From 8775c0f0d51117642891b8b5e9d05cb4121ff668 Mon Sep 17 00:00:00 2001 From: diamondniam <1yungdiamond@gmail.com> Date: Sat, 26 Jul 2025 02:48:07 +0400 Subject: [PATCH 3/4] feat(query): guide/operators.md listing --- apps/rxjs.dev/content/guide/operators.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/rxjs.dev/content/guide/operators.md b/apps/rxjs.dev/content/guide/operators.md index 7fdfdabe55..bd311a6f2e 100644 --- a/apps/rxjs.dev/content/guide/operators.md +++ b/apps/rxjs.dev/content/guide/operators.md @@ -166,6 +166,7 @@ These are Observable creation operators that also have join functionality -- emi - [`mergeScan`](/api/operators/mergeScan) - [`pairwise`](/api/operators/pairwise) - [`partition`](/api/operators/partition) +- [`query`](/api/operators/query) - [`scan`](/api/operators/scan) - [`switchScan`](/api/operators/switchScan) - [`switchMap`](/api/operators/switchMap) From 1637681f3ad771dadc62acdba14d88a6c4b3591f Mon Sep 17 00:00:00 2001 From: diamondniam <1yungdiamond@gmail.com> Date: Sat, 26 Jul 2025 02:59:23 +0400 Subject: [PATCH 4/4] feat(query): spec-dtslint test --- packages/rxjs/spec-dtslint/operators/query-spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/rxjs/spec-dtslint/operators/query-spec.ts diff --git a/packages/rxjs/spec-dtslint/operators/query-spec.ts b/packages/rxjs/spec-dtslint/operators/query-spec.ts new file mode 100644 index 0000000000..f1e9bdbf63 --- /dev/null +++ b/packages/rxjs/spec-dtslint/operators/query-spec.ts @@ -0,0 +1,13 @@ +import { of, throwError } from 'rxjs'; +import { query } from '../../src/internal/operators/query'; +import { QueryResult } from '../../src/internal/types'; + +it('should infer QueryResult when T is string', () => { + const result = of('hello').pipe(query()); + // $ExpectType Observable> +}); + +it('should handle error types correctly', () => { + const result = throwError(() => new Error()).pipe(query()); + // $ExpectType Observable> +});