Skip to content

Commit 59bd044

Browse files
feat(signals): deep freeze state in patchState in dev mode
1 parent 1f7f740 commit 59bd044

File tree

3 files changed

+77
-1
lines changed

3 files changed

+77
-1
lines changed

modules/signals/spec/signal-state.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,28 @@ describe('signalState', () => {
194194
expect(stateCounter).toBe(3);
195195
expect(userCounter).toBe(1);
196196
}));
197+
198+
describe('freezeInDevMode', () => {
199+
it('throws on a mutable change', () => {
200+
const userState = signalState(initialState);
201+
expect(() =>
202+
patchState(userState, (state) => {
203+
state.ngrx = 'mutable change';
204+
return state;
205+
})
206+
).toThrowError("Cannot assign to read only property 'ngrx' of object");
207+
});
208+
209+
it('throws on a mutable change', () => {
210+
const userState = signalState(initialState);
211+
expect(() =>
212+
patchState(userState, (state) => {
213+
state.user.firstName = 'mutable change';
214+
return state;
215+
})
216+
).toThrowError(
217+
"Cannot assign to read only property 'firstName' of object"
218+
);
219+
});
220+
});
197221
});

modules/signals/src/deep-freeze.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export function deepFreeze<T>(target: T): T {
2+
Object.freeze(target);
3+
4+
const targetIsFunction = typeof target === 'function';
5+
6+
Object.getOwnPropertyNames(target).forEach((prop) => {
7+
// Ignore Ivy properties, ref: https://github.com/ngrx/platform/issues/2109#issuecomment-582689060
8+
if (prop.startsWith('ɵ')) {
9+
return;
10+
}
11+
12+
if (
13+
hasOwnProperty(target, prop) &&
14+
(targetIsFunction
15+
? prop !== 'caller' && prop !== 'callee' && prop !== 'arguments'
16+
: true)
17+
) {
18+
const propValue = target[prop];
19+
20+
if (
21+
(isObjectLike(propValue) || typeof propValue === 'function') &&
22+
!Object.isFrozen(propValue)
23+
) {
24+
deepFreeze(propValue);
25+
}
26+
}
27+
});
28+
29+
return target;
30+
}
31+
32+
function hasOwnProperty(
33+
target: unknown,
34+
propertyName: string
35+
): target is { [propertyName: string]: unknown } {
36+
return isObjectLike(target)
37+
? Object.prototype.hasOwnProperty.call(target, propertyName)
38+
: false;
39+
}
40+
41+
function isObjectLike(target: unknown): target is object {
42+
return typeof target === 'object' && target !== null;
43+
}

modules/signals/src/state-source.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
} from '@angular/core';
1010
import { SIGNAL } from '@angular/core/primitives/signals';
1111
import { Prettify } from './ts-helpers';
12+
import { deepFreeze } from './deep-freeze';
13+
14+
declare const ngDevMode: boolean;
1215

1316
const STATE_WATCHERS = new WeakMap<object, Array<StateWatcher<any>>>();
1417

@@ -40,7 +43,9 @@ export function patchState<State extends object>(
4043
updaters.reduce(
4144
(nextState: State, updater) => ({
4245
...nextState,
43-
...(typeof updater === 'function' ? updater(nextState) : updater),
46+
...(typeof updater === 'function'
47+
? updater(freezeInDevMode(nextState))
48+
: updater),
4449
}),
4550
currentState
4651
)
@@ -49,6 +54,10 @@ export function patchState<State extends object>(
4954
notifyWatchers(stateSource);
5055
}
5156

57+
function freezeInDevMode<State extends object>(value: State): State {
58+
return ngDevMode ? deepFreeze(value) : value;
59+
}
60+
5261
export function getState<State extends object>(
5362
stateSource: StateSource<State>
5463
): State {

0 commit comments

Comments
 (0)