From 213317c46c035402f10e41240e79c90a435cedeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 19 Apr 2026 16:48:06 -0400 Subject: [PATCH 1/3] support inputs on angular adapter --- .changeset/eight-ways-dig.md | 5 ++ .../angular/reference/functions/injectAtom.md | 4 +- .../reference/functions/injectSelector.md | 4 +- packages/angular-store/package.json | 4 +- packages/angular-store/src/injectAtom.ts | 19 ++++- packages/angular-store/src/injectSelector.ts | 64 +++++++---------- packages/angular-store/tests/index.test.ts | 69 ++++++++++++++++++- packages/angular-store/tests/test-setup.ts | 15 ++-- packages/angular-store/tsconfig.spec.json | 9 +++ packages/angular-store/vitest.config.ts | 2 + pnpm-lock.yaml | 51 +++++++++++++- 11 files changed, 183 insertions(+), 63 deletions(-) create mode 100644 .changeset/eight-ways-dig.md create mode 100644 packages/angular-store/tsconfig.spec.json diff --git a/.changeset/eight-ways-dig.md b/.changeset/eight-ways-dig.md new file mode 100644 index 00000000..6633089c --- /dev/null +++ b/.changeset/eight-ways-dig.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-store': patch +--- + +support input signals in angular store diff --git a/docs/framework/angular/reference/functions/injectAtom.md b/docs/framework/angular/reference/functions/injectAtom.md index 8d9bd6ef..bd51d08b 100644 --- a/docs/framework/angular/reference/functions/injectAtom.md +++ b/docs/framework/angular/reference/functions/injectAtom.md @@ -9,7 +9,7 @@ title: injectAtom function injectAtom(atom, options?): WritableAtomSignal; ``` -Defined in: [packages/angular-store/src/injectAtom.ts:44](https://github.com/TanStack/store/blob/main/packages/angular-store/src/injectAtom.ts#L44) +Defined in: [packages/angular-store/src/injectAtom.ts:59](https://github.com/TanStack/store/blob/main/packages/angular-store/src/injectAtom.ts#L59) Returns a [WritableAtomSignal](../interfaces/WritableAtomSignal.md) that reads the current atom value when called and exposes a `.set` method for updates. @@ -27,7 +27,7 @@ atom. ### atom -`Atom`\<`TValue`\> +`Atom`\<`TValue`\> | () => `Atom`\<`TValue`\> ### options? diff --git a/docs/framework/angular/reference/functions/injectSelector.md b/docs/framework/angular/reference/functions/injectSelector.md index a92e9bc2..13fbe8b2 100644 --- a/docs/framework/angular/reference/functions/injectSelector.md +++ b/docs/framework/angular/reference/functions/injectSelector.md @@ -12,7 +12,7 @@ function injectSelector( options?): Signal; ``` -Defined in: [packages/angular-store/src/injectSelector.ts:93](https://github.com/TanStack/store/blob/main/packages/angular-store/src/injectSelector.ts#L93) +Defined in: [packages/angular-store/src/injectSelector.ts:55](https://github.com/TanStack/store/blob/main/packages/angular-store/src/injectSelector.ts#L55) Selects a slice of state from an atom or store and returns it as an Angular signal. @@ -33,7 +33,7 @@ This is the primary Angular read hook for TanStack Store. ### source -[`SelectionSource`](../type-aliases/SelectionSource.md)\<`TState`\> +[`SelectionSource`](../type-aliases/SelectionSource.md)\<`TState`\> | () => [`SelectionSource`](../type-aliases/SelectionSource.md)\<`TState`\> ### selector diff --git a/packages/angular-store/package.json b/packages/angular-store/package.json index 3136c64c..eb574150 100644 --- a/packages/angular-store/package.json +++ b/packages/angular-store/package.json @@ -50,11 +50,13 @@ }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.4.5", + "@analogjs/vitest-angular": "^2.3.1", "@angular/common": "^21.2.8", "@angular/compiler": "^21.2.8", "@angular/core": "^21.2.8", "@angular/platform-browser": "^21.2.8", - "@angular/platform-browser-dynamic": "^21.2.8", + "@testing-library/angular": "^19.1.1", + "@testing-library/jest-dom": "^6.9.1", "zone.js": "^0.16.1" }, "peerDependencies": { diff --git a/packages/angular-store/src/injectAtom.ts b/packages/angular-store/src/injectAtom.ts index fd5f29b0..e79f2951 100644 --- a/packages/angular-store/src/injectAtom.ts +++ b/packages/angular-store/src/injectAtom.ts @@ -25,6 +25,21 @@ export interface WritableAtomSignal { set: Atom['set'] } + +function createSetter( + atom: Atom | (() => Atom), +): Atom['set'] { + function set(value: TValue): void + function set(fn: (prevVal: TValue) => TValue): void + function set( + updaterOrValue: TValue | ((prevVal: TValue) => TValue), + ): void { + const _atom = typeof atom === "function" ? atom() : atom + _atom.set(updaterOrValue as never) + } + return set as Atom['set'] +} + /** * Returns a {@link WritableAtomSignal} that reads the current atom value when * called and exposes a `.set` method for updates. @@ -42,11 +57,11 @@ export interface WritableAtomSignal { * ``` */ export function injectAtom( - atom: Atom, + atom: Atom | (() => Atom), options?: InjectSelectorOptions, ): WritableAtomSignal { const value = injectSelector(atom, undefined, options) const atomSignal = (() => value()) as WritableAtomSignal - atomSignal.set = atom.set + atomSignal.set = createSetter(atom) return atomSignal } diff --git a/packages/angular-store/src/injectSelector.ts b/packages/angular-store/src/injectSelector.ts index d9b0e249..054f8d4b 100644 --- a/packages/angular-store/src/injectSelector.ts +++ b/packages/angular-store/src/injectSelector.ts @@ -1,7 +1,7 @@ import { - DestroyRef, Injector, assertInInjectionContext, + effect, inject, linkedSignal, runInInjectionContext, @@ -23,10 +23,6 @@ export type SelectionSource = { } } -function defaultCompare(a: T, b: T) { - return a === b -} - function resolveInjector( fn: (...args: Array) => unknown, injector?: Injector, @@ -39,40 +35,6 @@ function resolveInjector( return injector } -function createReadonlySelectionSignal( - source: SelectionSource, - selector: (state: NoInfer) => TSelected, - options?: InjectSelectorOptions, -): Signal { - const injector = resolveInjector( - createReadonlySelectionSignal, - options?.injector, - ) - - return runInInjectionContext(injector, () => { - const destroyRef = inject(DestroyRef) - const compare = options?.compare ?? defaultCompare - const { - injector: _injector, - compare: _compare, - ...signalOptions - } = options ?? {} - const slice = linkedSignal(() => selector(source.get()), { - ...signalOptions, - equal: compare, - }) - - const { unsubscribe } = source.subscribe((state) => { - slice.set(selector(state)) - }) - - destroyRef.onDestroy(() => { - unsubscribe() - }) - - return slice.asReadonly() - }) -} /** * Selects a slice of state from an atom or store and returns it as an Angular @@ -91,10 +53,30 @@ function createReadonlySelectionSignal( * ``` */ export function injectSelector>( - source: SelectionSource, + source: SelectionSource | (() => SelectionSource), selector: (state: NoInfer) => TSelected = (d) => d as unknown as TSelected, options?: InjectSelectorOptions, ): Signal { - return createReadonlySelectionSignal(source, selector, options) + const injector = resolveInjector( + injectSelector, + options?.injector, + ) + + return runInInjectionContext(injector, () => { + const _source = typeof source === "function" ? source : (() => source) + + const slice = linkedSignal(() => selector(_source().get()), { + equal: options?.compare, + }) + + effect(() => { + const { unsubscribe } = _source().subscribe((state) => { + slice.set(selector(state)) + }) + return unsubscribe + }) + + return slice.asReadonly() + }) } diff --git a/packages/angular-store/tests/index.test.ts b/packages/angular-store/tests/index.test.ts index 0dfe1ac3..e8e8666d 100644 --- a/packages/angular-store/tests/index.test.ts +++ b/packages/angular-store/tests/index.test.ts @@ -1,7 +1,16 @@ import { describe, expect, test } from 'vitest' -import { Component, effect } from '@angular/core' +import { + Component, + computed, + effect, + input, + inputBinding, + signal, + untracked, +} from '@angular/core' import { TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' +import { render } from '@testing-library/angular' import { Store, createAtom, createStore } from '@tanstack/store' import { _injectStore, @@ -12,6 +21,10 @@ import { } from '../src/index' import type { Atom } from '@tanstack/store' +function createStableSignal(fn: () => T): () => T { + return computed(() => untracked(fn)) +} + describe('atom hooks', () => { test('injectSelector reads mutable atom state and rerenders when updated', () => { const atom = createAtom(0) @@ -115,6 +128,34 @@ describe('atom hooks', () => { expect(fixture.nativeElement.textContent).toContain('Value: 0') }) + + test('injectAtom supports atoms created from input signals', async () => { + @Component({ + template: `

{{ doubled() }}

`, + standalone: true, + }) + class AtomFromInputChildCmp { + value = input.required() + atom = createStableSignal(() => createAtom(this.value() * 2)) + doubled = injectAtom(this.atom) + + constructor() { + effect(() => { + this.doubled.set(this.value() * 2) + }) + } + } + + const value = signal(3) + const { getByText, findByText } = await render(AtomFromInputChildCmp, { + bindings: [inputBinding('value', value)], + }) + + expect(getByText('6')).toBeInTheDocument() + + value.set(4) + expect(await findByText('8')).toBeInTheDocument() + }) }) describe('selector hooks', () => { @@ -364,6 +405,32 @@ describe('selector hooks', () => { fixture.debugElement.query(By.css('p#derived')).nativeElement.textContent, ).toContain('2') }) + + test('injectSelector supports selectors that read input signals', async () => { + const selectorReadsInputStore = createStore({ cats: 2, dogs: 4 }) + + @Component({ + template: `

{{ count() }}

`, + standalone: true, + }) + class SelectorReadsInputChildCmp { + animal = input.required<'cats' | 'dogs'>() + count = injectSelector( + selectorReadsInputStore, + (state) => state[this.animal()], + ) + } + + const animal = signal<'cats' | 'dogs'>('cats') + const { getByText, findByText } = await render(SelectorReadsInputChildCmp, { + bindings: [inputBinding('animal', animal)], + }) + + expect(getByText('2')).toBeInTheDocument() + + animal.set('dogs') + expect(await findByText('4')).toBeInTheDocument() + }) }) describe('injectStore', () => { diff --git a/packages/angular-store/tests/test-setup.ts b/packages/angular-store/tests/test-setup.ts index cb5fd340..e17b53c6 100644 --- a/packages/angular-store/tests/test-setup.ts +++ b/packages/angular-store/tests/test-setup.ts @@ -1,12 +1,5 @@ -import '@analogjs/vite-plugin-angular/setup-vitest' +import '@testing-library/jest-dom/vitest' +import '@angular/compiler' +import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed' -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting, -} from '@angular/platform-browser-dynamic/testing' -import { getTestBed } from '@angular/core/testing' - -getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), -) +setupTestBed() diff --git a/packages/angular-store/tsconfig.spec.json b/packages/angular-store/tsconfig.spec.json new file mode 100644 index 00000000..500e10cf --- /dev/null +++ b/packages/angular-store/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "types": ["vitest/globals", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["tests/**/*.ts"] +} diff --git a/packages/angular-store/vitest.config.ts b/packages/angular-store/vitest.config.ts index 07256bd8..12bcfcb8 100644 --- a/packages/angular-store/vitest.config.ts +++ b/packages/angular-store/vitest.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from 'vitest/config' +import angular from '@analogjs/vite-plugin-angular' import packageJson from './package.json' export default defineConfig({ + plugins: [angular({ tsconfig: './tsconfig.spec.json' })], test: { name: packageJson.name, dir: './tests', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2796cc23..270af209 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -959,6 +959,9 @@ importers: '@analogjs/vite-plugin-angular': specifier: ^2.4.5 version: 2.4.5(8a49bd0b443111e2f7e24ed14753c56f) + '@analogjs/vitest-angular': + specifier: ^2.3.1 + version: 2.4.8(@analogjs/vite-plugin-angular@2.4.5(8a49bd0b443111e2f7e24ed14753c56f))(@angular-devkit/architect@0.2102.7(chokidar@5.0.0))(@angular-devkit/schematics@21.2.7(chokidar@5.0.0))(vitest@4.1.4)(zone.js@0.16.1) '@angular/common': specifier: ^21.2.8 version: 21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2) @@ -971,9 +974,12 @@ importers: '@angular/platform-browser': specifier: ^21.2.8 version: 21.2.8(@angular/animations@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)) - '@angular/platform-browser-dynamic': - specifier: ^21.2.8 - version: 21.2.8(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/compiler@21.2.8)(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/platform-browser@21.2.8(@angular/animations@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))) + '@testing-library/angular': + specifier: ^19.1.1 + version: 19.2.1(693b1d0eacad9113f48af6b0f6cdf656) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 zone.js: specifier: ^0.16.1 version: 0.16.1 @@ -1197,6 +1203,18 @@ packages: '@angular/build': optional: true + '@analogjs/vitest-angular@2.4.8': + resolution: {integrity: sha512-gO4UmyXFXUum19sUP+V/phWLwI/CRYXPWZPYglf0HY+VTx8SIOPnw9Rw9KaeHiF3fI4ySzTezcS2sm1km762NA==} + peerDependencies: + '@analogjs/vite-plugin-angular': '*' + '@angular-devkit/architect': '>=0.1500.0 < 0.2200.0' + '@angular-devkit/schematics': '>=17.0.0' + vitest: ^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 + zone.js: '>=0.14.0' + peerDependenciesMeta: + zone.js: + optional: true + '@angular-devkit/architect@0.2102.7': resolution: {integrity: sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -4126,6 +4144,15 @@ packages: resolution: {integrity: sha512-wVT2YfKDSpd+4f7fk6UaPIP3a2J7LSovlyVuFF1PH2yQb7gjqehod5zdFiwFyEXgvI9XGuFvvs1OehkKNYcr6A==} engines: {node: '>=18'} + '@testing-library/angular@19.2.1': + resolution: {integrity: sha512-COWnkcTKFwb4fReLlInNATH1cPYmujWINnVMXdy0oJHidz0XIrdJopx/jwCBDIAgD4qtz+wEDsUWM4gI78Hakw==} + peerDependencies: + '@angular/common': '>= 21.0.0' + '@angular/core': '>= 21.0.0' + '@angular/platform-browser': '>= 21.0.0' + '@angular/router': '>= 21.0.0' + '@testing-library/dom': ^10.0.0 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -9297,6 +9324,15 @@ snapshots: '@angular-devkit/build-angular': 21.2.7(@angular/compiler-cli@21.2.8(@angular/compiler@21.2.8)(typescript@6.0.2))(@angular/compiler@21.2.8)(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/platform-browser@21.2.8(@angular/animations@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.6.0)(chokidar@5.0.0)(jiti@2.6.1)(lightningcss@1.32.0)(ng-packagr@21.2.2(@angular/compiler-cli@21.2.8(@angular/compiler@21.2.8)(typescript@6.0.2))(tslib@2.8.1)(typescript@6.0.2))(tsx@4.21.0)(typescript@6.0.2)(vitest@4.1.4)(yaml@2.8.3) '@angular/build': 21.2.7(@angular/compiler-cli@21.2.8(@angular/compiler@21.2.8)(typescript@6.0.2))(@angular/compiler@21.2.8)(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/platform-browser@21.2.8(@angular/animations@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.6.0)(chokidar@5.0.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(ng-packagr@21.2.2(@angular/compiler-cli@21.2.8(@angular/compiler@21.2.8)(typescript@6.0.2))(tslib@2.8.1)(typescript@6.0.2))(postcss@8.5.8)(terser@5.46.0)(tslib@2.8.1)(tsx@4.21.0)(typescript@6.0.2)(vitest@4.1.4)(yaml@2.8.3) + '@analogjs/vitest-angular@2.4.8(@analogjs/vite-plugin-angular@2.4.5(8a49bd0b443111e2f7e24ed14753c56f))(@angular-devkit/architect@0.2102.7(chokidar@5.0.0))(@angular-devkit/schematics@21.2.7(chokidar@5.0.0))(vitest@4.1.4)(zone.js@0.16.1)': + dependencies: + '@analogjs/vite-plugin-angular': 2.4.5(8a49bd0b443111e2f7e24ed14753c56f) + '@angular-devkit/architect': 0.2102.7(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.7(chokidar@5.0.0) + vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-istanbul@4.1.4)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(less@4.6.4)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + optionalDependencies: + zone.js: 0.16.1 + '@angular-devkit/architect@0.2102.7(chokidar@5.0.0)': dependencies: '@angular-devkit/core': 21.2.7(chokidar@5.0.0) @@ -12208,6 +12244,15 @@ snapshots: transitivePeerDependencies: - typescript + '@testing-library/angular@19.2.1(693b1d0eacad9113f48af6b0f6cdf656)': + dependencies: + '@angular/common': 21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2) + '@angular/core': 21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1) + '@angular/platform-browser': 21.2.8(@angular/animations@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)) + '@angular/router': 21.2.8(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/platform-browser@21.2.8(@angular/animations@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@21.2.8(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.8(@angular/compiler@21.2.8)(rxjs@7.8.2)(zone.js@0.16.1)))(rxjs@7.8.2) + '@testing-library/dom': 10.4.1 + tslib: 2.8.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 From f7603d5d522a52fc70f9855ddd8d38a92c4de33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 19 Apr 2026 17:24:11 -0400 Subject: [PATCH 2/3] alternative _injectStore for angular --- .../reference/functions/injectStore.md | 20 +++---- .../store-actions/src/app/app.component.ts | 8 +-- packages/angular-store/src/_injectStore.ts | 55 ++++++++++++++----- packages/angular-store/tests/index.test.ts | 21 ++++--- packages/angular-store/tests/test.test-d.ts | 18 +++--- 5 files changed, 76 insertions(+), 46 deletions(-) diff --git a/docs/framework/angular/reference/functions/injectStore.md b/docs/framework/angular/reference/functions/injectStore.md index b88a398b..c5c0df00 100644 --- a/docs/framework/angular/reference/functions/injectStore.md +++ b/docs/framework/angular/reference/functions/injectStore.md @@ -9,16 +9,16 @@ title: _injectStore function _injectStore( store, selector, - options?): [Signal, [TActions] extends [never] ? (updater) => void : TActions]; +options?): WritableStoreSliceSignal; ``` -Defined in: [packages/angular-store/src/\_injectStore.ts:24](https://github.com/TanStack/store/blob/main/packages/angular-store/src/_injectStore.ts#L24) +Defined in: [packages/angular-store/src/\_injectStore.ts:34](https://github.com/TanStack/store/blob/main/packages/angular-store/src/_injectStore.ts#L34) Experimental combined read+write injection function for stores, mirroring injectAtom's pattern. -Returns `[signal, actions]` when the store has an actions factory, or -`[signal, setState]` for plain stores. +Returns a callable slice with methods when the store has an actions factory, or +with only the setState method for plain stores. ## Type Parameters @@ -38,7 +38,7 @@ Returns `[signal, actions]` when the store has an actions factory, or ### store -`Store`\<`TState`, `TActions`\> +`Store`\<`TState`, `TActions`\> | () => `Store`\<`TState`, `TActions`\> ### selector @@ -50,16 +50,16 @@ Returns `[signal, actions]` when the store has an actions factory, or ## Returns -\[`Signal`\<`TSelected`\>, \[`TActions`\] *extends* \[`never`\] ? (`updater`) => `void` : `TActions`\] +`WritableStoreSliceSignal`\<`TState`, `TSelected`, `TActions`\> ## Example ```ts // Store with actions -readonly result = _injectStore(petStore, (s) => s.cats) -// result[0] is Signal, result[1] is actions +readonly dogs = _injectStore(petStore, (s) => s.dogs) +// dogs() and dogs.addDog() // Store without actions -readonly result = _injectStore(plainStore, (s) => s) -// result[0] is Signal, result[1] is setState +readonly value = _injectStore(plainStore, (s) => s) +// value() and value.setState(...) ``` diff --git a/examples/angular/store-actions/src/app/app.component.ts b/examples/angular/store-actions/src/app/app.component.ts index f42e19cb..1ba7ed56 100644 --- a/examples/angular/store-actions/src/app/app.component.ts +++ b/examples/angular/store-actions/src/app/app.component.ts @@ -46,7 +46,7 @@ const petStore = createStore(

Dogs: {{ dogs() }}

-
@@ -59,10 +59,8 @@ export class AppComponent { cats = injectSelector(petStore, (state) => state.cats) addCat = petStore.actions.addCat - // _injectStore gives both the selected signal and actions in a single tuple - private dogResult = _injectStore(petStore, (state) => state.dogs) - dogs = this.dogResult[0] - dogActions = this.dogResult[1] + // _injectStore: callable slice for reads; action methods for writes + dogs = _injectStore(petStore, (state) => state.dogs) total = injectSelector(petStore, (state) => state.cats + state.dogs) diff --git a/packages/angular-store/src/_injectStore.ts b/packages/angular-store/src/_injectStore.ts index 2a6f9714..6dc16104 100644 --- a/packages/angular-store/src/_injectStore.ts +++ b/packages/angular-store/src/_injectStore.ts @@ -1,24 +1,34 @@ +import { untracked } from '@angular/core' import { injectSelector } from './injectSelector' import type { Signal } from '@angular/core' import type { Store, StoreActionMap } from '@tanstack/store' import type { InjectSelectorOptions } from './injectSelector' +type WritableStoreSliceSignal< + TState, + TSelected, + TActions extends StoreActionMap, +> = Signal & + ([TActions] extends [never] + ? Pick, 'setState'> + : TActions) + /** * Experimental combined read+write injection function for stores, mirroring * injectAtom's pattern. - * - * Returns `[signal, actions]` when the store has an actions factory, or - * `[signal, setState]` for plain stores. + * + * Returns a callable slice with methods when the store has an actions factory, or + * with only the setState method for plain stores. * * @example * ```ts * // Store with actions - * readonly result = _injectStore(petStore, (s) => s.cats) - * // result[0] is Signal, result[1] is actions + * readonly dogs = _injectStore(petStore, (s) => s.dogs) + * // dogs() and dogs.addDog() * * // Store without actions - * readonly result = _injectStore(plainStore, (s) => s) - * // result[0] is Signal, result[1] is setState + * readonly value = _injectStore(plainStore, (s) => s) + * // value() and value.setState(...) * ``` */ export function _injectStore< @@ -26,16 +36,31 @@ export function _injectStore< TActions extends StoreActionMap, TSelected = NoInfer, >( - store: Store, + store: Store | (() => Store), selector: (state: NoInfer) => TSelected, options?: InjectSelectorOptions, -): [ - Signal, - [TActions] extends [never] ? Store['setState'] : TActions, -] { +): WritableStoreSliceSignal { const selected = injectSelector(store, selector, options) - const actionsOrSetState = - (store.actions as StoreActionMap | undefined) ?? store.setState - return [selected, actionsOrSetState] as any + return new Proxy(selected, { + apply: () => selected(), + get(_target, prop, receiver) { + const inst = untracked(() => + typeof store === 'function' ? store() : store, + ) + + const actions = inst.actions as StoreActionMap | undefined + + if (actions != null && typeof actions === 'object') { + const method = Reflect.get(actions, prop, actions) + if (Object.hasOwn(actions, prop) && typeof method === 'function') { + return method + } + } else if (prop === 'setState' && typeof inst.setState === 'function') { + return inst.setState + } + + return Reflect.get(selected, prop, receiver) + }, + }) as WritableStoreSliceSignal } diff --git a/packages/angular-store/tests/index.test.ts b/packages/angular-store/tests/index.test.ts index e8e8666d..3dd31d51 100644 --- a/packages/angular-store/tests/index.test.ts +++ b/packages/angular-store/tests/index.test.ts @@ -5,6 +5,7 @@ import { effect, input, inputBinding, + isSignal, signal, untracked, } from '@angular/core' @@ -496,6 +497,14 @@ describe('dataType', () => { }) describe('_injectStore', () => { + test('return value passes isSignal (proxies the selector signal)', () => { + TestBed.runInInjectionContext(() => { + const store = createStore(0) + const slice = _injectStore(store, (s) => s) + expect(isSignal(slice)).toBe(true) + }) + }) + test('returns selected state and actions for stores with actions', () => { const store = createStore({ count: 0 }, ({ setState }) => ({ inc: () => setState((prev) => ({ count: prev.count + 1 })), @@ -511,12 +520,10 @@ describe('_injectStore', () => { standalone: true, }) class MyCmp { - private result = _injectStore(store, (state) => state.count) - count = this.result[0] - actions = this.result[1] + protected count = _injectStore(store, (state) => state.count) inc() { - this.actions.inc() + this.count.inc() } } @@ -550,12 +557,10 @@ describe('_injectStore', () => { standalone: true, }) class MyCmp { - private result = _injectStore(store, (state) => state) - value = this.result[0] - setState = this.result[1] + private value = _injectStore(store, (state) => state) inc() { - this.setState((prev) => prev + 1) + this.value.setState((prev: number) => prev + 1) } } diff --git a/packages/angular-store/tests/test.test-d.ts b/packages/angular-store/tests/test.test-d.ts index 97adf274..a33329b3 100644 --- a/packages/angular-store/tests/test.test-d.ts +++ b/packages/angular-store/tests/test.test-d.ts @@ -73,22 +73,24 @@ test('createStoreContext preserves typed context shape', () => { expectTypeOf(ctx.petStore).toEqualTypeOf>() }) -test('_injectStore returns actions for stores with actions', () => { +test('_injectStore returns callable slice with actions for stores with actions', () => { const store = createStore({ count: 0 }, ({ setState }) => ({ inc: () => setState((prev) => ({ count: prev.count + 1 })), })) - const [selected, actions] = _injectStore(store, (state) => state.count) + const slice = _injectStore(store, (state) => state.count) - expectTypeOf(selected).toEqualTypeOf>() - expectTypeOf(actions.inc).toBeFunction() + expectTypeOf(slice).toEqualTypeOf< + Signal & { inc: () => void } + >() }) -test('_injectStore returns setState for plain stores', () => { +test('_injectStore returns callable slice with setState for plain stores', () => { const store = createStore(0) - const [selected, setState] = _injectStore(store, (state) => state) + const slice = _injectStore(store, (state) => state) - expectTypeOf(selected).toEqualTypeOf>() - expectTypeOf(setState).toEqualTypeOf['setState']>() + expectTypeOf(slice).toEqualTypeOf< + Signal & { setState: Store['setState'] } + >() }) From 3abbb66166f012aa5c5ec1bb3a8d373cf873c145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 19 Apr 2026 17:47:24 -0400 Subject: [PATCH 3/3] fix: correct cleanup and test setup --- packages/angular-store/src/injectSelector.ts | 4 ++-- packages/angular-store/tests/index.test.ts | 2 +- packages/angular-store/tsconfig.spec.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/angular-store/src/injectSelector.ts b/packages/angular-store/src/injectSelector.ts index 054f8d4b..0c8864ec 100644 --- a/packages/angular-store/src/injectSelector.ts +++ b/packages/angular-store/src/injectSelector.ts @@ -70,11 +70,11 @@ export function injectSelector>( equal: options?.compare, }) - effect(() => { + effect((onCleanup) => { const { unsubscribe } = _source().subscribe((state) => { slice.set(selector(state)) }) - return unsubscribe + onCleanup(unsubscribe) }) return slice.asReadonly() diff --git a/packages/angular-store/tests/index.test.ts b/packages/angular-store/tests/index.test.ts index 3dd31d51..96d0cf8c 100644 --- a/packages/angular-store/tests/index.test.ts +++ b/packages/angular-store/tests/index.test.ts @@ -557,7 +557,7 @@ describe('_injectStore', () => { standalone: true, }) class MyCmp { - private value = _injectStore(store, (state) => state) + protected value = _injectStore(store, (state) => state) inc() { this.value.setState((prev: number) => prev + 1) diff --git a/packages/angular-store/tsconfig.spec.json b/packages/angular-store/tsconfig.spec.json index 500e10cf..9fd35999 100644 --- a/packages/angular-store/tsconfig.spec.json +++ b/packages/angular-store/tsconfig.spec.json @@ -4,6 +4,6 @@ "noEmit": false, "types": ["vitest/globals", "node"] }, - "files": ["src/test-setup.ts"], + "files": ["tests/test-setup.ts"], "include": ["tests/**/*.ts"] }