Skip to content

Commit 2cf0c83

Browse files
fix(signals): expose readonly signals in slices
`withState`, `signalState`, and `withLinkedState` now pass a `Signal` instead of the original `WritableSignal` to the DeepSignal. This prevents accidental misuse where consumers (e.g. `ngModel` or other APIs with incompatible types) could assert `WritableSignal` and write directly into the state. Closes #4958
1 parent 0365ea5 commit 2cf0c83

File tree

7 files changed

+49
-14
lines changed

7 files changed

+49
-14
lines changed

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ describe('signalState', () => {
6060
expect(isSignal(state.ngrx)).toBe(true);
6161
});
6262

63+
it('does not expose state slices as writable signals', () => {
64+
const state = signalState(initialState);
65+
expect(() => (state as any).foo.set('baz')).toThrow(
66+
'set is not a function'
67+
);
68+
});
69+
6370
it('caches previously created signals', () => {
6471
const state = signalState(initialState);
6572
const user1 = state.user;

modules/signals/spec/with-linked-state.spec.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { linkedSignal, signal } from '@angular/core';
1+
import { linkedSignal, signal, WritableSignal } from '@angular/core';
22
import {
33
getState,
44
patchState,
@@ -254,4 +254,15 @@ describe('withLinkedState', () => {
254254
'value'
255255
);
256256
});
257+
258+
it('does not expose linked state properties as writable signals', () => {
259+
const initialStore = getInitialInnerStore();
260+
const userStore = withLinkedState(() => ({
261+
foo: () => 'bar',
262+
}))(initialStore);
263+
264+
expect(() =>
265+
(userStore.stateSignals.foo as WritableSignal<string>).set('baz')
266+
).toThrow('set is not a function');
267+
});
257268
});

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isSignal, signal } from '@angular/core';
1+
import { isSignal, signal, WritableSignal } from '@angular/core';
22
import { getState, withComputed, withMethods, withState } from '../src';
33
import { getInitialInnerStore } from '../src/signal-store';
44

@@ -38,6 +38,19 @@ describe('withState', () => {
3838
expect(isSignal(store.stateSignals.x.y)).toBe(true);
3939
});
4040

41+
it('does not expose state slices as writable signals', () => {
42+
const initialStore = getInitialInnerStore();
43+
44+
const store = withState({
45+
foo: 'bar',
46+
x: { y: 'z' },
47+
})(initialStore);
48+
49+
expect(() =>
50+
(store.stateSignals.foo as WritableSignal<string>).set('baz')
51+
).toThrow('set is not a function');
52+
});
53+
4154
it('patches state source and creates deep signals for state slices provided via factory', () => {
4255
const initialStore = getInitialInnerStore();
4356

modules/signals/src/signal-state.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { computed, signal } from '@angular/core';
22
import { DeepSignal, toDeepSignal } from './deep-signal';
3-
import { SignalsDictionary } from './signal-store-models';
3+
import { WritableSignalsDictionary } from './signal-store-models';
44
import { STATE_SOURCE, WritableStateSource } from './state-source';
55

66
export type SignalState<State extends object> = DeepSignal<State> &
@@ -16,7 +16,7 @@ export function signalState<State extends object>(
1616
...signalsDict,
1717
[key]: signal((initialState as Record<string | symbol, unknown>)[key]),
1818
}),
19-
{} as SignalsDictionary
19+
{} as WritableSignalsDictionary
2020
);
2121

2222
const signalState = computed(() =>
@@ -32,7 +32,7 @@ export function signalState<State extends object>(
3232

3333
for (const key of stateKeys) {
3434
Object.defineProperty(signalState, key, {
35-
value: toDeepSignal(stateSource[key]),
35+
value: toDeepSignal(stateSource[key].asReadonly()),
3636
});
3737
}
3838

modules/signals/src/signal-store-models.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Signal } from '@angular/core';
1+
import { Signal, WritableSignal } from '@angular/core';
22
import { DeepSignal } from './deep-signal';
33
import { WritableStateSource } from './state-source';
44
import { IsKnownRecord, Prettify } from './ts-helpers';
@@ -14,6 +14,11 @@ export type StateSignals<State> =
1414

1515
export type SignalsDictionary = Record<string | symbol, Signal<unknown>>;
1616

17+
export type WritableSignalsDictionary = Record<
18+
string | symbol,
19+
WritableSignal<unknown>
20+
>;
21+
1722
export type MethodsDictionary = Record<string, Function>;
1823

1924
export type SignalStoreHooks = {

modules/signals/src/with-linked-state.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
SignalStoreFeature,
88
SignalStoreFeatureResult,
99
StateSignals,
10+
WritableSignalsDictionary,
1011
} from './signal-store-models';
1112
import { isWritableSignal, STATE_SOURCE } from './state-source';
1213
import { Prettify } from './ts-helpers';
@@ -89,15 +90,15 @@ export function withLinkedState<
8990
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
9091
assertUniqueStoreMembers(store, stateKeys);
9192
}
92-
const stateSource = store[STATE_SOURCE] as SignalsDictionary;
93+
const stateSource = store[STATE_SOURCE] as WritableSignalsDictionary;
9394
const stateSignals = {} as SignalsDictionary;
9495

9596
for (const key of stateKeys) {
9697
const signalOrComputationFn = linkedState[key];
9798
stateSource[key] = isWritableSignal(signalOrComputationFn)
9899
? signalOrComputationFn
99100
: linkedSignal(signalOrComputationFn);
100-
stateSignals[key] = toDeepSignal(stateSource[key]);
101+
stateSignals[key] = toDeepSignal(stateSource[key].asReadonly());
101102
}
102103

103104
return {

modules/signals/src/with-state.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Signal, signal } from '@angular/core';
1+
import { signal } from '@angular/core';
22
import { toDeepSignal } from './deep-signal';
33
import { assertUniqueStoreMembers } from './signal-store-assertions';
44
import {
@@ -7,6 +7,7 @@ import {
77
SignalsDictionary,
88
SignalStoreFeature,
99
SignalStoreFeatureResult,
10+
WritableSignalsDictionary,
1011
} from './signal-store-models';
1112
import { STATE_SOURCE } from './state-source';
1213

@@ -38,15 +39,12 @@ export function withState<State extends object>(
3839
assertUniqueStoreMembers(store, stateKeys);
3940
}
4041

41-
const stateSource = store[STATE_SOURCE] as Record<
42-
string | symbol,
43-
Signal<unknown>
44-
>;
42+
const stateSource = store[STATE_SOURCE] as WritableSignalsDictionary;
4543
const stateSignals: SignalsDictionary = {};
4644

4745
for (const key of stateKeys) {
4846
stateSource[key] = signal(state[key]);
49-
stateSignals[key] = toDeepSignal(stateSource[key]);
47+
stateSignals[key] = toDeepSignal(stateSource[key].asReadonly());
5048
}
5149

5250
return {

0 commit comments

Comments
 (0)