diff --git a/modules/signals/spec/deep-freeze.spec.ts b/modules/signals/spec/deep-freeze.spec.ts deleted file mode 100644 index 4c0e420edf..0000000000 --- a/modules/signals/spec/deep-freeze.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { - getState, - patchState, - signalState, - signalStore, - withState, -} from '../src'; - -describe('deepFreeze', () => { - const SECRET = Symbol('secret'); - - const initialState = { - user: { - firstName: 'John', - lastName: 'Smith', - }, - foo: 'bar', - numbers: [1, 2, 3], - ngrx: 'signals', - nestedSymbol: { - [SECRET]: 'another secret', - }, - [SECRET]: { - code: 'secret', - value: '123', - }, - }; - - for (const { stateFactory, name } of [ - { - name: 'signalStore', - stateFactory: () => { - const Store = signalStore( - { protectedState: false }, - withState(initialState) - ); - return TestBed.configureTestingModule({ providers: [Store] }).inject( - Store - ); - }, - }, - { name: 'signalState', stateFactory: () => signalState(initialState) }, - ]) { - describe(name, () => { - it(`throws on a mutable change`, () => { - const state = stateFactory(); - expect(() => - patchState(state, (state) => { - state.ngrx = 'mutable change'; - return state; - }) - ).toThrowError("Cannot assign to read only property 'ngrx' of object"); - }); - - it('throws on a nested mutable change', () => { - const state = stateFactory(); - expect(() => - patchState(state, (state) => { - state.user.firstName = 'mutable change'; - return state; - }) - ).toThrowError( - "Cannot assign to read only property 'firstName' of object" - ); - }); - - describe('mutable changes outside of patchState', () => { - it('throws on reassigned a property of the exposed state', () => { - const state = stateFactory(); - expect(() => { - state.user().firstName = 'mutable change 1'; - }).toThrowError( - "Cannot assign to read only property 'firstName' of object" - ); - }); - - it('throws when exposed state via getState is mutated', () => { - const state = stateFactory(); - const s = getState(state); - - expect(() => (s.ngrx = 'mutable change 2')).toThrowError( - "Cannot assign to read only property 'ngrx' of object" - ); - }); - - it('throws when mutable change happens', () => { - const state = stateFactory(); - const s = { user: { firstName: 'M', lastName: 'S' } }; - patchState(state, s); - - expect(() => { - s.user.firstName = 'mutable change 3'; - }).toThrowError( - "Cannot assign to read only property 'firstName' of object" - ); - }); - - it('throws when mutable change of root symbol property happens', () => { - 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 when mutable change of nested symbol property happens', () => { - const state = stateFactory(); - const s = getState(state); - - expect(() => { - s.nestedSymbol[SECRET] = 'mutable change'; - }).toThrowError( - "Cannot assign to read only property 'Symbol(secret)' of object" - ); - }); - }); - }); - } - - describe('special tests', () => { - for (const { name, mutationFn } of [ - { - name: 'location', - mutationFn: (state: { location: { city: string } }) => - (state.location.city = 'Paris'), - }, - { - name: 'user', - mutationFn: (state: { user: { firstName: string } }) => - (state.user.firstName = 'Jane'), - }, - ]) { - it(`throws on concatenated state (${name})`, () => { - const UserStore = signalStore( - { providedIn: 'root' }, - withState(initialState), - withState({ location: { city: 'London' } }) - ); - const store = TestBed.inject(UserStore); - const state = getState(store); - - expect(() => mutationFn(state)).toThrowError(); - }); - } - }); -}); diff --git a/modules/signals/src/deep-freeze.ts b/modules/signals/src/deep-freeze.ts deleted file mode 100644 index aeeb9b1f87..0000000000 --- a/modules/signals/src/deep-freeze.ts +++ /dev/null @@ -1,48 +0,0 @@ -declare const ngDevMode: boolean; - -export function deepFreeze(target: T): T { - Object.freeze(target); - - const targetIsFunction = typeof target === 'function'; - - Reflect.ownKeys(target).forEach((prop) => { - if (String(prop).startsWith('ɵ')) { - return; - } - - if ( - hasOwnProperty(target, prop) && - (targetIsFunction - ? prop !== 'caller' && prop !== 'callee' && prop !== 'arguments' - : true) - ) { - const propValue = target[prop]; - - if ( - (isObjectLike(propValue) || typeof propValue === 'function') && - !Object.isFrozen(propValue) - ) { - deepFreeze(propValue); - } - } - }); - - return target; -} - -export function freezeInDevMode(target: T): T { - return ngDevMode ? deepFreeze(target) : target; -} - -function hasOwnProperty( - target: unknown, - propertyName: string | symbol -): target is { [propertyName: string | symbol]: unknown } { - return isObjectLike(target) - ? Object.prototype.hasOwnProperty.call(target, propertyName) - : false; -} - -function isObjectLike(target: unknown): target is object { - return typeof target === 'object' && target !== null; -} diff --git a/modules/signals/src/signal-state.ts b/modules/signals/src/signal-state.ts index 72f1e02674..d31521151e 100644 --- a/modules/signals/src/signal-state.ts +++ b/modules/signals/src/signal-state.ts @@ -1,7 +1,6 @@ import { signal } from '@angular/core'; import { STATE_SOURCE, WritableStateSource } from './state-source'; import { DeepSignal, toDeepSignal } from './deep-signal'; -import { freezeInDevMode } from './deep-freeze'; export type SignalState = DeepSignal & WritableStateSource; @@ -9,7 +8,7 @@ export type SignalState = DeepSignal & export function signalState( initialState: State ): SignalState { - const stateSource = signal(freezeInDevMode(initialState as State)); + const stateSource = signal(initialState as State); const signalState = toDeepSignal(stateSource.asReadonly()); Object.defineProperty(signalState, STATE_SOURCE, { value: stateSource, diff --git a/modules/signals/src/state-source.ts b/modules/signals/src/state-source.ts index 4db0e38881..4db6ec3a68 100644 --- a/modules/signals/src/state-source.ts +++ b/modules/signals/src/state-source.ts @@ -8,7 +8,6 @@ import { WritableSignal, } from '@angular/core'; import { Prettify } from './ts-helpers'; -import { freezeInDevMode } from './deep-freeze'; const STATE_WATCHERS = new WeakMap, Array>>(); @@ -38,11 +37,10 @@ export function patchState( ): void { stateSource[STATE_SOURCE].update((currentState) => updaters.reduce( - (nextState: State, updater) => - freezeInDevMode({ - ...nextState, - ...(typeof updater === 'function' ? updater(nextState) : updater), - }), + (nextState: State, updater) => ({ + ...nextState, + ...(typeof updater === 'function' ? updater(nextState) : updater), + }), currentState ) ); diff --git a/modules/signals/src/with-state.ts b/modules/signals/src/with-state.ts index 309cfe7a9d..e2ca7aede3 100644 --- a/modules/signals/src/with-state.ts +++ b/modules/signals/src/with-state.ts @@ -9,7 +9,6 @@ import { SignalStoreFeature, SignalStoreFeatureResult, } from './signal-store-models'; -import { freezeInDevMode } from './deep-freeze'; export function withState( stateFactory: () => State @@ -36,12 +35,10 @@ export function withState( assertUniqueStoreMembers(store, stateKeys); - store[STATE_SOURCE].update((currentState) => - freezeInDevMode({ - ...currentState, - ...state, - }) - ); + store[STATE_SOURCE].update((currentState) => ({ + ...currentState, + ...state, + })); const stateSignals = stateKeys.reduce((acc, key) => { const sliceSignal = computed( diff --git a/projects/ngrx.io/content/guide/migration/v19.md b/projects/ngrx.io/content/guide/migration/v19.md index 5e93ae991c..a0fe3be87a 100644 --- a/projects/ngrx.io/content/guide/migration/v19.md +++ b/projects/ngrx.io/content/guide/migration/v19.md @@ -23,49 +23,6 @@ Version 19 has the minimum version requirements: ### Signals -#### Throw error in dev mode on state mutation - -The `patchState` method applies a deep freeze on the state in dev mode. -If you try to update the state directly, it will throw an error in dev mode. - -BEFORE: - -```ts -const userState = signalState(initialState); -patchState(userState, (state) => { - // mutable change which went through - state.user.firstName = 'mutable change'; - return state; -}); -``` - -AFTER: - -```ts -const userState = signalState(initialState); -patchState(userState, (state) => { - // mutable change throws in dev mode - state.user.firstName = 'mutable change'; - return state; -}); -``` - -To fix the error, update the state in an immutable way. - -```ts -const userState = signalState(initialState); -patchState(userState, (state) => { - return { - ...state, - user: { - ...state.user, - // immutable change which went through - firstName: 'immutable change', - }, - }; -}); -``` - #### `computed` is replaced with `props` To support more cases, the `props` property is added to `signalStoreFeature`, which replaces the existing `computed` property. diff --git a/projects/ngrx.io/content/guide/signals/signal-state.md b/projects/ngrx.io/content/guide/signals/signal-state.md index c78129a0f2..f0876b32d3 100644 --- a/projects/ngrx.io/content/guide/signals/signal-state.md +++ b/projects/ngrx.io/content/guide/signals/signal-state.md @@ -87,7 +87,6 @@ patchState(
Updaters passed to the `patchState` function must perform state updates in an immutable manner. -If a mutable change occurs to the state object, an error will be thrown in development mode.