diff --git a/modules/signals/spec/deep-freeze.spec.ts b/modules/signals/spec/deep-freeze.spec.ts index a432c94e25..0e625fccfa 100644 --- a/modules/signals/spec/deep-freeze.spec.ts +++ b/modules/signals/spec/deep-freeze.spec.ts @@ -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', @@ -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 [ @@ -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); @@ -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" + ); + }); + }); }); }); } diff --git a/modules/signals/spec/signal-store.spec.ts b/modules/signals/spec/signal-store.spec.ts index 4d388478b9..3168692b91 100644 --- a/modules/signals/spec/signal-store.spec.ts +++ b/modules/signals/spec/signal-store.spec.ts @@ -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, @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { diff --git a/modules/signals/spec/state-source.spec.ts b/modules/signals/spec/state-source.spec.ts index 7da0d8b305..11c163b832 100644 --- a/modules/signals/spec/state-source.spec.ts +++ b/modules/signals/spec/state-source.spec.ts @@ -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: { @@ -27,6 +29,7 @@ describe('StateSource', () => { foo: 'bar', numbers: [1, 2, 3], ngrx: 'signals', + [SECRET]: 'secret', }; describe('patchState', () => { @@ -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(); diff --git a/modules/signals/spec/with-methods.spec.ts b/modules/signals/spec/with-methods.spec.ts index 1fdb03492b..16ee8ab7dc 100644 --- a/modules/signals/spec/with-methods.spec.ts +++ b/modules/signals/spec/with-methods.spec.ts @@ -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() {}, @@ -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)' ); }); }); diff --git a/modules/signals/spec/with-props.spec.ts b/modules/signals/spec/with-props.spec.ts index 1d6e3e167b..ce473bb1c4 100644 --- a/modules/signals/spec/with-props.spec.ts +++ b/modules/signals/spec/with-props.spec.ts @@ -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', () => { @@ -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'); - }); }); diff --git a/modules/signals/src/deep-freeze.ts b/modules/signals/src/deep-freeze.ts index be2e5773ee..1b8e76db92 100644 --- a/modules/signals/src/deep-freeze.ts +++ b/modules/signals/src/deep-freeze.ts @@ -1,13 +1,12 @@ declare const ngDevMode: boolean; -export function deepFreeze(target: T): T { +export function deepFreeze(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; } diff --git a/modules/signals/src/signal-store-assertions.ts b/modules/signals/src/signal-store-assertions.ts index 865cddbef2..29c7fc4c9c 100644 --- a/modules/signals/src/signal-store-assertions.ts +++ b/modules/signals/src/signal-store-assertions.ts @@ -4,7 +4,7 @@ declare const ngDevMode: unknown; export function assertUniqueStoreMembers( store: InnerSignalStore, - newMemberKeys: string[] + newMemberKeys: (string | symbol)[] ): void { if (!ngDevMode) { return; @@ -15,7 +15,7 @@ export function assertUniqueStoreMembers( ...store.props, ...store.methods, }; - const overriddenKeys = Object.keys(storeMembers).filter((memberKey) => + const overriddenKeys = Reflect.ownKeys(storeMembers).filter((memberKey) => newMemberKeys.includes(memberKey) ); @@ -23,7 +23,7 @@ export function assertUniqueStoreMembers( console.warn( '@ngrx/signals: SignalStore members cannot be overridden.', 'Trying to override:', - overriddenKeys.join(', ') + overriddenKeys.map((key) => String(key)).join(', ') ); } } diff --git a/modules/signals/src/with-methods.ts b/modules/signals/src/with-methods.ts index cd5252ab0d..092012aed7 100644 --- a/modules/signals/src/with-methods.ts +++ b/modules/signals/src/with-methods.ts @@ -30,7 +30,7 @@ export function withMethods< ...store.props, ...store.methods, }); - assertUniqueStoreMembers(store, Object.keys(methods)); + assertUniqueStoreMembers(store, Reflect.ownKeys(methods)); return { ...store, diff --git a/modules/signals/src/with-state.ts b/modules/signals/src/with-state.ts index a6fe37c274..17041d4641 100644 --- a/modules/signals/src/with-state.ts +++ b/modules/signals/src/with-state.ts @@ -32,7 +32,7 @@ export function withState( return (store) => { const state = typeof stateOrFactory === 'function' ? stateOrFactory() : stateOrFactory; - const stateKeys = Object.keys(state); + const stateKeys = Reflect.ownKeys(state); assertUniqueStoreMembers(store, stateKeys);