Skip to content

Commit 5749543

Browse files
fix(signals): remove state checks for better DX (#4124)
1 parent 18227cc commit 5749543

File tree

9 files changed

+155
-148
lines changed

9 files changed

+155
-148
lines changed

modules/signals/entities/src/with-entities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function withEntities<Entity>(config?: {
5454
withState({
5555
[entityMapKey]: {},
5656
[idsKey]: [],
57-
} as any),
57+
}),
5858
withComputed((store) => ({
5959
[entitiesKey]: computed(() => {
6060
const entityMap = store[entityMapKey]() as EntityMap<Entity>;

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { effect, isSignal } from '@angular/core';
22
import * as angular from '@angular/core';
3+
import { TestBed } from '@angular/core/testing';
34
import { patchState, signalState } from '../src';
45
import { STATE_SIGNAL } from '../src/signal-state';
5-
import { TestBed } from '@angular/core/testing';
66

77
describe('signalState', () => {
88
const initialState = {
@@ -85,6 +85,25 @@ describe('signalState', () => {
8585
expect((state[STATE_SIGNAL] as any).ngrx).toBe(undefined);
8686
});
8787

88+
it('overrides Function properties if state keys have the same name', () => {
89+
const initialState = { name: { length: { length: 'ngrx' }, name: 20 } };
90+
const state = signalState(initialState);
91+
92+
expect(state()).toBe(initialState);
93+
94+
expect(state.name()).toBe(initialState.name);
95+
expect(isSignal(state.name)).toBe(true);
96+
97+
expect(state.name.name()).toBe(20);
98+
expect(isSignal(state.name.name)).toBe(true);
99+
100+
expect(state.name.length()).toBe(initialState.name.length);
101+
expect(isSignal(state.name.length)).toBe(true);
102+
103+
expect(state.name.length.length()).toBe('ngrx');
104+
expect(isSignal(state.name.length.length)).toBe(true);
105+
});
106+
88107
it('emits new values only for affected signals', () => {
89108
TestBed.runInInjectionContext(() => {
90109
const state = signalState(initialState);
@@ -144,7 +163,7 @@ describe('signalState', () => {
144163
});
145164
});
146165

147-
it('should not emit if there was no change', () =>
166+
it('does not emit if there was no change', () =>
148167
TestBed.runInInjectionContext(() => {
149168
let stateCounter = 0;
150169
let userCounter = 0;

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { inject, InjectionToken, isSignal, signal } from '@angular/core';
22
import { TestBed } from '@angular/core/testing';
33
import {
4+
patchState,
45
signalStore,
56
withComputed,
67
withHooks,
@@ -70,6 +71,35 @@ describe('signalStore', () => {
7071
expect(store.x.y.z()).toBe(10);
7172
});
7273

74+
it('overrides Function properties if nested state keys have the same name', () => {
75+
const Store = signalStore(
76+
withState({ name: { length: { name: false } } })
77+
);
78+
const store = new Store();
79+
80+
expect(store.name()).toEqual({ length: { name: false } });
81+
expect(isSignal(store.name)).toBe(true);
82+
83+
expect(store.name.length()).toEqual({ name: false });
84+
expect(isSignal(store.name.length)).toBe(true);
85+
86+
expect(store.name.length.name()).toBe(false);
87+
expect(isSignal(store.name.length.name)).toBe(true);
88+
});
89+
90+
it('does not create signals for optional state slices without initial value', () => {
91+
type State = { x?: number; y?: { z: number } };
92+
93+
const Store = signalStore(withState<State>({ x: 10 }));
94+
const store = new Store();
95+
96+
expect(store.x!()).toBe(10);
97+
expect(store.y).toBe(undefined);
98+
99+
patchState(store, { y: { z: 100 } });
100+
expect(store.y).toBe(undefined);
101+
});
102+
73103
it('executes withState factory in injection context', () => {
74104
const TOKEN = new InjectionToken('TOKEN', {
75105
providedIn: 'root',

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

Lines changed: 21 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ describe('signalState', () => {
169169

170170
expectSnippet(snippet).toInfer(
171171
'state1Keys',
172-
'unique symbol | unique symbol'
172+
'unique symbol | keyof Signal<{ [key: string]: number; }>'
173173
);
174174

175175
expectSnippet(snippet).toInfer(
@@ -179,7 +179,7 @@ describe('signalState', () => {
179179

180180
expectSnippet(snippet).toInfer(
181181
'state2Keys',
182-
'unique symbol | unique symbol'
182+
'unique symbol | keyof Signal<{ [key: number]: { foo: string; }; }>'
183183
);
184184

185185
expectSnippet(snippet).toInfer(
@@ -189,7 +189,7 @@ describe('signalState', () => {
189189

190190
expectSnippet(snippet).toInfer(
191191
'state3Keys',
192-
'unique symbol | unique symbol'
192+
'unique symbol | keyof Signal<Record<string, { bar: number; }>>'
193193
);
194194

195195
expectSnippet(snippet).toInfer(
@@ -270,50 +270,26 @@ describe('signalState', () => {
270270
expectSnippet(snippet).toInfer('z', 'Signal<boolean | undefined>');
271271
});
272272

273-
it('fails when state contains Function properties', () => {
274-
expectSnippet(`const state = signalState({ name: '' })`).toFail(
275-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
276-
);
277-
278-
expectSnippet(
279-
`const state = signalState({ foo: { arguments: [] } })`
280-
).toFail(
281-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
282-
);
283-
284-
expectSnippet(`
285-
type State = { foo: { bar: { call?: boolean }; baz: number } };
286-
const state = signalState<State>({ foo: { bar: {}, baz: 1 } });
287-
`).toFail(
288-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
289-
);
290-
291-
expectSnippet(
292-
`const state = signalState({ foo: { apply: 'apply', bar: true } })`
293-
).toFail(
294-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
295-
);
296-
297-
expectSnippet(`
298-
type State = { bind?: { foo: string } };
299-
const state = signalState<State>({ bind: { foo: 'bar' } });
300-
`).toFail(
301-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
302-
);
303-
304-
expectSnippet(
305-
`const state = signalState({ foo: { bar: { prototype: [] }; baz: 1 } })`
306-
).toFail(
307-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
308-
);
309-
310-
expectSnippet(`const state = signalState({ foo: { length: 10 } })`).toFail(
311-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
312-
);
273+
it('succeeds when state contains Function properties', () => {
274+
const snippet = `
275+
const state1 = signalState({ name: 0 });
276+
const state2 = signalState({ foo: { length: [] as boolean[] } });
277+
const state3 = signalState({ name: { length: '' } });
278+
279+
const name = state1.name;
280+
const length1 = state2.foo.length;
281+
const name2 = state3.name;
282+
const length2 = state3.name.length;
283+
`;
313284

314-
expectSnippet(`const state = signalState({ caller: '' })`).toFail(
315-
/@ngrx\/signals: signal state cannot contain `Function` property or method names/
285+
expectSnippet(snippet).toSucceed();
286+
expectSnippet(snippet).toInfer('name', 'Signal<number>');
287+
expectSnippet(snippet).toInfer('length1', 'Signal<boolean[]>');
288+
expectSnippet(snippet).toInfer(
289+
'name2',
290+
'Signal<{ length: string; }> & Readonly<{ length: Signal<string>; }>'
316291
);
292+
expectSnippet(snippet).toInfer('length2', 'Signal<string>');
317293
});
318294

319295
it('fails when state is not an object', () => {

modules/signals/spec/types/signal-store.types.spec.ts

Lines changed: 62 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -253,62 +253,33 @@ describe('signalStore', () => {
253253
);
254254
});
255255

256-
it('fails when nested state slices contain Function properties', () => {
257-
expectSnippet(`
258-
const Store = signalStore(withState({ x: { name?: '' } }));
259-
`).toFail(
260-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
261-
);
262-
263-
expectSnippet(`
264-
const Store = signalStore(withState({ x: { arguments: [] } }));
265-
`).toFail(
266-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
267-
);
268-
269-
expectSnippet(`
270-
const Store = signalStore(
271-
withState({ x: { bar: { call: false }, baz: 1 } })
272-
);
273-
`).toFail(
274-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
275-
);
276-
277-
expectSnippet(`
278-
const Store = signalStore(
279-
withState({ x: { apply: 'apply', bar: true } })
280-
)
281-
`).toFail(
282-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
283-
);
284-
285-
expectSnippet(`
286-
const Store = signalStore(
287-
withState({ x: { bind: { foo: 'bar' } } })
288-
);
289-
`).toFail(
290-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
256+
it('succeeds when nested state slices contain Function properties', () => {
257+
const snippet1 = `
258+
type State = { x: { name?: string } };
259+
const Store = signalStore(withState<State>({ x: { name: '' } }));
260+
const store = new Store();
261+
const name = store.x.name;
262+
`;
263+
expectSnippet(snippet1).toSucceed();
264+
expectSnippet(snippet1).toInfer(
265+
'name',
266+
'Signal<string | undefined> | undefined'
291267
);
292268

293-
expectSnippet(`
269+
const snippet2 = `
294270
const Store = signalStore(
295-
withState({ x: { bar: { prototype: [] }; baz: 1 } })
271+
withState({ x: { length: { name: false }, baz: 1 } })
296272
);
297-
`).toFail(
298-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
299-
);
300-
301-
expectSnippet(`
302-
const Store = signalStore(withState({ x: { length: 10 } }));
303-
`).toFail(
304-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
305-
);
306-
307-
expectSnippet(`
308-
const Store = signalStore(withState({ x: { caller: '' } }));
309-
`).toFail(
310-
/@ngrx\/signals: nested state slices cannot contain `Function` property or method names/
273+
const store = new Store();
274+
const length = store.x.length;
275+
const name = store.x.length.name;
276+
`;
277+
expectSnippet(snippet2).toSucceed();
278+
expectSnippet(snippet2).toInfer(
279+
'length',
280+
'Signal<{ name: boolean; }> & Readonly<{ name: Signal<boolean>; }>'
311281
);
282+
expectSnippet(snippet2).toInfer('name', 'Signal<boolean>');
312283
});
313284

314285
it('succeeds when nested state slices are optional', () => {
@@ -355,8 +326,8 @@ describe('signalStore', () => {
355326
);
356327
});
357328

358-
it('fails when root state slices are optional', () => {
359-
expectSnippet(`
329+
it('succeeds when root state slices are optional', () => {
330+
const snippet = `
360331
type State = {
361332
foo?: { s: string };
362333
bar: number;
@@ -365,26 +336,53 @@ describe('signalStore', () => {
365336
const Store = signalStore(
366337
withState<State>({ foo: { s: '' }, bar: 1 })
367338
);
368-
`).toFail(/@ngrx\/signals: root state slices cannot be optional/);
339+
const store = new Store();
340+
const foo = store.foo;
341+
`;
342+
343+
expectSnippet(snippet).toSucceed();
344+
expectSnippet(snippet).toInfer(
345+
'foo',
346+
'Signal<{ s: string; } | undefined> | undefined'
347+
);
369348
});
370349

371-
it('fails when state is an unknown record', () => {
372-
expectSnippet(`
373-
const Store1 = signalStore(withState<{ [key: string]: number }>({}));
374-
`).toFail(/@ngrx\/signals: root state keys must be string literals/);
350+
it('succeeds when state is an unknown record', () => {
351+
const snippet1 = `
352+
const Store = signalStore(withState<{ [key: string]: number }>({}));
353+
const store = new Store();
375354
376-
expectSnippet(`
377-
const Store2 = signalStore(withState<{ [key: number]: { bar: string } }>({}));
378-
`).toFail(/@ngrx\/signals: root state keys must be string literals/);
355+
const x = store.x;
356+
const y = store.y;
357+
`;
358+
expectSnippet(snippet1).toSucceed();
359+
expectSnippet(snippet1).toInfer('x', 'Signal<number>');
360+
expectSnippet(snippet1).toInfer('y', 'Signal<number>');
379361

380-
expectSnippet(`
381-
const Store3 = signalStore(
362+
const snippet2 = `
363+
const Store = signalStore(
364+
withState<{ [key: number]: { bar: string } }>({})
365+
);
366+
const store = new Store();
367+
const x = store[0];
368+
const y = store[1];
369+
`;
370+
expectSnippet(snippet2).toSucceed();
371+
expectSnippet(snippet2).toInfer('x', 'DeepSignal<{ bar: string; }>');
372+
expectSnippet(snippet2).toInfer('y', 'DeepSignal<{ bar: string; }>');
373+
374+
const snippet3 = `
375+
const Store = signalStore(
382376
withState<Record<string, { foo: boolean } | number>>({
383377
x: { foo: true },
384378
y: 1,
385379
})
386380
);
387-
`).toFail(/@ngrx\/signals: root state keys must be string literals/);
381+
const store = new Store();
382+
const m = store.m;
383+
`;
384+
expectSnippet(snippet3).toSucceed();
385+
expectSnippet(snippet3).toInfer('m', 'Signal<number | { foo: boolean; }>');
388386
});
389387

390388
it('fails when state is not an object', () => {

modules/signals/src/deep-signal.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1-
import { computed, Signal, untracked } from '@angular/core';
1+
import {
2+
computed,
3+
isSignal,
4+
Signal as NgSignal,
5+
untracked,
6+
} from '@angular/core';
27
import { IsUnknownRecord } from './ts-helpers';
38

9+
// An extended Signal type that enables the correct typing
10+
// of nested signals with the `name' or `length' key.
11+
interface Signal<T> extends NgSignal<T> {
12+
name: unknown;
13+
length: unknown;
14+
}
15+
416
export type DeepSignal<T> = Signal<T> &
517
(T extends Record<string, unknown>
618
? IsUnknownRecord<T> extends true
@@ -26,8 +38,10 @@ export function toDeepSignal<T>(signal: Signal<T>): DeepSignal<T> {
2638
return target[prop];
2739
}
2840

29-
if (!target[prop]) {
30-
target[prop] = computed(() => target()[prop]);
41+
if (!isSignal(target[prop])) {
42+
Object.defineProperty(target, prop, {
43+
value: computed(() => target()[prop]),
44+
});
3145
}
3246

3347
return toDeepSignal(target[prop]);

0 commit comments

Comments
 (0)