Skip to content

Commit 8203d33

Browse files
authoredFeb 7, 2025
feat: add withImmutableState
`withImmutableState` acts like `withState` but prevents unintended mutations by throwing a runtime error. The protection applies not only within SignalStore but also externally.

12 files changed

+586
-0
lines changed
 

‎apps/demo/e2e/immutable-state.spec.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('immutable state', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('');
6+
await page.getByRole('link', { name: 'withImmutableState' }).click();
7+
});
8+
9+
for (const position of ['inside', 'outside']) {
10+
test(`mutation ${position}`, async ({ page }) => {
11+
const errorInConsole = page.waitForEvent('console');
12+
await page.getByRole('button', { name: position }).click();
13+
expect((await errorInConsole).text()).toContain(
14+
`Cannot assign to read only property 'id'`
15+
);
16+
});
17+
}
18+
19+
test(`mutation via form field`, async ({ page }) => {
20+
const errorInConsole = page.waitForEvent('console');
21+
await page.getByRole('textbox').focus();
22+
await page.keyboard.press('Space');
23+
expect((await errorInConsole).text()).toContain(
24+
`Cannot assign to read only property 'name'`
25+
);
26+
});
27+
});

‎apps/demo/src/app/app.component.html

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
>
2121
<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
2222
<a mat-list-item routerLink="/reset">withReset</a>
23+
<a mat-list-item routerLink="/immutable-state">withImmutableState</a>
2324
</mat-nav-list>
2425
</mat-drawer>
2526
<mat-drawer-content>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Component, inject } from '@angular/core';
2+
import { patchState, signalStore, withMethods } from '@ngrx/signals';
3+
import { withImmutableState } from '@angular-architects/ngrx-toolkit';
4+
import { MatButton } from '@angular/material/button';
5+
import { FormsModule } from '@angular/forms';
6+
7+
const initialState = { user: { id: 1, name: 'Konrad' } };
8+
9+
const UserStore = signalStore(
10+
{ providedIn: 'root' },
11+
withImmutableState(initialState),
12+
withMethods((store) => ({
13+
mutateState() {
14+
patchState(store, (state) => {
15+
state.user.id = 2;
16+
return state;
17+
});
18+
},
19+
}))
20+
);
21+
22+
@Component({
23+
template: `
24+
<h2>
25+
<pre>withImmutableState</pre>
26+
</h2>
27+
<p>
28+
withImmutableState throws an error if the state is mutated, regardless
29+
inside or outside the SignalStore.
30+
</p>
31+
<ul>
32+
<li>
33+
<button mat-raised-button (click)="mutateOutside()">
34+
Mutate State outside the SignalStore
35+
</button>
36+
</li>
37+
<li>
38+
<button mat-raised-button (click)="mutateInside()">
39+
Mutate State inside the SignalStore
40+
</button>
41+
</li>
42+
</ul>
43+
44+
<p>Form to edit State mutable via ngModel</p>
45+
<input [(ngModel)]="userStore.user().name" />
46+
`,
47+
imports: [MatButton, FormsModule],
48+
})
49+
export class ImmutableStateComponent {
50+
protected readonly userStore = inject(UserStore);
51+
mutateOutside() {
52+
initialState.user.id = 2;
53+
}
54+
55+
mutateInside() {
56+
this.userStore.mutateState();
57+
}
58+
}

‎apps/demo/src/app/lazy-routes.ts

+7
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,11 @@ export const lazyRoutes: Route[] = [
3838
loadComponent: () =>
3939
import('./reset/todo.component').then((m) => m.TodoComponent),
4040
},
41+
{
42+
path: 'immutable-state',
43+
loadComponent: () =>
44+
import('./immutable-state/immutable-state.component').then(
45+
(m) => m.ImmutableStateComponent
46+
),
47+
},
4148
];

‎docs/docs/extensions.md

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ It offers extensions like:
1212
- [Storage Sync](./with-storage-sync): Synchronizes the Store with Web Storage
1313
- [Undo Redo](./with-undo-redo): Adds Undo/Redo functionality to your store
1414
- [Reset](./with-reset): Adds a `resetState` method to your store
15+
- [State Immutability Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store.
1516

1617
To install it, run
1718

‎docs/docs/with-immutable-state.md

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
title: withImmutableState()
3+
---
4+
5+
`withImmutableState` acts like `withState` but protects
6+
the state against unintended mutable changes, by throwing
7+
a runtime error.
8+
9+
The protection is not limited to changes within the
10+
SignalStore but also outside of it.
11+
12+
```ts
13+
const initialState = { user: { id: 1, name: 'Konrad' } };
14+
15+
const UserStore = signalStore(
16+
{ providedIn: 'root' },
17+
withImmutableState(initialState),
18+
withMethods((store) => ({
19+
mutateState() {
20+
patchState(store, (state) => {
21+
state.user.id = 2;
22+
return state;
23+
});
24+
},
25+
}))
26+
);
27+
```
28+
29+
If `mutateState` is called, a runtime error will be thrown.
30+
31+
```ts
32+
class SomeComponent {
33+
userStore = inject(UserStore);
34+
35+
mutateChange() {
36+
this.userStore.mutateState(); // 🔥 throws an error
37+
}
38+
}
39+
```
40+
41+
The same is also true, when `initialState` is changed:
42+
43+
```ts
44+
initialState.user.id = 2; // 🔥 throws an error
45+
```
46+
47+
Finally, it could also happen, if third-party libraries or the Angular API does mutations to the state.
48+
49+
A common example is the usage in template-driven forms:
50+
51+
```ts
52+
@Component({
53+
template: ` <input [(ngModel)]="userStore.user().id" /> `,
54+
})
55+
class SomeComponent {}
56+
```
57+
58+
## Protection in production mode
59+
60+
By default, `withImmutableState` is only active in development mode.
61+
62+
There is a way to enable it in production mode as well:
63+
64+
```ts
65+
const UserStore = signalStore({ providedIn: 'root' }, withImmutableState(initialState, { enableInProduction: true }));
66+
```

‎docs/sidebars.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const sidebars: SidebarsConfig = {
2121
'with-redux',
2222
'with-storage-sync',
2323
'with-undo-redo',
24+
'with-immutable-state',
2425
],
2526
reduxConnectorSidebar: [
2627
{

‎libs/ngrx-toolkit/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export * from './lib/with-data-service';
2020
export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
2121
export * from './lib/with-pagination';
2222
export { withReset, setResetState } from './lib/with-reset';
23+
export { withImmutableState } from './lib/immutable-state/with-immutable-state';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Deep freezes a state object along its properties with primitive values
3+
* on the first level.
4+
*
5+
* The reason for this is that the final state is a merge of all
6+
* root properties of all states, i.e. `withState`,....
7+
*
8+
* Since the root object will not be part of the state (shadow clone),
9+
* we are not freezing it.
10+
*/
11+
12+
export function deepFreeze<T extends Record<string | symbol, unknown>>(
13+
target: T,
14+
// if empty all properties will be frozen
15+
propertyNamesToBeFrozen: (string | symbol)[],
16+
// also means that we are on the first level
17+
isRoot = true
18+
): void {
19+
const runPropertyNameCheck = propertyNamesToBeFrozen.length > 0;
20+
for (const key of Reflect.ownKeys(target)) {
21+
if (runPropertyNameCheck && !propertyNamesToBeFrozen.includes(key)) {
22+
continue;
23+
}
24+
25+
const propValue = target[key];
26+
if (isRecordLike(propValue) && !Object.isFrozen(propValue)) {
27+
Object.freeze(propValue);
28+
deepFreeze(propValue, [], false);
29+
} else if (isRoot) {
30+
Object.defineProperty(target, key, {
31+
value: propValue,
32+
writable: false,
33+
configurable: false,
34+
});
35+
}
36+
}
37+
}
38+
39+
function isRecordLike(
40+
target: unknown
41+
): target is Record<string | symbol, unknown> {
42+
return typeof target === 'object' && target !== null;
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { isDevMode as ngIsInDevMode } from '@angular/core';
2+
3+
// necessary wrapper function to test prod mode
4+
export function isDevMode() {
5+
return ngIsInDevMode();
6+
}

0 commit comments

Comments
 (0)
Please sign in to comment.