Skip to content

Support input signals in Angular adapter#291

Open
benjavicente wants to merge 3 commits intoTanStack:mainfrom
benjavicente:angular-signal-store
Open

Support input signals in Angular adapter#291
benjavicente wants to merge 3 commits intoTanStack: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 and selectors can be provided lazily via factory functions.
    • Store injection now returns a callable signal-like slice that combines value access with store actions.
  • Documentation

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

    • Test suites and setup updated to use Angular signal APIs and a centralized Angular test runner; test typings adjusted.
  • Chores

    • Test config and dev tooling dependencies updated.

@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

coderabbitai bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

This PR makes Angular store inputs lazy-capable (accepting factory functions), replaces _injectStore's tuple return with a callable signal proxy exposing actions/setState, updates selector subscription to use Angular effects, and migrates tests to an AnalogJS-based Vitest Angular testbed.

Changes

Cohort / File(s) Summary
Changeset & Release Note
​.changeset/eight-ways-dig.md
Added patch changeset documenting support for input signals.
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.
Docs: function signatures
docs/framework/angular/reference/functions/injectAtom.md, docs/framework/angular/reference/functions/injectSelector.md, docs/framework/angular/reference/functions/injectStore.md
Signatures updated to accept factory forms (e.g., `Atom
Core: injectAtom / injectSelector
packages/angular-store/src/injectAtom.ts, packages/angular-store/src/injectSelector.ts
Parameters now accept lazy factories; injectSelector uses an effect for subscription lifecycle and no longer forces a default comparator.
Core: _injectStore (callable proxy)
packages/angular-store/src/_injectStore.ts
Return changed from [Signal, actionsOrSetState] to a WritableStoreSliceSignal implemented via Proxy (callable signal + property forwarding to actions or setState); store parameter accepts factory; untracked used for lazy resolution; added exported type alias.
Example app
examples/angular/store-actions/src/app/app.component.ts
Refactored to use callable slice API (dogs.addDog()), removed tuple destructuring and separate action fields.
Tests & test setup
packages/angular-store/tests/index.test.ts, packages/angular-store/tests/test.test-d.ts, packages/angular-store/tests/test-setup.ts, packages/angular-store/tsconfig.spec.json, packages/angular-store/vitest.config.ts
Switched test environment to @analogjs/vitest-angular setup; 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 Store as StoreInstance
  participant Actions

  Component->>SliceProxy: call slice()  (read selected value)
  SliceProxy->>StoreInstance: resolve store (lazy) / 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 (if present)
  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 sprung up, callable and spry,
Actions at paw, setState close by,
Tests snug in a new bed — a bright, bouncy day!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Support input signals in Angular adapter' is clear, concise, and directly reflects the main objective of the PR—enabling lazy initialization of stores via input signal functions.
Description check ✅ Passed The PR description includes a detailed 🎯 Changes section explaining the three main improvements, references supporting documentation, and fully completes the ✅ Checklist and 🚀 Release Impact sections as required by the template.

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

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

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

@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

@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

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