Skip to content

Commit

Permalink
feat(signals): extend symbol support
Browse files Browse the repository at this point in the history
Following functions support symbols:
- `withState`
- `withMethods`
- `withComputed`
- Slices (`DeepSignal`)
- `deepFreeze`
- override protection
  • Loading branch information
rainerhahnekamp committed Jan 19, 2025
1 parent c747174 commit 655f7c9
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 34 deletions.
37 changes: 36 additions & 1 deletion modules/signals/spec/deep-freeze.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { TestBed } from '@angular/core/testing';
import { withState } from '../src/with-state';

describe('deepFreeze', () => {
const DIRECT_SECRET = Symbol('direct secret');
const SECRET = Symbol('secret');

const initialState = {
user: {
firstName: 'John',
Expand All @@ -13,6 +16,14 @@ describe('deepFreeze', () => {
foo: 'bar',
numbers: [1, 2, 3],
ngrx: 'signals',
[DIRECT_SECRET]: 'secret',
nestedSymbol: {
[SECRET]: 'another secret',
},
[SECRET]: {
code: 'secret',
value: '123',
},
};

for (const { stateFactory, name } of [
Expand Down Expand Up @@ -71,7 +82,7 @@ describe('deepFreeze', () => {
);
});

it('throws when mutable change happens for', () => {
it('throws when mutable change happens', () => {
const state = stateFactory();
const s = { user: { firstName: 'M', lastName: 'S' } };
patchState(state, s);
Expand All @@ -82,6 +93,30 @@ describe('deepFreeze', () => {
"Cannot assign to read only property 'firstName' of object"
);
});

describe('symbol', () => {
it('throws on a mutable change on property of a frozen symbol', () => {
const state = stateFactory();
const s = getState(state);

expect(() => {
s[SECRET].code = 'mutable change';
}).toThrowError(
"Cannot assign to read only property 'code' of object"
);
});

it('throws on a mutable change on nested symbol', () => {
const state = stateFactory();
const s = getState(state);

expect(() => {
s.nestedSymbol[SECRET] = 'mutable change';
}).toThrowError(
"Cannot assign to read only property 'Symbol(secret)' of object"
);
});
});
});
});
}
Expand Down
93 changes: 92 additions & 1 deletion modules/signals/spec/signal-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { inject, InjectionToken, isSignal, signal } from '@angular/core';
import {
computed,
inject,
InjectionToken,
isSignal,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
patchState,
Expand Down Expand Up @@ -145,6 +151,14 @@ describe('signalStore', () => {

expect(store.foo()).toBe('foo');
});

it('can have symbols as keys as well', () => {
const SECRET = Symbol('SECRET');
const Store = signalStore(withState({ [SECRET]: 'bar' }));
const store = new Store();

expect(store[SECRET]()).toBe('bar');
});
});

describe('withProps', () => {
Expand Down Expand Up @@ -183,6 +197,50 @@ describe('signalStore', () => {

expect(store.foo).toBe('bar');
});

it('allows symbols as props', () => {
const SECRET = Symbol('SECRET');

const Store = signalStore(withProps(() => ({ [SECRET]: 'secret' })));
const store = TestBed.configureTestingModule({
providers: [Store],
}).inject(Store);

expect(store[SECRET]).toBe('secret');
});

it('allows numbers as props', () => {
const Store = signalStore(withProps(() => ({ 1: 'Number One' })));
const store = TestBed.configureTestingModule({
providers: [Store],
}).inject(Store);

expect(store[1]).toBe('Number One');
});

it('passes on a symbol to the features', () => {
const SECRET = Symbol('SECRET');
const SecretStore = signalStore(
{ providedIn: 'root' },
withProps(() => ({
[SECRET]: 'not your business',
})),
withMethods((store) => ({
reveil() {
return store[SECRET];
},
})),
withComputed((state) => ({
secret: computed(() => state[SECRET]),
}))
);

const secretStore = TestBed.inject(SecretStore);

expect(secretStore.reveil()).toBe('not your business');
expect(secretStore.secret()).toBe('not your business');
expect(secretStore[SECRET]).toBe('not your business');
});
});

describe('withComputed', () => {
Expand Down Expand Up @@ -221,6 +279,26 @@ describe('signalStore', () => {

expect(store.bar()).toBe('bar');
});

it('can also expose a symbol', () => {
const SECRET = Symbol('SECRET');
const SecretStore = signalStore(
{ providedIn: 'root' },
withComputed(() => ({
[SECRET]: computed(() => 'secret'),
})),
withMethods((store) => ({
reveil() {
return store[SECRET];
},
}))
);

const secretStore = TestBed.inject(SecretStore);
const secretSignal = secretStore.reveil();

expect(secretSignal()).toBe('secret');
});
});

describe('withMethods', () => {
Expand Down Expand Up @@ -263,6 +341,19 @@ describe('signalStore', () => {

expect(store.baz()).toBe('baz');
});

it('can also expose a symbol', () => {
const SECRET = Symbol('SECRET');
const SecretStore = signalStore(
{ providedIn: 'root' },
withMethods(() => ({
[SECRET]: () => 'my secret',
}))
);
const secretStore = TestBed.inject(SecretStore);

expect(secretStore[SECRET]()).toBe('my secret');
});
});

describe('withHooks', () => {
Expand Down
10 changes: 10 additions & 0 deletions modules/signals/spec/state-source.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
import { STATE_SOURCE } from '../src/state-source';
import { createLocalService } from './helpers';

const SECRET = Symbol('SECRET');

describe('StateSource', () => {
const initialState = {
user: {
Expand All @@ -27,6 +29,7 @@ describe('StateSource', () => {
foo: 'bar',
numbers: [1, 2, 3],
ngrx: 'signals',
[SECRET]: 'secret',
};

describe('patchState', () => {
Expand Down Expand Up @@ -78,6 +81,13 @@ describe('StateSource', () => {
});
});

it('patches property with symbol keys', () => {
const state = stateFactory();

patchState(state, { [SECRET]: 'another secret' });
expect(state[SECRET]()).toBe('another secret');
});

it('patches state via sequence of partial state objects and updater functions', () => {
const state = stateFactory();

Expand Down
8 changes: 7 additions & 1 deletion modules/signals/spec/with-methods.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ describe('withMethods', () => {
});

it('logs warning if previously defined signal store members have the same name', () => {
const STATE_SECRET = Symbol('state_secret');
const COMPUTED_SECRET = Symbol('computed_secret');
const initialStore = [
withState({
p1: 'p1',
p2: false,
[STATE_SECRET]: 1,
}),
withComputed(() => ({
s1: signal(true).asReadonly(),
s2: signal({ s: 2 }).asReadonly(),
[COMPUTED_SECRET]: signal(1).asReadonly(),
})),
withMethods(() => ({
m1() {},
Expand All @@ -43,12 +47,14 @@ describe('withMethods', () => {
s1: () => 100,
m2,
m3: () => 'm3',
[STATE_SECRET]() {},
[COMPUTED_SECRET]() {},
}))(initialStore);

expect(console.warn).toHaveBeenCalledWith(
'@ngrx/signals: SignalStore members cannot be overridden.',
'Trying to override:',
'p2, s1, m2'
'p2, s1, m2, Symbol(state_secret), Symbol(computed_secret)'
);
});
});
23 changes: 1 addition & 22 deletions modules/signals/spec/with-props.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { signal } from '@angular/core';
import { of } from 'rxjs';
import { signalStore, withMethods, withProps, withState } from '../src';
import { withMethods, withProps, withState } from '../src';
import { getInitialInnerStore } from '../src/signal-store';
import { TestBed } from '@angular/core/testing';

describe('withProps', () => {
it('adds properties to the store immutably', () => {
Expand Down Expand Up @@ -48,24 +47,4 @@ describe('withProps', () => {
's1, p2, m1'
);
});

it('allows symbols as props', () => {
const SECRET = Symbol('SECRET');

const Store = signalStore(withProps(() => ({ [SECRET]: 'secret' })));
const store = TestBed.configureTestingModule({ providers: [Store] }).inject(
Store
);

expect(store[SECRET]).toBe('secret');
});

it('allows numbers as props', () => {
const Store = signalStore(withProps(() => ({ 1: 'Number One' })));
const store = TestBed.configureTestingModule({ providers: [Store] }).inject(
Store
);

expect(store[1]).toBe('Number One');
});
});
7 changes: 3 additions & 4 deletions modules/signals/src/deep-freeze.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
declare const ngDevMode: boolean;

export function deepFreeze<T>(target: T): T {
export function deepFreeze<T extends object>(target: T): T {
Object.freeze(target);

const targetIsFunction = typeof target === 'function';

Object.getOwnPropertyNames(target).forEach((prop) => {
// Ignore Ivy properties, ref: https://github.com/ngrx/platform/issues/2109#issuecomment-582689060
if (prop.startsWith('ɵ')) {
Reflect.ownKeys(target).forEach((prop) => {
if (String(prop).startsWith('ɵ')) {
return;
}

Expand Down
6 changes: 3 additions & 3 deletions modules/signals/src/signal-store-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ declare const ngDevMode: unknown;

export function assertUniqueStoreMembers(
store: InnerSignalStore,
newMemberKeys: string[]
newMemberKeys: (string | symbol)[]
): void {
if (!ngDevMode) {
return;
Expand All @@ -15,15 +15,15 @@ export function assertUniqueStoreMembers(
...store.props,
...store.methods,
};
const overriddenKeys = Object.keys(storeMembers).filter((memberKey) =>
const overriddenKeys = Reflect.ownKeys(storeMembers).filter((memberKey) =>
newMemberKeys.includes(memberKey)
);

if (overriddenKeys.length > 0) {
console.warn(
'@ngrx/signals: SignalStore members cannot be overridden.',
'Trying to override:',
overriddenKeys.join(', ')
overriddenKeys.map((key) => String(key)).join(', ')
);
}
}
2 changes: 1 addition & 1 deletion modules/signals/src/with-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function withMethods<
...store.props,
...store.methods,
});
assertUniqueStoreMembers(store, Object.keys(methods));
assertUniqueStoreMembers(store, Reflect.ownKeys(methods));

return {
...store,
Expand Down
2 changes: 1 addition & 1 deletion modules/signals/src/with-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function withState<State extends object>(
return (store) => {
const state =
typeof stateOrFactory === 'function' ? stateOrFactory() : stateOrFactory;
const stateKeys = Object.keys(state);
const stateKeys = Reflect.ownKeys(state);

assertUniqueStoreMembers(store, stateKeys);

Expand Down

0 comments on commit 655f7c9

Please sign in to comment.