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 d06aef67230..0bd552bb751 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, + untracked, + signal, +} from '@angular/core' +import { beforeEach, describe, expect, test } from 'vitest' import { signalProxy } from '../signal-proxy' +import { registerSignalInput } from './test-utils' +import { TestBed } from '@angular/core/testing' 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/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4b1ce4ca5c2..b19ecec45cf 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -27,6 +27,7 @@ import type { CreateBaseQueryOptions } from './types' * Base implementation for `injectQuery` and `injectInfiniteQuery`. * @param optionsFn * @param Observer + * @param excludeFunctions - Array of function property names to exclude from signal conversion */ export function createBaseQuery< TQueryFnData, @@ -43,6 +44,7 @@ export function createBaseQuery< TQueryKey >, Observer: typeof QueryObserver, + excludeFunctions: ReadonlyArray = [], ) { const ngZone = inject(NgZone) const pendingTasks = inject(PendingTasks) @@ -211,7 +213,7 @@ export function createBaseQuery< }) }) - return signalProxy(resultSignal.asReadonly()) + return signalProxy(resultSignal.asReadonly(), excludeFunctions) } const OBSERVER_NOT_READY_ERROR = 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction' diff --git a/packages/angular-query-experimental/src/inject-infinite-query.ts b/packages/angular-query-experimental/src/inject-infinite-query.ts index ee6de032409..36a5d33cacd 100644 --- a/packages/angular-query-experimental/src/inject-infinite-query.ts +++ b/packages/angular-query-experimental/src/inject-infinite-query.ts @@ -110,8 +110,20 @@ export function injectInfiniteQuery< * @param options - Additional configuration. * @returns The infinite query result. */ -export function injectInfiniteQuery( - injectInfiniteQueryFn: () => CreateInfiniteQueryOptions, +export function injectInfiniteQuery< + TQueryFnData = unknown, + 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 +132,7 @@ export function injectInfiniteQuery( createBaseQuery( injectInfiniteQueryFn, InfiniteQueryObserver as typeof QueryObserver, + ['refetch', 'fetchNextPage', 'fetchPreviousPage'], ), ) } diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 991c2ea0a1d..e8d995904fe 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -184,10 +184,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 2f201799e74..d72151ed042 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -323,8 +323,8 @@ export function injectQueries< return combine ? result : (result as QueriesResults).map((query) => - signalProxy(signal(query)), + signalProxy(signal(query), ['refetch']), ) }) - }) as unknown as Signal + }) as Signal } diff --git a/packages/angular-query-experimental/src/inject-query.ts b/packages/angular-query-experimental/src/inject-query.ts index 1dac0ab6949..bd5223d3ed9 100644 --- a/packages/angular-query-experimental/src/inject-query.ts +++ b/packages/angular-query-experimental/src/inject-query.ts @@ -221,6 +221,6 @@ export function injectQuery( ) { !options?.injector && assertInInjectionContext(injectQuery) return runInInjectionContext(options?.injector ?? inject(Injector), () => - createBaseQuery(injectQueryFn, QueryObserver), + createBaseQuery(injectQueryFn, QueryObserver, ['refetch']), ) as unknown as CreateQueryResult } diff --git a/packages/angular-query-experimental/src/signal-proxy.ts b/packages/angular-query-experimental/src/signal-proxy.ts index e2a9de345f6..d26d8824cf3 100644 --- a/packages/angular-query-experimental/src/signal-proxy.ts +++ b/packages/angular-query-experimental/src/signal-proxy.ts @@ -1,32 +1,47 @@ 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 FunctionKeys = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never +}[keyof T] + +export type MapToSignals = never> = { + [K in keyof T]: K extends ExcludeFields ? 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>( +export function signalProxy< + TInput extends Record, + const ExcludeFields extends ReadonlyArray> = [], +>( inputSignal: Signal, + excludeFields: ExcludeFields | ReadonlyArray = [], ) { - const internalState = {} as MapToSignals + const internalState = {} as MapToSignals + const excludeFieldsArray = excludeFields as ReadonlyArray - return new Proxy>(internalState, { + 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 expluded 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 + // otherwise, make a computed field // @ts-expect-error return (target[prop] = computed(() => inputSignal()[prop])) }, diff --git a/packages/angular-query-experimental/src/types.ts b/packages/angular-query-experimental/src/types.ts index 1d4bf86487a..bce0c45509a 100644 --- a/packages/angular-query-experimental/src/types.ts +++ b/packages/angular-query-experimental/src/types.ts @@ -16,7 +16,7 @@ import type { QueryObserverResult, } from '@tanstack/query-core' import type { Signal } from '@angular/core' -import type { MapToSignals } from './signal-proxy' +import type { MapToSignals, FunctionKeys } from './signal-proxy' export interface CreateBaseQueryOptions< TQueryFnData = unknown, @@ -100,7 +100,10 @@ export type CreateBaseQueryResult< TError = DefaultError, TState = QueryObserverResult, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + FunctionKeys> + > export type CreateQueryResult< TData = unknown, @@ -112,13 +115,19 @@ export type DefinedCreateQueryResult< TError = DefaultError, TState = DefinedQueryObserverResult, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + FunctionKeys> + > export type CreateInfiniteQueryResult< TData = unknown, TError = DefaultError, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + InfiniteQueryObserverResult, + FunctionKeys> + > export type DefinedCreateInfiniteQueryResult< TData = unknown, @@ -127,7 +136,10 @@ export type DefinedCreateInfiniteQueryResult< TData, TError >, -> = MapToSignals +> = MapToSignals< + TDefinedInfiniteQueryObserver, + FunctionKeys +> export interface CreateMutationOptions< TData = unknown, @@ -276,4 +288,7 @@ export type CreateMutationResult< TOnMutateResult >, > = BaseMutationNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + FunctionKeys> + >