Skip to content

feat(signals): allow user-defined signals in withState and signalState by splitting STATE_SOURCE #4795

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b61366b
feat(signals)!: allow user-defined signals in `withState` and `signal…
rainerhahnekamp Apr 19, 2025
c9060d9
feat(signals): warn on optional properties
rainerhahnekamp Jun 1, 2025
68367cc
refactor(signals): rename `StripWritableSignals` to `StateResult`
rainerhahnekamp Jun 1, 2025
1388b86
fix(signals): ensure `patchState` only patches affected signals
rainerhahnekamp Jun 1, 2025
b047ff0
refactor(signals): remove unnecessary code comments
rainerhahnekamp Jun 1, 2025
423a0ea
refactor(signals): inline `stateAsRecord`
rainerhahnekamp Jun 1, 2025
49fc004
fix(signals): ESLint issue with using `number` as variable name
rainerhahnekamp Jun 1, 2025
cf0093e
fix(signals): check for `ngDevMode` properly
rainerhahnekamp Jun 1, 2025
5d271b5
feat(signals): add user-defined signals for `signalState`
rainerhahnekamp Jun 4, 2025
5784c89
docs(signals): add user-defined signals for `signalState`
rainerhahnekamp Jun 4, 2025
f0ff302
Update projects/ngrx.io/content/guide/signals/signal-state.md
rainerhahnekamp Jun 4, 2025
ee3d751
tests: add test verifying further breaking change
rainerhahnekamp Jun 7, 2025
9d6b09b
Update projects/www/src/app/pages/guide/signals/signal-state.md
rainerhahnekamp Jun 9, 2025
8ee9d11
Update projects/www/src/app/pages/guide/signals/signal-state.md
rainerhahnekamp Jun 13, 2025
52bf86d
Update modules/signals/spec/state-source.spec.ts
rainerhahnekamp Jun 23, 2025
43b94c3
Update modules/signals/spec/state-source.spec.ts
rainerhahnekamp Jun 23, 2025
584c875
Update modules/signals/spec/state-source.spec.ts
rainerhahnekamp Jun 23, 2025
26f5acd
Update modules/signals/spec/state-source.spec.ts
rainerhahnekamp Jun 23, 2025
d1e99ae
Update modules/signals/src/state-source.ts
rainerhahnekamp Jun 23, 2025
ee9abb6
Update modules/signals/src/state-source.ts
rainerhahnekamp Jun 24, 2025
17cb345
feat(signals): remove unused imports
rainerhahnekamp Jun 24, 2025
9a79b14
feat(signals): rely on signal's internal equal fn in `patchState`
rainerhahnekamp Jun 24, 2025
b295d18
feat(signals): add further tests and remove unused imports
rainerhahnekamp Jun 24, 2025
ac8fce3
docs(signals): update docs
rainerhahnekamp Jun 24, 2025
6b67dd6
Update modules/signals/src/state-source.ts
markostanimirovic Jun 29, 2025
786757b
Update projects/ngrx.io/content/guide/signals/signal-store/index.md
markostanimirovic Jun 29, 2025
0b37e73
Update projects/ngrx.io/content/guide/signals/signal-store/index.md
markostanimirovic Jun 29, 2025
5e85aee
Update modules/signals/spec/state-source.spec.ts
markostanimirovic Jun 29, 2025
2484d5a
Update projects/www/src/app/pages/guide/signals/signal-state.md
markostanimirovic Jun 29, 2025
5b95c95
Merge branch 'main' into signals/feat/user-defined-signals-in-state
markostanimirovic Jun 29, 2025
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
24 changes: 24 additions & 0 deletions modules/signals/spec/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component, inject, Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { SignalsDictionary } from '../src/signal-store-models';

export function createLocalService<Service extends Type<unknown>>(
serviceToken: Service
@@ -30,3 +31,26 @@ export function createLocalService<Service extends Type<unknown>>(
destroy: () => fixture.destroy(),
};
}

/**
* This could be done by using `getState`, but
* 1. We don't want to depend on the implementation of `getState` in the test.
* 2. We want to be able to provide the state in its actual type (with slice signals).
*/
export function assertStateSource(
state: SignalsDictionary,
expected: SignalsDictionary
): void {
const stateKeys = Reflect.ownKeys(state);
const expectedKeys = Reflect.ownKeys(expected);

const currentState = stateKeys.reduce((acc, key) => {
acc[key] = state[key]();
return acc;
}, {} as Record<string | symbol, unknown>);
const expectedState = expectedKeys.reduce((acc, key) => {
acc[key] = expected[key]();
return acc;
}, {} as Record<string | symbol, unknown>);
expect(currentState).toEqual(expectedState);
}
42 changes: 21 additions & 21 deletions modules/signals/spec/signal-state.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { computed } from '@angular/core';
import { effect, isSignal } from '@angular/core';
import { computed, effect, isSignal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalState } from '../src';
import { SignalsDictionary } from '../src/signal-store-models';
import { STATE_SOURCE } from '../src/state-source';

vi.mock('@angular/core', { spy: true });
@@ -21,21 +21,30 @@ describe('signalState', () => {
vi.clearAllMocks();
});

it('has writable state source', () => {
const state = signalState({});
const stateSource = state[STATE_SOURCE];
it('creates its properties as Signals', () => {
const state = signalState({ foo: 'bar' });
const stateSource: SignalsDictionary = state[STATE_SOURCE];

expect(isSignal(stateSource)).toBe(true);
expect(typeof stateSource.update === 'function').toBe(true);
expect(isSignal(state)).toBe(true);
for (const key of Reflect.ownKeys(stateSource)) {
expect(isSignal(stateSource[key])).toBe(true);
expect(typeof stateSource[key].update === 'function').toBe(true);
}
});

it('does not keep the object reference of the initial state', () => {
const state = signalState(initialState);
expect(state()).not.toBe(initialState);
expect(state()).toEqual(initialState);
});

it('creates signals for nested state slices', () => {
const state = signalState(initialState);

expect(state()).toBe(initialState);
expect(state()).toEqual(initialState);
expect(isSignal(state)).toBe(true);

expect(state.user()).toBe(initialState.user);
expect(state.user()).toEqual(initialState.user);
expect(isSignal(state.user)).toBe(true);

expect(state.user.firstName()).toBe(initialState.user.firstName);
@@ -80,20 +89,11 @@ describe('signalState', () => {
expect((state.user.firstName as any).y).toBe(undefined);
});

it('does not modify STATE_SOURCE', () => {
const state = signalState(initialState);

expect((state[STATE_SOURCE] as any).user).toBe(undefined);
expect((state[STATE_SOURCE] as any).foo).toBe(undefined);
expect((state[STATE_SOURCE] as any).numbers).toBe(undefined);
expect((state[STATE_SOURCE] as any).ngrx).toBe(undefined);
});

it('overrides Function properties if state keys have the same name', () => {
const initialState = { name: { length: { length: 'ngrx' }, name: 20 } };
const state = signalState(initialState);

expect(state()).toBe(initialState);
expect(state()).toEqual(initialState);

expect(state.name()).toBe(initialState.name);
expect(isSignal(state.name)).toBe(true);
@@ -190,12 +190,12 @@ describe('signalState', () => {

patchState(state, {});
TestBed.tick();
expect(stateCounter).toBe(2);
expect(stateCounter).toBe(1);
expect(userCounter).toBe(1);

patchState(state, (state) => state);
TestBed.tick();
expect(stateCounter).toBe(3);
expect(stateCounter).toBe(1);
expect(userCounter).toBe(1);
}));
});
11 changes: 8 additions & 3 deletions modules/signals/spec/signal-store-feature.spec.ts
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import {
withState,
} from '../src';
import { STATE_SOURCE } from '../src/state-source';
import { assertStateSource } from './helpers';

describe('signalStoreFeature', () => {
function withCustomFeature1() {
@@ -50,7 +51,7 @@ describe('signalStoreFeature', () => {

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
assertStateSource(store[STATE_SOURCE], { foo: signal('foo') });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('foo1');
expect(store.baz()).toBe('foofoo12');
@@ -65,7 +66,7 @@ describe('signalStoreFeature', () => {

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
assertStateSource(store[STATE_SOURCE], { foo: signal('foo') });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('foo1');
expect(store.m()).toBe('foofoofoo123');
@@ -81,7 +82,11 @@ describe('signalStoreFeature', () => {

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo', foo1: 1, foo2: 2 });
assertStateSource(store[STATE_SOURCE], {
foo: signal('foo'),
foo1: signal(1),
foo2: signal(2),
});
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('foo1');
expect(store.baz()).toBe('foofoo12');
65 changes: 45 additions & 20 deletions modules/signals/spec/signal-store.spec.ts
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ import {
withState,
} from '../src';
import { STATE_SOURCE } from '../src/state-source';
import { createLocalService } from './helpers';
import { assertStateSource, createLocalService } from './helpers';

describe('signalStore', () => {
describe('creation', () => {
@@ -47,42 +47,56 @@ describe('signalStore', () => {
expect(store1.foo()).toBe('bar');
});

it('creates a store with readonly state source by default', () => {
it('creates a store with state source as Record holding slices as signals by default', () => {
const Store = signalStore(withState({ foo: 'bar' }));
const store = new Store();
const stateSource = store[STATE_SOURCE];

expect(isSignal(stateSource)).toBe(true);
expect(stateSource()).toEqual({ foo: 'bar' });
expect(isSignal(stateSource)).toBe(false);
expect(Object.keys(stateSource)).toEqual(['foo']);
expect(isSignal(stateSource.foo)).toBe(true);
assertStateSource(stateSource, {
foo: signal('bar'),
});
});

it('creates a store with readonly state source when protectedState option is true', () => {
it('creates a store with state source as Record holding slices as signals when protectedState option is true', () => {
const Store = signalStore(
{ protectedState: true },
withState({ foo: 'bar' })
);
const store = new Store();
const stateSource = store[STATE_SOURCE];

expect(isSignal(stateSource)).toBe(true);
expect(stateSource()).toEqual({ foo: 'bar' });
expect(isSignal(stateSource)).toBe(false);
expect(Object.keys(stateSource)).toEqual(['foo']);
expect(isSignal(stateSource.foo)).toBe(true);
assertStateSource(stateSource, {
foo: signal('bar'),
});
});

it('creates a store with writable state source when protectedState option is false', () => {
it('creates a store with state source as Record holding slices as writeable signals when protectedState option is false', () => {
const Store = signalStore(
{ protectedState: false },
withState({ foo: 'bar' })
);
const store = new Store();
const stateSource = store[STATE_SOURCE];

expect(isSignal(stateSource)).toBe(true);
expect(stateSource()).toEqual({ foo: 'bar' });
expect(typeof stateSource.update === 'function').toBe(true);
expect(isSignal(stateSource)).toBe(false);
expect(Object.keys(stateSource)).toEqual(['foo']);
expect(isSignal(stateSource.foo)).toBe(true);
assertStateSource(stateSource, {
foo: signal('bar'),
});
expect(typeof stateSource.foo.update === 'function').toBe(true);

patchState(store, { foo: 'baz' });

expect(stateSource()).toEqual({ foo: 'baz' });
assertStateSource(stateSource, {
foo: signal('baz'),
});
});
});

@@ -97,10 +111,11 @@ describe('signalStore', () => {

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({
foo: 'foo',
x: { y: { z: 10 } },
assertStateSource(store[STATE_SOURCE], {
foo: signal('foo'),
x: signal({ y: { z: 10 } }),
});

expect(store.foo()).toBe('foo');
expect(store.x()).toEqual({ y: { z: 10 } });
expect(store.x.y()).toEqual({ z: 10 });
@@ -178,7 +193,9 @@ describe('signalStore', () => {

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
assertStateSource(store[STATE_SOURCE], {
foo: signal('foo'),
});
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.num).toBe(10);
@@ -236,7 +253,9 @@ describe('signalStore', () => {

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
assertStateSource(store[STATE_SOURCE], {
foo: signal('foo'),
});
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.num).toBe(10);
@@ -279,7 +298,9 @@ describe('signalStore', () => {
withMethods(() => ({ baz: () => 'baz' })),
withProps(() => ({ num: 100 })),
withMethods((store) => {
expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
assertStateSource(store[STATE_SOURCE], {
foo: signal('foo'),
});
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.baz()).toBe('baz');
@@ -291,7 +312,9 @@ describe('signalStore', () => {

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
assertStateSource(store[STATE_SOURCE], {
foo: signal('foo'),
});
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.baz()).toBe('baz');
@@ -372,7 +395,9 @@ describe('signalStore', () => {
withProps(() => ({ num: 10 })),
withHooks({
onInit(store) {
expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
assertStateSource(store[STATE_SOURCE], {
foo: signal('foo'),
});
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.baz()).toBe('baz');
Loading
Oops, something went wrong.