Skip to content

Support input signals in Angular adapter#291

Open
benjavicente wants to merge 4 commits into
TanStack:mainfrom
benjavicente:angular-signal-store
Open

Support input signals in Angular adapter#291
benjavicente wants to merge 4 commits into
TanStack:mainfrom
benjavicente:angular-signal-store

Conversation

@benjavicente
Copy link
Copy Markdown

@benjavicente benjavicente commented Mar 12, 2026

🎯 Changes

  • Adds support to input signals by allowing the first argument to be a function that returns the store
  • Correctly supports selectors with input signals by running the selector only in effects and linkedSignal.
  • Migrates angular tests to use testing library, like the other adapters

A full write out can be found here.

The main idea is that in Angular we can't call input signals on component initialization, so this fails:

export function injectRelativeTimestamp(timestamp: Signal<number>) {
  // Throws NG0952/NG0950: no model/input available yet
  const relativeTimestamp = new RelativeTime(timestamp());
}

By allowing a function/signal, we can allow consumers to lazy initialize the store, demonstrated by a couple of tests where createStableSignal (computed(() => untracked(fn))) is used. A previous PR I made #285 added that helper in the library, but since in React we are using useState or useRef without a helper to do the same idea (keeping a stable reference to the store), this PR does not provide a similar helper. Consumers are expected to keep the store getter function/signal stable.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • New Features

    • Atoms, selectors and stores can be provided lazily via factory functions.
    • Store injection now returns a callable signal-like slice that combines value access with actions (or setState).
  • Documentation

    • Updated signatures and examples to reflect lazy inputs and the callable slice API.
  • Tests / Tooling

    • Tests migrated to Angular signal APIs and centralized test runner; typings and test cases updated.
  • Examples

    • Example app wiring adjusted to use the new callable slice.
  • Chores

    • Test config and dev tooling dependencies updated.

Review Change Stack

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 12, 2026

🦋 Changeset detected

Latest commit: 38ccc0b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@tanstack/angular-store Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@benjavicente benjavicente mentioned this pull request Mar 12, 2026
4 tasks
@KevinVandy KevinVandy requested a review from crutchcorn March 12, 2026 18:56
@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Mar 12, 2026

View your CI Pipeline Execution ↗ for commit 38ccc0b


☁️ Nx Cloud last updated this comment at 2026-03-12 18:57:06 UTC

@benjavicente benjavicente force-pushed the angular-signal-store branch from 38ccc0b to f7603d5 Compare April 19, 2026 21:28
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

This PR accepts lazy factory inputs across injectAtom/injectSelector/_injectStore, changes _injectStore to return a callable proxy signal exposing actions or setState, rewrites selector subscription to use Angular effects, updates docs/examples, and migrates tests to an AnalogJS Vitest Angular testbed.

Changes

Angular store changes

Layer / File(s) Summary
Changeset entry
.changeset/eight-ways-dig.md
Added patch changeset documenting support for input signals.
Docs: updated signatures
docs/framework/angular/reference/functions/injectAtom.md, docs/framework/angular/reference/functions/injectSelector.md, docs/framework/angular/reference/functions/injectStore.md
Documentation signatures expanded to accept factory forms (e.g., `Atom
_injectStore: callable slice
packages/angular-store/src/_injectStore.ts
Added WritableStoreSliceSignal type; _injectStore now accepts lazy store and returns a callable Proxy that reads selected value and forwards properties to store actions or setState.
injectAtom: lazy setter
packages/angular-store/src/injectAtom.ts
injectAtom accepts `Atom
injectSelector: effect-based subscription
packages/angular-store/src/injectSelector.ts
injectSelector accepts thunk-or-source, uses Angular effect to subscribe/teardown, and sets equality only from options?.compare.
Example app
examples/angular/store-actions/src/app/app.component.ts
Updated example to use callable slice API (dogs.addDog()), removed tuple destructuring and separate action fields.
Package config
packages/angular-store/package.json
Updated devDependencies: removed @angular/platform-browser-dynamic; added @analogjs/vitest-angular, @testing-library/angular, @testing-library/jest-dom.
Tests & testbed / config
packages/angular-store/tests/*, packages/angular-store/tsconfig.spec.json, packages/angular-store/vitest.config.ts
Switched test setup to @analogjs/vitest-angular setupTestBed(); added tests for lazy atom/selector inputs and callable slice behavior; updated type tests to assert `Signal & { actions

Sequence Diagram(s)

sequenceDiagram
  participant Component
  participant SliceProxy
  participant StoreInstance
  participant Actions

  Component->>SliceProxy: call slice() (read selected value)
  SliceProxy->>StoreInstance: resolve lazy store (untracked) / read selected signal
  StoreInstance-->>SliceProxy: selected value
  SliceProxy-->>Component: return value

  Component->>SliceProxy: call slice.someAction(...) (e.g., addDog)
  SliceProxy->>Actions: forward to store.actions.someAction
  Actions-->>StoreInstance: mutate state
  StoreInstance-->>SliceProxy: selected signal updates
  SliceProxy-->>Component: new value on subsequent slice() calls
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I nibbled code and found a cozy way to play,
Factories hum softly, signals hop and sway,
A proxy learned to answer when the slice was called,
Actions and setState now rest where they belong, installed,
Tests tucked in a new bed—how bright the harvest day!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Support input signals in Angular adapter' directly and clearly summarizes the main objective of the PR—adding support for input signals to the Angular store adapter, which is the primary feature being introduced.
Description check ✅ Passed The PR description fully addresses the template requirements with a comprehensive Changes section explaining the feature and its motivation, both checklist items properly checked, and the Release Impact section indicating a changeset was generated for published code changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: lockfile failed supply-chain policy check. Run pnpm install locally to update the lockfile.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/angular-store/tests/index.test.ts (1)

133-159: Clarify the createStableSignal + effect pattern with a comment.

The atom is created exactly once from the initial input value (due to untracked in createStableSignal), and subsequent updates are propagated through the explicit effect that calls this.doubled.set(...). This is subtle and the exact pattern consumers are expected to follow per the PR notes — a one-line comment in the test would help future readers understand why both pieces are needed (and why simply reading this.value() inside createAtom(...) would not react).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/angular-store/tests/index.test.ts` around lines 133 - 159, Add a
one-line comment in the test above AtomFromInputChildCmp explaining that
createStableSignal/untracked ensures the createAtom call runs only once (so the
atom is created from the initial input value), and that the separate effect
calling this.doubled.set(this.value() * 2) is required to propagate subsequent
input changes to the injected atom (i.e. why reading this.value() inside
createAtom would not make it reactive). Reference the createStableSignal,
createAtom, injectAtom, and effect usage to make the intent clear to future
readers.
docs/framework/angular/reference/functions/injectStore.md (1)

12-65: Docs accurately reflect the new WritableStoreSliceSignal return.

One small suggestion: mention in the "Returns" section that the result is also a Signal<TSelected> (callable) so readers immediately grasp the dual nature, rather than only seeing it in the example. Optional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/framework/angular/reference/functions/injectStore.md` around lines 12 -
65, Update the "Returns" section for _injectStore/WritableStoreSliceSignal to
explicitly state the return value is also a callable Signal<TSelected> (i.e., it
behaves as Signal<TSelected> and exposes writable slice methods or setState
depending on the store), so readers see the dual nature immediately; mention the
generic form WritableStoreSliceSignal<TState, TSelected, TActions> is also a
Signal<TSelected> and is callable (e.g., value()) in addition to its methods.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/angular-store/src/injectSelector.ts`:
- Around line 73-78: The effect currently returns the unsubscribe function which
Angular's effect ignores, leaking subscriptions; change the effect callback to
accept the onCleanup parameter and use it to tear down the subscription: when
calling _source().subscribe(...) keep the Subscription (or its unsubscribe
method) and call onCleanup(() => subscription.unsubscribe()) so each re-run and
injector destruction properly unsubscribes and prevents multiple slice.set calls
from stale subscriptions.

In `@packages/angular-store/tests/index.test.ts`:
- Around line 560-564: Change the member visibility of value from private to
protected so the template can access it consistently with the sibling test;
locate the class that defines "private value = _injectStore(store, (state) =>
state)" and update the declaration to "protected value" (keep the rest of the
code, including the inc() method and the use of _injectStore, unchanged).

In `@packages/angular-store/tsconfig.spec.json`:
- Line 7: The tsconfig.spec.json currently lists a non-existent setup path
("src/test-setup.ts"); update the "files" entry in tsconfig.spec.json to point
to the actual test setup file ("tests/test-setup.ts") so TypeScript typechecking
uses the same setup used by vitest (setupFiles: ['./tests/test-setup.ts']).
Locate the "files" array in tsconfig.spec.json and replace "src/test-setup.ts"
with "tests/test-setup.ts".

---

Nitpick comments:
In `@docs/framework/angular/reference/functions/injectStore.md`:
- Around line 12-65: Update the "Returns" section for
_injectStore/WritableStoreSliceSignal to explicitly state the return value is
also a callable Signal<TSelected> (i.e., it behaves as Signal<TSelected> and
exposes writable slice methods or setState depending on the store), so readers
see the dual nature immediately; mention the generic form
WritableStoreSliceSignal<TState, TSelected, TActions> is also a
Signal<TSelected> and is callable (e.g., value()) in addition to its methods.

In `@packages/angular-store/tests/index.test.ts`:
- Around line 133-159: Add a one-line comment in the test above
AtomFromInputChildCmp explaining that createStableSignal/untracked ensures the
createAtom call runs only once (so the atom is created from the initial input
value), and that the separate effect calling this.doubled.set(this.value() * 2)
is required to propagate subsequent input changes to the injected atom (i.e. why
reading this.value() inside createAtom would not make it reactive). Reference
the createStableSignal, createAtom, injectAtom, and effect usage to make the
intent clear to future readers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1554c20-9661-44e0-bbe1-8aa133837652

📥 Commits

Reviewing files that changed from the base of the PR and between 83e2978 and f7603d5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • .changeset/eight-ways-dig.md
  • docs/framework/angular/reference/functions/injectAtom.md
  • docs/framework/angular/reference/functions/injectSelector.md
  • docs/framework/angular/reference/functions/injectStore.md
  • examples/angular/store-actions/src/app/app.component.ts
  • packages/angular-store/package.json
  • packages/angular-store/src/_injectStore.ts
  • packages/angular-store/src/injectAtom.ts
  • packages/angular-store/src/injectSelector.ts
  • packages/angular-store/tests/index.test.ts
  • packages/angular-store/tests/test-setup.ts
  • packages/angular-store/tests/test.test-d.ts
  • packages/angular-store/tsconfig.spec.json
  • packages/angular-store/vitest.config.ts

Comment thread packages/angular-store/src/injectSelector.ts Outdated
Comment thread packages/angular-store/tests/index.test.ts Outdated
Comment thread packages/angular-store/tsconfig.spec.json Outdated
@benjavicente
Copy link
Copy Markdown
Author

Updated the PR with the changes to the public api (from store -> to select selector and atom).

Also updated the experimental injectStore with a proxy pattern instead of the tuple pattern. The tuple pattern is undesirable in Angular since you can't destructure in a class body, and thanks to that, Angula's APIs don't use destructuring.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/angular-store/tests/index.test.ts (1)

500-506: Add _injectStore coverage for lazy store factories.

Current _injectStore tests cover direct stores, but the PR’s new lazy-source contract also needs a proxy-path regression test, especially for .inc()/.setState access through the proxy get trap.

Suggested additional test coverage
   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('supports lazy store factories when exposing actions', async () => {
+    `@Component`({
+      template: `
+        <p>{{ count() }}</p>
+        <button id="inc" (click)="count.inc()">Inc</button>
+      `,
+      standalone: true,
+    })
+    class LazyStoreCmp {
+      initial = input.required<number>()
+      store = createStableSignal(() =>
+        createStore({ count: this.initial() }, ({ setState }) => ({
+          inc: () => setState((prev) => ({ count: prev.count + 1 })),
+        })),
+      )
+      count = _injectStore(this.store, (state) => state.count)
+    }
+
+    const initial = signal(1)
+    const { getByText, container } = await render(LazyStoreCmp, {
+      bindings: [inputBinding('initial', initial)],
+    })
+
+    expect(getByText('1')).toBeInTheDocument()
+    container.querySelector<HTMLButtonElement>('button#inc')?.click()
+    expect(await screen.findByText('2')).toBeInTheDocument()
+  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/angular-store/tests/index.test.ts` around lines 500 - 506, Add a
test that exercises _injectStore with a lazy store factory (instead of a direct
store) to ensure the proxy get trap correctly forwards methods and signals;
specifically, inside TestBed.runInInjectionContext create a lazy factory that
returns createStore(0) (or similar), call _injectStore with that factory, then
assert isSignal on the returned slice and also call proxyed methods like .inc()
and .setState (or the store's mutation methods) through the proxy and verify the
underlying store state changes — target the functions _injectStore, createStore,
and TestBed.runInInjectionContext when adding this regression test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/angular-store/tests/index.test.ts`:
- Around line 500-506: Add a test that exercises _injectStore with a lazy store
factory (instead of a direct store) to ensure the proxy get trap correctly
forwards methods and signals; specifically, inside TestBed.runInInjectionContext
create a lazy factory that returns createStore(0) (or similar), call
_injectStore with that factory, then assert isSignal on the returned slice and
also call proxyed methods like .inc() and .setState (or the store's mutation
methods) through the proxy and verify the underlying store state changes —
target the functions _injectStore, createStore, and
TestBed.runInInjectionContext when adding this regression test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d103ae0e-5534-4207-a8fc-6db4240d47fe

📥 Commits

Reviewing files that changed from the base of the PR and between f7603d5 and 3abbb66.

📒 Files selected for processing (3)
  • packages/angular-store/src/injectSelector.ts
  • packages/angular-store/tests/index.test.ts
  • packages/angular-store/tsconfig.spec.json
✅ Files skipped from review due to trivial changes (1)
  • packages/angular-store/tsconfig.spec.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/angular-store/src/injectSelector.ts

@benjavicente benjavicente force-pushed the angular-signal-store branch from 3abbb66 to f85dcb2 Compare May 26, 2026 00:24
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 26, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​testing-library/​angular@​19.3.010010010096100
Added@​analogjs/​vitest-angular@​2.5.29910010096100

View full report

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/angular-store/src/injectAtom.ts`:
- Around line 50-55: The wrapper currently forwards to _atom.set(updaterOrValue
as never) which discards the proper Atom<T>['set'] typing; update injectAtom so
value.set (declared as WritableAtomSignal<T>) forwards the argument with the
correct type instead of casting to never — for example, keep value.set signature
as Atom<T>['set'] and call _atom.set(updaterOrValue as
Parameters<Atom<T>['set']>[0]) or generically type injectAtom so _atom.set
accepts the same parameter type; change the forwarding in value.set (the wrapper
around _atom.set) to preserve Atom['set'] typing rather than using never.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 277ccddf-b10a-4275-9507-2dcb6d26fc58

📥 Commits

Reviewing files that changed from the base of the PR and between f85dcb2 and 4043047.

📒 Files selected for processing (1)
  • packages/angular-store/src/injectAtom.ts

Comment thread packages/angular-store/src/injectAtom.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant