Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(signals): add patchState function and remove $update method #4037

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions modules/signals/spec/patch-state.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { patchState, signalState } from '../src';

describe('patchState', () => {
const initialState = {
user: {
firstName: 'John',
lastName: 'Smith',
},
foo: 'bar',
numbers: [1, 2, 3],
ngrx: 'signals',
};

it('patches state via partial state object', () => {
const state = signalState(initialState);

patchState(state, {
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});

expect(state()).toEqual({
...initialState,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});
});

it('patches state via updater function', () => {
const state = signalState(initialState);

patchState(state, (state) => ({
numbers: [...state.numbers, 4],
ngrx: 'rocks',
timdeschryver marked this conversation as resolved.
Show resolved Hide resolved
}));

expect(state()).toEqual({
...initialState,
numbers: [1, 2, 3, 4],
ngrx: 'rocks',
});
});

it('patches state via sequence of partial state objects and updater functions', () => {
const state = signalState(initialState);

patchState(
state,
{ user: { firstName: 'Johannes', lastName: 'Schmidt' } },
(state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }),
(state) => ({ user: { ...state.user, firstName: 'Jovan' } }),
{ foo: 'foo' }
);

expect(state()).toEqual({
...initialState,
user: { firstName: 'Jovan', lastName: 'Schmidt' },
foo: 'foo',
numbers: [1, 2, 3, 4],
});
});

it('patches state immutably', () => {
const state = signalState(initialState);

patchState(state, {
foo: 'bar',
numbers: [3, 2, 1],
ngrx: 'rocks',
});

expect(state.user()).toBe(initialState.user);
expect(state.foo()).toBe(initialState.foo);
expect(state.numbers()).not.toBe(initialState.numbers);
expect(state.ngrx()).not.toBe(initialState.ngrx);
});
});
219 changes: 76 additions & 143 deletions modules/signals/spec/signal-state.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { effect, isSignal } from '@angular/core';
import { signalState } from '../src';
import { patchState, signalState } from '../src';
import { testEffects } from './helpers';

describe('signalState', () => {
Expand All @@ -13,167 +13,100 @@ describe('signalState', () => {
ngrx: 'signals',
};

describe('$update', () => {
it('updates state via partial state object', () => {
const state = signalState(initialState);
it('creates signals for nested state slices', () => {
const state = signalState(initialState);

state.$update({
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});
expect(state()).toBe(initialState);
expect(isSignal(state)).toBe(true);

expect(state()).toEqual({
...initialState,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});
});
expect(state.user()).toBe(initialState.user);
expect(isSignal(state.user)).toBe(true);

it('updates state via updater function', () => {
const state = signalState(initialState);
expect(state.user.firstName()).toBe(initialState.user.firstName);
expect(isSignal(state.user.firstName)).toBe(true);

state.$update((state) => ({
numbers: [...state.numbers, 4],
ngrx: 'rocks',
}));
expect(state.foo()).toBe(initialState.foo);
expect(isSignal(state.foo)).toBe(true);

expect(state()).toEqual({
...initialState,
numbers: [1, 2, 3, 4],
ngrx: 'rocks',
});
});
expect(state.numbers()).toBe(initialState.numbers);
expect(isSignal(state.numbers)).toBe(true);

expect(state.ngrx()).toBe(initialState.ngrx);
expect(isSignal(state.ngrx)).toBe(true);
});

it('does not modify props that are not state slices', () => {
const state = signalState(initialState);
(state as any).x = 1;
(state.user as any).x = 2;
(state.user.firstName as any).x = 3;

expect((state as any).x).toBe(1);
expect((state.user as any).x).toBe(2);
expect((state.user.firstName as any).x).toBe(3);

it('updates state via sequence of partial state objects and updater functions', () => {
expect((state as any).y).toBe(undefined);
expect((state.user as any).y).toBe(undefined);
expect((state.user.firstName as any).y).toBe(undefined);
});

it(
'emits new values only for affected signals',
testEffects((tick) => {
const state = signalState(initialState);
let numbersEmitted = 0;
let userEmitted = 0;
let firstNameEmitted = 0;

state.$update(
{ user: { firstName: 'Johannes', lastName: 'Schmidt' } },
(state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }),
(state) => ({ user: { ...state.user, firstName: 'Jovan' } }),
{ foo: 'foo' }
);

expect(state()).toEqual({
...initialState,
user: { firstName: 'Jovan', lastName: 'Schmidt' },
foo: 'foo',
numbers: [1, 2, 3, 4],
effect(() => {
state.numbers();
numbersEmitted++;
});
});

it('updates state immutably', () => {
const state = signalState(initialState);
effect(() => {
state.user();
userEmitted++;
});

state.$update({
foo: 'bar',
numbers: [3, 2, 1],
ngrx: 'rocks',
effect(() => {
state.user.firstName();
firstNameEmitted++;
});

expect(state.user()).toBe(initialState.user);
expect(state.foo()).toBe(initialState.foo);
expect(state.numbers()).not.toBe(initialState.numbers);
expect(state.ngrx()).not.toBe(initialState.ngrx);
});
});
expect(numbersEmitted).toBe(0);
expect(userEmitted).toBe(0);
expect(firstNameEmitted).toBe(0);

describe('nested signals', () => {
it('creates signals for nested state slices', () => {
const state = signalState(initialState);
tick();

expect(state()).toBe(initialState);
expect(isSignal(state)).toBe(true);
expect(numbersEmitted).toBe(1);
expect(userEmitted).toBe(1);
expect(firstNameEmitted).toBe(1);

expect(state.user()).toBe(initialState.user);
expect(isSignal(state.user)).toBe(true);
patchState(state, { numbers: [1, 2, 3] });
tick();

expect(state.user.firstName()).toBe(initialState.user.firstName);
expect(isSignal(state.user.firstName)).toBe(true);
expect(numbersEmitted).toBe(2);
expect(userEmitted).toBe(1);
expect(firstNameEmitted).toBe(1);

expect(state.foo()).toBe(initialState.foo);
expect(isSignal(state.foo)).toBe(true);
patchState(state, (state) => ({
user: { ...state.user, lastName: 'Schmidt' },
}));
tick();

expect(state.numbers()).toBe(initialState.numbers);
expect(isSignal(state.numbers)).toBe(true);
expect(numbersEmitted).toBe(2);
expect(userEmitted).toBe(2);
expect(firstNameEmitted).toBe(1);

expect(state.ngrx()).toBe(initialState.ngrx);
expect(isSignal(state.ngrx)).toBe(true);
});
patchState(state, (state) => ({
user: { ...state.user, firstName: 'Johannes' },
}));
tick();

it('does not modify props that are not state slices', () => {
const state = signalState(initialState);
(state as any).x = 1;
(state.user as any).x = 2;
(state.user.firstName as any).x = 3;

expect((state as any).x).toBe(1);
expect((state.user as any).x).toBe(2);
expect((state.user.firstName as any).x).toBe(3);

expect((state as any).y).toBe(undefined);
expect((state.user as any).y).toBe(undefined);
expect((state.user.firstName as any).y).toBe(undefined);
});

it(
'emits new values only for affected signals',
testEffects((tick) => {
const state = signalState(initialState);
let numbersEmitted = 0;
let userEmitted = 0;
let firstNameEmitted = 0;

effect(() => {
state.numbers();
numbersEmitted++;
});

effect(() => {
state.user();
userEmitted++;
});

effect(() => {
state.user.firstName();
firstNameEmitted++;
});

expect(numbersEmitted).toBe(0);
expect(userEmitted).toBe(0);
expect(firstNameEmitted).toBe(0);

tick();

expect(numbersEmitted).toBe(1);
expect(userEmitted).toBe(1);
expect(firstNameEmitted).toBe(1);

state.$update({ numbers: [1, 2, 3] });
tick();

expect(numbersEmitted).toBe(2);
expect(userEmitted).toBe(1);
expect(firstNameEmitted).toBe(1);

state.$update((state) => ({
user: { ...state.user, lastName: 'Schmidt' },
}));
tick();

expect(numbersEmitted).toBe(2);
expect(userEmitted).toBe(2);
expect(firstNameEmitted).toBe(1);

state.$update((state) => ({
user: { ...state.user, firstName: 'Johannes' },
}));
tick();

expect(numbersEmitted).toBe(2);
expect(userEmitted).toBe(3);
expect(firstNameEmitted).toBe(2);
})
);
});
expect(numbersEmitted).toBe(2);
expect(userEmitted).toBe(3);
expect(firstNameEmitted).toBe(2);
})
);
});
2 changes: 1 addition & 1 deletion modules/signals/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { PartialStateUpdater, patchState } from './patch-state';
export { selectSignal } from './select-signal';
export { signalState } from './signal-state';
export { SignalStateUpdater } from './signal-state-models';
20 changes: 20 additions & 0 deletions modules/signals/src/patch-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SIGNAL_STATE_META_KEY, SignalStateMeta } from './signal-state';

export type PartialStateUpdater<State extends Record<string, unknown>> =
| Partial<State>
| ((state: State) => Partial<State>);

export function patchState<State extends Record<string, unknown>>(
signalState: SignalStateMeta<State>,
...updaters: PartialStateUpdater<State>[]
): void {
signalState[SIGNAL_STATE_META_KEY].update((currentState) =>
updaters.reduce(
(nextState: State, updater) => ({
...nextState,
...(typeof updater === 'function' ? updater(nextState) : updater),
}),
currentState
)
);
}
21 changes: 0 additions & 21 deletions modules/signals/src/signal-state-models.ts

This file was deleted.

Loading