Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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<number>()
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)
})
})
})
4 changes: 3 additions & 1 deletion packages/angular-query-experimental/src/create-base-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +44,7 @@ export function createBaseQuery<
TQueryKey
>,
Observer: typeof QueryObserver,
excludeFunctions: ReadonlyArray<string> = [],
) {
const ngZone = inject(NgZone)
const pendingTasks = inject(PendingTasks)
Expand Down Expand Up @@ -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'
17 changes: 15 additions & 2 deletions packages/angular-query-experimental/src/inject-infinite-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TQueryFnData>,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
>(
injectInfiniteQueryFn: () => CreateInfiniteQueryOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>,
options?: InjectInfiniteQueryOptions,
) {
!options?.injector && assertInInjectionContext(injectInfiniteQuery)
Expand All @@ -120,6 +132,7 @@ export function injectInfiniteQuery(
createBaseQuery(
injectInfiniteQueryFn,
InfiniteQueryObserver as typeof QueryObserver,
['refetch', 'fetchNextPage', 'fetchPreviousPage'],
),
)
}
11 changes: 5 additions & 6 deletions packages/angular-query-experimental/src/inject-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,9 @@ export function injectMutation<
}
})

return signalProxy(resultSignal) as CreateMutationResult<
TData,
TError,
TVariables,
TOnMutateResult
>
return signalProxy(resultSignal, [
'mutate',
'mutateAsync',
'reset',
]) as CreateMutationResult<TData, TError, TVariables, TOnMutateResult>
}
4 changes: 2 additions & 2 deletions packages/angular-query-experimental/src/inject-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,8 @@ export function injectQueries<
return combine
? result
: (result as QueriesResults<T>).map((query) =>
signalProxy(signal(query)),
signalProxy(signal(query), ['refetch']),
)
})
}) as unknown as Signal<TCombinedResult>
}) as Signal<TCombinedResult>
}
2 changes: 1 addition & 1 deletion packages/angular-query-experimental/src/inject-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
33 changes: 24 additions & 9 deletions packages/angular-query-experimental/src/signal-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,47 @@
import { computed, untracked } from '@angular/core'
import type { Signal } from '@angular/core'

export type MapToSignals<T> = {
[K in keyof T]: T[K] extends Function ? T[K] : Signal<T[K]>
export type FunctionKeys<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any ? K : never
}[keyof T]

export type MapToSignals<T, ExcludeFields extends FunctionKeys<T> = never> = {
[K in keyof T]: K extends ExcludeFields ? T[K] : Signal<T[K]>
}

/**
* 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<TInput extends Record<string | symbol, any>>(
export function signalProxy<
TInput extends Record<string | symbol, any>,
const ExcludeFields extends ReadonlyArray<FunctionKeys<TInput>> = [],
>(
inputSignal: Signal<TInput>,
excludeFields: ExcludeFields | ReadonlyArray<string> = [],
) {
const internalState = {} as MapToSignals<TInput>
const internalState = {} as MapToSignals<TInput, ExcludeFields[number]>
const excludeFieldsArray = excludeFields as ReadonlyArray<string>

return new Proxy<MapToSignals<TInput>>(internalState, {
return new Proxy<MapToSignals<TInput, ExcludeFields[number]>>(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<TInput[typeof prop]>) =>
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]))
},
Expand Down
27 changes: 21 additions & 6 deletions packages/angular-query-experimental/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -100,7 +100,10 @@ export type CreateBaseQueryResult<
TError = DefaultError,
TState = QueryObserverResult<TData, TError>,
> = BaseQueryNarrowing<TData, TError> &
MapToSignals<OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>>
MapToSignals<
OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>,
FunctionKeys<OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>>
>

export type CreateQueryResult<
TData = unknown,
Expand All @@ -112,13 +115,19 @@ export type DefinedCreateQueryResult<
TError = DefaultError,
TState = DefinedQueryObserverResult<TData, TError>,
> = BaseQueryNarrowing<TData, TError> &
MapToSignals<OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>>
MapToSignals<
OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>,
FunctionKeys<OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>>
>

export type CreateInfiniteQueryResult<
TData = unknown,
TError = DefaultError,
> = BaseQueryNarrowing<TData, TError> &
MapToSignals<InfiniteQueryObserverResult<TData, TError>>
MapToSignals<
InfiniteQueryObserverResult<TData, TError>,
FunctionKeys<InfiniteQueryObserverResult<TData, TError>>
>

export type DefinedCreateInfiniteQueryResult<
TData = unknown,
Expand All @@ -127,7 +136,10 @@ export type DefinedCreateInfiniteQueryResult<
TData,
TError
>,
> = MapToSignals<TDefinedInfiniteQueryObserver>
> = MapToSignals<
TDefinedInfiniteQueryObserver,
FunctionKeys<TDefinedInfiniteQueryObserver>
>

export interface CreateMutationOptions<
TData = unknown,
Expand Down Expand Up @@ -276,4 +288,7 @@ export type CreateMutationResult<
TOnMutateResult
>,
> = BaseMutationNarrowing<TData, TError, TVariables, TOnMutateResult> &
MapToSignals<OmitKeyof<TState, keyof BaseMutationNarrowing, 'safely'>>
MapToSignals<
OmitKeyof<TState, keyof BaseMutationNarrowing, 'safely'>,
FunctionKeys<OmitKeyof<TState, keyof BaseMutationNarrowing, 'safely'>>
>