Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
8 changes: 8 additions & 0 deletions packages/utils/src/store/ReactStore.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expectType } from '../testUtils';
import { createSelector } from './createSelector';
import type { Store } from './Store';
import { ReactStore } from './ReactStore';

interface TestState {
Expand Down Expand Up @@ -120,3 +121,10 @@ const mismatchedListener = (newValue: string) => {
};
// @ts-expect-error listener must match selector return type
store.observe((state) => state.text.length, mismatchedListener);

// Calling create() on the generic class constructs a ReactStore at runtime, but the
// inferred instance type degrades to the base Store — a known limitation (see Store.create).
{
const degraded = ReactStore.create({ count: 0 });
expectType<Store<{ count: number }>, typeof degraded>(degraded);
}
9 changes: 9 additions & 0 deletions packages/utils/src/store/ReactStore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ function useStableStore<State extends object>(initial: State) {
describe('ReactStore', () => {
const { render } = createRenderer();

it('create() constructs a fully wired ReactStore instance', () => {
const store = ReactStore.create({ value: 1, label: 'a' });

expect(store).toBeInstanceOf(ReactStore);
expect(store.state.value).toBe(1);
// The static type degrades to `Store` on the generic class (see Store.create).
expect((store as ReactStore<TestState>).context).toEqual({});
});

it('syncs internal state from controlled prop', () => {
let store!: ReactStore<TestState>;

Expand Down
15 changes: 15 additions & 0 deletions packages/utils/src/store/Store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expectType } from '../testUtils';
import { Store } from './Store';

// Store.create returns an instance of the class it is called on.
{
const store = Store.create({ value: 1 });
expectType<Store<{ value: number }>, typeof store>(store);
}
{
class SubStore extends Store<{ value: number }> {
isSub = true;
}
const sub = SubStore.create({ value: 1 });
expectType<SubStore, typeof sub>(sub);
}
133 changes: 133 additions & 0 deletions packages/utils/src/store/Store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { expect, vi } from 'vitest';
import { Store } from './Store';

type State = { value: number; label: string };

describe('Store', () => {
describe('Store.create', () => {
it('returns a Store instance seeded with the given state', () => {
const store = Store.create({ value: 1, label: 'a' });

expect(store).toBeInstanceOf(Store);
expect(store.state).toEqual({ value: 1, label: 'a' });
});

it('produces an independent instance per call', () => {
const first = Store.create({ value: 0 });
const second = Store.create({ value: 0 });

first.set('value', 1);

expect(first.state.value).toBe(1);
expect(second.state.value).toBe(0);
});

it('constructs an instance of the subclass it is called on', () => {
class SubStore extends Store<{ count: number }> {
increment() {
this.set('count', this.state.count + 1);
}
}

const store = SubStore.create({ count: 1 });

expect(store).toBeInstanceOf(SubStore);
store.increment();
expect(store.state.count).toBe(2);
});
});

it('notifies subscribers with the new state', () => {
const store = new Store<State>({ value: 0, label: 'a' });
const listener = vi.fn();
store.subscribe(listener);

store.setState({ value: 1, label: 'a' });

expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ value: 1, label: 'a' });
expect(store.state.value).toBe(1);
});

it('does not notify when setState receives the current state reference', () => {
const store = new Store<State>({ value: 0, label: 'a' });
const listener = vi.fn();
store.subscribe(listener);

store.setState(store.state);

expect(listener).not.toHaveBeenCalled();
});

it('unsubscribing stops notifications', () => {
const store = new Store<State>({ value: 0, label: 'a' });
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);

unsubscribe();
store.set('value', 1);

expect(listener).not.toHaveBeenCalled();
});

it('set() writes a single key and skips same-value writes', () => {
const store = new Store<State>({ value: 0, label: 'a' });
const listener = vi.fn();
store.subscribe(listener);

store.set('value', 1);
expect(store.state).toEqual({ value: 1, label: 'a' });
expect(listener).toHaveBeenCalledTimes(1);

store.set('value', 1);
expect(listener).toHaveBeenCalledTimes(1);
});

it('update() merges changed keys and skips no-op updates', () => {
const store = new Store<State>({ value: 0, label: 'a' });
const listener = vi.fn();
store.subscribe(listener);

store.update({ value: 2, label: 'b' });
expect(store.state).toEqual({ value: 2, label: 'b' });
expect(listener).toHaveBeenCalledTimes(1);

store.update({ value: 2, label: 'b' });
expect(listener).toHaveBeenCalledTimes(1);
});

it('notifyAll() renews the state reference and notifies', () => {
const store = new Store<State>({ value: 0, label: 'a' });
const listener = vi.fn();
store.subscribe(listener);
const previous = store.state;

store.notifyAll();

expect(listener).toHaveBeenCalledTimes(1);
expect(store.state).not.toBe(previous);
expect(store.state).toEqual(previous);
});

it('a nested setState from a listener stops the outer notification pass', () => {
const store = new Store<State>({ value: 0, label: 'a' });

const first = vi.fn((state: State) => {
if (state.value === 1) {
store.set('value', 2);
}
});
const second = vi.fn();
store.subscribe(first);
store.subscribe(second);

store.set('value', 1);

// The nested set() notified every listener with the final state; the outer
// pass detected it and did not deliver the stale state to `second`.
expect(store.state.value).toBe(2);
expect(first).toHaveBeenCalledTimes(2);
expect(second).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledWith({ value: 2, label: 'a' });
});
});
9 changes: 9 additions & 0 deletions packages/utils/src/store/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ type Listener<T> = (state: T) => void;
* It uses an observer pattern to notify subscribers when the state changes.
*/
export class Store<State> {
/**
* Creates a store with the given initial state, constructing the class it is called on.
* Calling it on a generic base class (e.g. `ReactStore.create(...)`) constructs that
* class but degrades the inferred instance type to `Store`; use `new` there instead.
*/
static create<T, This extends Store<T>>(this: new (state: T) => This, state: T): This {
return new this(state);
}

/**
* The current state of the store.
* This property is updated immediately when the state changes as a result of calling {@link setState}, {@link update}, or {@link set}.
Expand Down
60 changes: 60 additions & 0 deletions packages/utils/src/store/createSelector.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expectType } from '../testUtils';
import { createSelector } from './createSelector';

interface State {
value: number;
label: string;
}

declare const state: State;
const input = (s: State) => s.value;

// Combiners receive the input selector results plus up to three additional arguments.
{
const selector = createSelector(
(s: State) => s.value,
(s: State) => s.label,
(value, label, x1: number, x2: string, x3: boolean) => `${value}${label}${x1}${x2}${x3}`,
);
expectType<string, ReturnType<typeof selector>>(selector(state, 1, 'a', true));
}

// Seven input selectors go through the unrolled fast paths.
createSelector(
input,
input,
input,
input,
input,
input,
input,
(v1, v2, v3, v4, v5, v6, v7) => v1 + v2 + v3 + v4 + v5 + v6 + v7,
);

// prettier-ignore
// @ts-expect-error Eight input selectors are not supported.
createSelector(input, input, input, input, input, input, input, input, (v1: number) => v1);

// A composed combiner takes at most three arguments beyond the input selector results,
// since the runtime forwards only three.
createSelector(input, (value, x1: number, x2: number, x3: number) => value + x1 + x2 + x3);

// prettier-ignore
// @ts-expect-error A composed combiner cannot take a fourth extra argument.
createSelector(input, (value, x1: number, x2: number, x3: number, x4: number) => value + x4);

// prettier-ignore
// @ts-expect-error The limit counts the arguments left after the input selector results.
createSelector(input, input, (v1, v2, x1: number, x2: number, x3: number, x4: number) => v1 + v2 + x4);

// The single-function form is returned verbatim, so it keeps its own signature and is not
// bound by the extra-argument limit.
{
const constant = createSelector(() => 42);
expectType<number, ReturnType<typeof constant>>(constant());
}
createSelector((s: State, x1: number, x2: number, x3: number, x4: number) => s.value + x4);

// prettier-ignore
// @ts-expect-error Combiners cannot have optional parameters.
createSelector(input, (value, x1?: number) => value + (x1 ?? 0));
89 changes: 89 additions & 0 deletions packages/utils/src/store/createSelector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect } from 'vitest';
import { createSelector } from './createSelector';

describe('createSelector', () => {
it('returns the input function when called with a single selector', () => {
const fn = (state: { value: number }) => state.value;
const selector = createSelector(fn);

expect(selector).toBe(fn);
expect(selector({ value: 5 })).toBe(5);
});

it('supports one input selector plus a combiner', () => {
type S = { a: number };
const state: S = { a: 1 };

const selector = createSelector(
(s: S) => s.a,
(a) => a + 1,
);

expect(selector(state)).toBe(2);
});

it('supports six input selectors plus a combiner', () => {
type S = { v1: number; v2: number; v3: number; v4: number; v5: number; v6: number };
const state: S = { v1: 1, v2: 2, v3: 4, v4: 8, v5: 16, v6: 32 };

const selector = createSelector(
(s: S) => s.v1,
(s: S) => s.v2,
(s: S) => s.v3,
(s: S) => s.v4,
(s: S) => s.v5,
(s: S) => s.v6,
(v1, v2, v3, v4, v5, v6) => v1 + v2 + v3 + v4 + v5 + v6,
);

expect(selector(state)).toBe(63);
});

it('supports seven input selectors plus a combiner', () => {
type S = {
v1: number;
v2: number;
v3: number;
v4: number;
v5: number;
v6: number;
v7: number;
};
const state: S = { v1: 1, v2: 2, v3: 4, v4: 8, v5: 16, v6: 32, v7: 64 };

const selector = createSelector(
(s: S) => s.v1,
(s: S) => s.v2,
(s: S) => s.v3,
(s: S) => s.v4,
(s: S) => s.v5,
(s: S) => s.v6,
(s: S) => s.v7,
(v1, v2, v3, v4, v5, v6, v7) => v1 + v2 + v3 + v4 + v5 + v6 + v7,
);

expect(selector(state)).toBe(127);
});

it('throws when given one more than the maximum (eight input selectors plus a combiner)', () => {
const fn = (s: any) => s;

expect(
// @ts-expect-error nine functions exceed the supported arity
() => createSelector(fn, fn, fn, fn, fn, fn, fn, fn, fn),
).toThrow('Unsupported number of selectors');
});

it('passes extra args through to every input selector and to the combiner', () => {
type S = { value: number };
const state: S = { value: 10 };

const selector = createSelector(
(s: S, multiplier: number) => s.value * multiplier,
(s: S, _multiplier: number, offset: number) => s.value + offset,
(scaled, shifted, multiplier, offset) => ({ scaled, shifted, multiplier, offset }),
);

expect(selector(state, 3, 7)).toEqual({ scaled: 30, shifted: 17, multiplier: 3, offset: 7 });
});
});
Loading
Loading