-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(signal-store): Selecting into a store whose value can be undefin…
…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
Showing
20 changed files
with
550 additions
and
317 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>>(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.