diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index af6b243623..f9008dec88 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -1381,6 +1381,62 @@ describe('Component Store', () => { }); }); + describe('selectSignal', () => { + it('creates a signal from the provided state projector function', () => { + const store = new ComponentStore<{ foo: string }>({ foo: 'bar' }); + let projectorExecutionCount = 0; + + const foo = store.selectSignal((state) => { + projectorExecutionCount++; + return state.foo; + }); + + expect(foo()).toBe('bar'); + + foo(); + store.patchState({ foo: 'baz' }); + foo(); + + expect(foo()).toBe('baz'); + expect(projectorExecutionCount).toBe(2); + }); + + it('creates a signal by combining provided signals', () => { + const store = new ComponentStore<{ x: number; y: number; z: number }>({ + x: 1, + y: 10, + z: 100, + }); + let projectorExecutionCount = 0; + + const x = store.selectSignal((s) => s.x); + const y = store.selectSignal((s) => s.y); + const xPlusY = store.selectSignal(x, y, (x, y) => { + projectorExecutionCount++; + return x + y; + }); + + expect(xPlusY()).toBe(11); + + // projector should not be executed + store.patchState({ z: 1000 }); + xPlusY(); + + store.patchState({ x: 10 }); + xPlusY(); + + expect(xPlusY()).toBe(20); + expect(projectorExecutionCount).toBe(2); + }); + + it('throws an error when the signal is read before the state initialization', () => { + const store = new ComponentStore<{ foo: string }>(); + const foo = store.selectSignal((s) => s.foo); + + expect(() => foo()).toThrowError(); + }); + }); + describe('effect', () => { let componentStore: ComponentStore; diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index e688da18ad..eb4267ea73 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -56,6 +56,14 @@ export type Projector[], Result> = ( ...args: SelectorResults ) => Result; +type SignalsProjector[], Result> = ( + ...values: { + [Key in keyof Signals]: Signals[Key] extends Signal + ? Value + : never; + } +) => Result; + @Injectable() export class ComponentStore implements OnDestroy { // Should be used only in ngOnDestroy. @@ -67,10 +75,12 @@ export class ComponentStore implements OnDestroy { private isInitialized = false; // Needs to be after destroy$ is declared because it's used in select. readonly state$: Observable = this.select((s) => s); - private ɵhasProvider = false; - // Signal of state$ - readonly state: Signal; + readonly state: Signal = toSignal( + this.state$.pipe(takeUntil(this.destroy$)), + { requireSync: false, manualCleanup: true } + ); + private ɵhasProvider = false; constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) { // State can be initialized either through constructor or setState. @@ -79,10 +89,6 @@ export class ComponentStore implements OnDestroy { } this.checkProviderForHooks(); - this.state = toSignal(this.stateSubject$.pipe(takeUntil(this.destroy$)), { - requireSync: false, - manualCleanup: true, - }); } /** Completes all relevant Observable streams. */ @@ -293,12 +299,40 @@ export class ComponentStore implements OnDestroy { } /** - * Returns a signal of the provided projector function. - * - * @param projector projector function + * Creates a signal from the provided state projector function. + */ + selectSignal(projector: (state: T) => Result): Signal; + /** + * Creates a signal by combining provided signals. */ - selectSignal(projector: (state: T) => K): Signal { - return computed(() => projector(this.state())); + selectSignal[], Result>( + ...signalsWithProjector: [ + ...selectors: Signals, + projector: SignalsProjector + ] + ): Signal; + selectSignal( + ...args: + | [(state: T) => unknown] + | [ + ...signals: Signal[], + projector: (...values: unknown[]) => unknown + ] + ): Signal { + if (args.length === 1) { + const projector = args[0] as (state: T) => unknown; + return computed(() => projector(this.state())); + } + + const signals = args.slice(0, -1) as Signal[]; + const projector = args[args.length - 1] as ( + ...values: unknown[] + ) => unknown; + + return computed(() => { + const values = signals.map((signal) => signal()); + return projector(...values); + }); } /**