Skip to content

Commit

Permalink
feat(signal-store): Selecting into a store whose value can be undefin…
Browse files Browse the repository at this point in the history
…ed now works, returning a `ReadonlyStore`

BREAKING CHANGE: The typing for stores in the new @s-libs/signal-store is more strict, e.g. forbidding `.assign()` when the state could be undefined.
  • Loading branch information
ersimont authored May 11, 2024
1 parent 2f35dd1 commit 3c015bd
Show file tree
Hide file tree
Showing 20 changed files with 550 additions and 317 deletions.
1 change: 1 addition & 0 deletions .idea/inspectionProfiles/Project_Default.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion projects/integration/src/app/api-tests/signal-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
spreadArrayStore,
pushToArrayStore,
} from '@s-libs/signal-store';
import { staticTest } from '@s-libs/ng-dev';
import { expectTypeOf } from 'expect-type';

describe('signal-store', () => {
it('has PersistentStore', () => {
Expand All @@ -16,7 +18,9 @@ describe('signal-store', () => {
});

it('has Store', () => {
expect(Store).toBeDefined();
staticTest(() => {
expectTypeOf<Store<number>>().toBeObject();
});
});

it('has spreadArrayStore()', () => {
Expand Down
210 changes: 210 additions & 0 deletions projects/signal-store/src/lib/abstract-store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { cloneDeep, identity, pick } from '@s-libs/micro-dash';
import { ComponentContext, staticTest } from '@s-libs/ng-dev';
import { expectTypeOf } from 'expect-type';
import { InnerState, TestState } from '../test-helpers/test-state';
import { RootStore } from './root-store';
import { Store } from './store';

describe('AbstractStore', () => {
let store: RootStore<TestState>;

beforeEach(() => {
store = new RootStore(new TestState());
});

describe('()', () => {
it('caches values', () => {
expect(store('counter')).toBe(store('counter'));
});
});

describe('.state', () => {
it('works when there are no subscribers', () => {
expect(store('nested')('state').state).toBe(0);
expect(store('nested')('state').state).toBe(0);

store('nested')('state').state = 1;
expect(store('nested')('state').state).toBe(1);
expect(store('nested')('state').state).toBe(1);
});

it('integrates with change detection & such', () => {
@Component({
selector: 'sl-inner',
standalone: true,
template: `{{ store('counter').state }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class InnerComponent {
store = store;
}

@Component({
standalone: true,
template: `<sl-inner />`,
imports: [InnerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestComponent {}

const ctx = new ComponentContext(TestComponent);
ctx.run(async () => {
expect(ctx.fixture.nativeElement.textContent).toBe('0');
store('counter').state = 1;
ctx.tick();
expect(ctx.fixture.nativeElement.textContent).toBe('1');
});
});
});

describe('.assign()', () => {
it('assigns the exact objects given', () => {
const before = store('nested').state;
const left = new InnerState();
const right = new InnerState();
store('nested').assign({ left, right });
const after = store('nested').state;

expect(before).not.toBe(after);
expect(before.left).toBeUndefined();
expect(before.right).toBeUndefined();
expect(after.left).toBe(left);
expect(after.right).toBe(right);
});

it('does nothing when setting to the same value', () => {
const startingState = store.state;
const stateClone = cloneDeep(startingState);

store.assign(pick(startingState, 'counter', 'nested'));
expect(store.state).toBe(startingState);
expect(cloneDeep(store.state)).toEqual(stateClone);

store.assign({});
expect(store.state).toBe(startingState);
expect(cloneDeep(store.state)).toEqual(stateClone);

store('nested').assign(startingState.nested);
expect(store.state).toBe(startingState);
expect(cloneDeep(store.state)).toEqual(stateClone);
});

it('throws with a useful message when the state is missing', () => {
expect(() => {
store('optional').nonNull.assign({ state: 3 });
}).toThrowError('cannot assign to undefined state');
});
});

describe('.update()', () => {
it('set the state to the exact object returned', () => {
const object = new InnerState();
store('optional').update(() => object);
expect(store('optional').state).toBe(object);
});

it('uses the passed-in arguments', () => {
store('nested').update(() => new InnerState(1));
expect(store('nested')('state').state).toBe(1);

store('nested').update(
(_state, left, right) => {
const newState = new InnerState(2);
newState.left = left;
newState.right = right;
return newState;
},
new InnerState(3),
new InnerState(4),
);
expect(store('nested')('state').state).toBe(2);
expect(store('nested')('left').state!.state).toBe(3);
expect(store('nested')('right').state!.state).toBe(4);
});

it('is OK having `undefined` returned', () => {
store('optional').state = new InnerState();

expect(store('optional').state).not.toBe(undefined);
store('optional').update(() => undefined);
expect(store('optional').state).toBe(undefined);
});

it('is OK having the same object returned', () => {
const origState = store.state;
store.update(identity);
expect(store.state).toBe(origState);
});

it('throws with a useful message when the state is missing', () => {
expect(() => {
store('optional')
.nonNull('state')
.update(() => 3);
}).toThrowError('cannot modify when parent state is missing');
});

it('does nothing when setting to the same value', () => {
const startingState = store.state;
const stateClone = cloneDeep(startingState);

store.update(identity);
expect(store.state).toBe(startingState);
expect(cloneDeep(store.state)).toEqual(stateClone);

store('counter').update(identity);
expect(store.state).toBe(startingState);
expect(cloneDeep(store.state)).toEqual(stateClone);

store('nested').update(identity);
expect(store.state).toBe(startingState);
expect(cloneDeep(store.state)).toEqual(stateClone);
});
});

describe('.mutate()', () => {
it('uses the passed-in arguments', () => {
store('array').state = [];

store('array').mutate((array) => {
array!.push(1);
});
expect(store('array').state).toEqual([1]);

store('array').mutate(
(array, a, b) => {
array!.push(a, b);
},
2,
3,
);
expect(store('array').state).toEqual([1, 2, 3]);
});

it('works when the state is undefined', () => {
store('optional').mutate((value) => {
expect(value).toBe(undefined);
});
});
});

it('has fancy typing', () => {
staticTest(() => {
class State {
a!: number;
b!: string;
obj!: { c: Date };
ary!: boolean[];
}

const str = new RootStore(new State());

expectTypeOf(str('a')).toEqualTypeOf<Store<number>>();
expectTypeOf(str('obj')).toEqualTypeOf<Store<{ c: Date }>>();
expectTypeOf(str('obj')('c')).toEqualTypeOf<Store<Date>>();
expectTypeOf(str('ary')).toEqualTypeOf<Store<boolean[]>>();
expectTypeOf(str('ary')(1)).toEqualTypeOf<Store<boolean>>();
});
});
});
95 changes: 95 additions & 0 deletions projects/signal-store/src/lib/abstract-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Signal } from '@angular/core';
import { CallableObject, WeakValueMap } from '@s-libs/js-core';
import { clone, every, isUndefined } from '@s-libs/micro-dash';
import { buildChild, ChildStore } from './child-store';
import { GetSlice, Slice, Store } from './store';

export interface AbstractStore<T> {
// eslint-disable-next-line @typescript-eslint/prefer-function-type -- this syntax is required to merge with the class below
<K extends keyof NonNullable<T>>(attr: K): Slice<T, K>;
}

export abstract class AbstractStore<T>
extends CallableObject<GetSlice<T>>
implements Store<T>
{
/**
* Assigns the given values to the state of this store object. The resulting state will be like `Object.assign(store.state, value)`.
*/
assign = this.#assign as Store<T>['assign'];

#children = new WeakValueMap<keyof NonNullable<T>, ChildStore<any>>();

constructor(
private signal: Signal<T>,
makeChild: typeof buildChild,
) {
super((childKey): any => {
let child = this.#children.get(childKey);
if (child === undefined) {
child = makeChild(this, childKey);
this.#children.set(childKey, child);
}
return child;
});
}

get nonNull(): Store<NonNullable<T>> {
return this as unknown as Store<NonNullable<T>>;
}

/**
* Get the current state of this store object. This is backed by a signal, so it will trigger change detection when accessed in templates, etc.
*/
get state(): T {
return this.signal();
}

/**
* Change the value of this store. Following the pattern of immutable objects, the parent store will also update with shallow copy but with this value swapped in, and so on for all ancestors.
*/
set state(value: T) {
this.set(value);
}

/**
* Runs `func` on the state and replaces it with the return value. The first argument to `func` will be the state, followed by the arguments in `args`.
*
* WARNING: You SHOULD NOT use a function that will mutate the state.
*/
update<A extends any[]>(func: (state: T, ...args: A) => T, ...args: A): void {
this.set(func(this.state, ...args));
}

/**
* Runs `func` on a shallow clone of the state, replacing the state with the clone. The first argument to `func` will be the cloned state, followed by the arguments in `args`.
*
* WARNING: You SHOULD NOT use a function that will mutate nested objects within the state.
*/
mutate<A extends any[]>(
func: (state: T, ...args: A) => void,
...args: A
): void {
const state = clone(this.state);
func(state, ...args);
this.set(state);
}

#assign(value: Partial<T>): void {
this.update((state) => {
if (isUndefined(state)) {
throw new Error('cannot assign to undefined state');
}

if (
every(value, (innerValue, key) => state[key as keyof T] === innerValue)
) {
return state;
} else {
return { ...state, ...value };
}
});
}

protected abstract set(value: T): void;
}
4 changes: 2 additions & 2 deletions projects/signal-store/src/lib/child-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { InnerState, TestState } from '../test-helpers/test-state';
import { RootStore } from './index';
import { RootStore } from './root-store';

describe('ChildStore', () => {
let store: RootStore<TestState>;
Expand All @@ -11,7 +11,7 @@ describe('ChildStore', () => {
describe('()', () => {
it('throws with a useful message when used to modify missing state', () => {
expect(() => {
store<'optional', InnerState>('optional')('state').state = 2;
store('optional').nonNull('state').state = 2;
}).toThrowError('cannot modify when parent state is missing');
});
});
Expand Down
12 changes: 7 additions & 5 deletions projects/signal-store/src/lib/child-store.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { computed, Signal } from '@angular/core';
import { clone } from '@s-libs/micro-dash';
import { AbstractStore } from './abstract-store';
import { Store } from './store';

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */

// defined here and passed to `Store` to work around some problems with circular imports
export function buildChild(parent: Store<any>, childKey: any): Store<unknown> {
export function buildChild(
parent: Store<any>,
childKey: keyof any,
): ChildStore<any> {
const childSignal = computed((): any => parent.state?.[childKey]);
return new ChildStore(parent, childKey, childSignal);
}

export class ChildStore<T> extends Store<T> {
export class ChildStore<T> extends AbstractStore<T> {
constructor(
private parent: Store<any>,
private key: any,
private key: keyof any,
signal: Signal<T>,
) {
super(signal, buildChild);
Expand Down
4 changes: 0 additions & 4 deletions projects/signal-store/src/lib/index.ts

This file was deleted.

Loading

0 comments on commit 3c015bd

Please sign in to comment.