-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(signals)!: freeze state in dev mode
BREAKING CHANGE: the state of `signalStore` and `signalState` is frozen during dev mode. Mutable changes - not just in `patchState` - will throw an error. BEFORE: ```typescript const userState = signalState(initialState); patchState(userState, (state) => { state.user.firstName = 'mutable change'; // mutable change went through return state; }); getState(userState).user.firstName = 'mutable change'; // mutable change went through ``` AFTER: ``` const userState = signalState(initialState); patchState(userState, (state) => { state.user.firstName = 'mutable change'; // throws in dev mode return state; }); getState(userState).user.firstName = 'mutable change'; // throws in dev mode ```
- Loading branch information
1 parent
ab1d1b4
commit bff9d2c
Showing
5 changed files
with
179 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
import { getState, patchState } from '../src/state-source'; | ||
import { signalState } from '../src/signal-state'; | ||
import { signalStore } from '../src/signal-store'; | ||
import { TestBed } from '@angular/core/testing'; | ||
import { withState } from '../src/with-state'; | ||
|
||
describe('deepFreeze', () => { | ||
const initialState = { | ||
user: { | ||
firstName: 'John', | ||
lastName: 'Smith', | ||
}, | ||
foo: 'bar', | ||
numbers: [1, 2, 3], | ||
ngrx: 'signals', | ||
}; | ||
|
||
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 for', () => { | ||
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" | ||
); | ||
}); | ||
}); | ||
}); | ||
} | ||
|
||
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(); | ||
}); | ||
} | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
declare const ngDevMode: boolean; | ||
|
||
export function deepFreeze<T>(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('ɵ')) { | ||
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<T>(target: T): T { | ||
return ngDevMode ? deepFreeze(target) : target; | ||
} | ||
|
||
function hasOwnProperty( | ||
target: unknown, | ||
propertyName: string | ||
): target is { [propertyName: string]: unknown } { | ||
return isObjectLike(target) | ||
? Object.prototype.hasOwnProperty.call(target, propertyName) | ||
: false; | ||
} | ||
|
||
function isObjectLike(target: unknown): target is object { | ||
return typeof target === 'object' && target !== null; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters