refactor(store): merge getSnapshot/subscribe into attach (#364)

This commit is contained in:
rahim
2026-02-01 19:00:46 +11:00
committed by GitHub
parent 9b36f3acbf
commit 6ec2e80b86
27 changed files with 520 additions and 715 deletions
+5 -21
View File
@@ -31,40 +31,25 @@ export interface TaskContext<Target, State extends object> {
}
// ----------------------------------------
// Sync Config
// Attach
// ----------------------------------------
export type GetSnapshot<Target, State extends object> = (ctx: GetSnapshotContext<Target, State>) => Partial<State>;
export type Attach<Target, State extends object> = (ctx: AttachContext<Target, State>) => void;
export interface GetSnapshotContext<Target, State extends object> {
export interface AttachContext<Target, State extends object> {
target: Target;
get: () => Readonly<State>;
initialState: Readonly<State>;
}
export type Subscribe<Target, State extends object> = (ctx: SubscribeContext<Target, State>) => void;
export interface SubscribeContext<Target, State extends object> {
target: Target;
update: () => void;
signal: AbortSignal;
get: () => Readonly<State>;
set: (partial: Partial<State>) => void;
}
// ----------------------------------------
// Feature Context
// ----------------------------------------
export interface FeatureContext<Target, State extends object> {
task: Task<Target, State>;
get: () => Readonly<State>;
target: () => Target;
}
/** Context passed to state factory - uses loose types to enable State inference. */
export interface StateFactoryContext<Target> {
task: Task<Target, any>;
get: () => Readonly<object>;
target: () => Target;
}
@@ -76,8 +61,7 @@ export type StateFactory<Target, State extends object> = (ctx: StateFactoryConte
export interface FeatureConfig<Target, State extends object> {
state: StateFactory<Target, State>;
getSnapshot: GetSnapshot<Target, State>;
subscribe: Subscribe<Target, State>;
attach?: Attach<Target, State>;
}
export interface Feature<Target, State extends object> extends FeatureConfig<Target, State> {
+6 -26
View File
@@ -6,28 +6,21 @@ export interface State<T extends object> {
}
export interface WritableState<T extends object> extends State<T> {
set: <K extends keyof T>(key: K, value: T[K]) => void;
patch: (partial: Partial<T>) => void;
delete: <K extends keyof T>(key: K) => void;
}
let flushScheduled = false;
let isFlushScheduled = false;
function scheduleFlush(): void {
if (flushScheduled) return;
flushScheduled = true;
if (isFlushScheduled) return;
isFlushScheduled = true;
queueMicrotask(flush);
}
const pendingContainers = new Set<StateContainer<any>>();
export function flush(): void {
flushScheduled = false;
for (const container of pendingContainers) {
container.flush();
}
isFlushScheduled = false;
for (const container of pendingContainers) container.flush();
pendingContainers.clear();
}
@@ -46,21 +39,9 @@ class StateContainer<T extends object> implements WritableState<T> {
return this.#current;
}
set<K extends keyof T>(key: K, value: T[K]): void {
if (Object.is(this.#current[key], value)) return;
this.#current = Object.freeze({ ...this.#current, [key]: value });
this.#markPending();
}
delete<K extends keyof T>(key: K): void {
if (!(key in this.#current)) return;
const { [key]: _, ...rest } = this.#current;
this.#current = Object.freeze(rest as T);
this.#markPending();
}
patch(partial: Partial<T>): void {
const next = { ...this.#current };
let changed = false;
for (const key in partial) {
@@ -88,7 +69,6 @@ class StateContainer<T extends object> implements WritableState<T> {
flush(): void {
if (!this.#pending) return;
this.#pending = false;
for (const fn of this.#listeners) fn();
}
+11 -32
View File
@@ -4,6 +4,7 @@ import type { PendingTask, StoreConfig } from './config';
import { StoreError } from './errors';
import type {
AnyFeature,
AttachContext,
StateFactoryContext,
TaskContext,
TaskHandler,
@@ -37,16 +38,15 @@ export function createStore<Features extends AnyFeature[]>(config: StoreConfig<F
// Reactive state - initialized after building features
let state: WritableState<State>;
const ctx: StateFactoryContext<Target> = {
const stateFactoryCtx: StateFactoryContext<Target> = {
task: executeTask,
get: () => state.current,
target: () => {
if (!target) throw new StoreError('NO_TARGET');
return target;
},
};
const featureState = buildFeatureState(ctx);
const featureState = buildFeatureState(stateFactoryCtx);
state = createState(featureState);
const store = {
@@ -110,23 +110,22 @@ export function createStore<Features extends AnyFeature[]>(config: StoreConfig<F
attachAbort = new AbortController();
const signal = attachAbort.signal;
state.patch(featureState);
// Create attach context once, share across all features
const attachCtx: AttachContext<Target, State> = {
target: newTarget,
signal,
get: () => state.current,
set: (partial) => state.patch(partial),
};
for (const feature of features) {
try {
feature.subscribe({
target: newTarget,
update: () => syncFeature(feature, newTarget),
signal,
get: () => state.current,
});
feature.attach?.(attachCtx);
} catch (error) {
handleError(error);
}
}
syncAll();
try {
config.onAttach?.({ store, target: newTarget, signal });
} catch (error) {
@@ -176,26 +175,6 @@ export function createStore<Features extends AnyFeature[]>(config: StoreConfig<F
return result as State;
}
function syncAll(): void {
if (!target) return;
for (const feature of features) {
syncFeature(feature, target);
}
}
function syncFeature(feature: AnyFeature<Target>, t: Target): void {
try {
const snapshot = feature.getSnapshot({
target: t,
get: () => state.current,
initialState: featureState,
});
state.patch(snapshot as Partial<State>);
} catch (error) {
handleError(error);
}
}
async function executeTask<Output>(handler: TaskHandler<Target, State, Output>): Promise<Awaited<Output>>;
async function executeTask<Output>(options: TaskOptions<Target, State, Output>): Promise<Awaited<Output>>;
async function executeTask<Output>(
+25 -25
View File
@@ -3,20 +3,15 @@ import { describe, expect, it, vi } from 'vitest';
import { defineFeature, isFeature } from '../feature';
describe('defineFeature', () => {
it('creates feature with create function and config', () => {
it('creates feature with state factory and optional attach', () => {
interface Target {
value: number;
}
const feature = defineFeature<Target>()({
state: ({ task, target }) => ({
// State
state: ({ task }) => ({
count: 0,
// Actions
increment(amount: number) {
target().value += amount;
},
asyncIncrement(amount: number) {
return task({
key: 'increment',
handler: ({ target }) => {
@@ -25,16 +20,17 @@ describe('defineFeature', () => {
});
},
}),
getSnapshot: ({ target }) => ({ count: target.value }),
subscribe: vi.fn(),
attach({ target, set }) {
set({ count: target.value });
},
});
expect(feature.state).toBeTypeOf('function');
expect(feature.getSnapshot).toBeTypeOf('function');
expect(feature.subscribe).toBeTypeOf('function');
expect(feature.attach).toBeTypeOf('function');
});
it('factory receives task, get, and target helpers', () => {
it('factory receives task and target helpers', () => {
interface Target {
value: number;
}
@@ -43,8 +39,6 @@ describe('defineFeature', () => {
defineFeature<Target>()({
state: factorySpy,
getSnapshot: () => ({ count: 0 }),
subscribe: () => {},
});
// Can't call the factory directly, but we can verify the shape
@@ -52,20 +46,23 @@ describe('defineFeature', () => {
expect(factorySpy).not.toHaveBeenCalled();
});
it('allows sync actions using target()', () => {
it('allows sync actions using task handler', () => {
interface Target {
volume: number;
}
const feature = defineFeature<Target>()({
state: ({ target }) => ({
state: ({ task }) => ({
volume: 1,
setVolume(value: number) {
target().volume = value;
return task({
key: 'volume',
handler: ({ target }) => {
target.volume = value;
},
});
},
}),
getSnapshot: ({ target }) => ({ volume: target.volume }),
subscribe: () => {},
});
expect(feature.state).toBeTypeOf('function');
@@ -86,8 +83,6 @@ describe('defineFeature', () => {
});
},
}),
getSnapshot: () => ({ playing: false }),
subscribe: () => {},
});
expect(feature.state).toBeTypeOf('function');
@@ -109,20 +104,25 @@ describe('defineFeature', () => {
});
},
}),
getSnapshot: () => ({ loading: false }),
subscribe: () => {},
});
expect(feature.state).toBeTypeOf('function');
});
it('attach is optional', () => {
const feature = defineFeature<HTMLVideoElement>()({
state: () => ({ playing: false }),
});
expect(feature.state).toBeTypeOf('function');
expect(feature.attach).toBeUndefined();
});
});
describe('isFeature', () => {
it('returns true for features created with defineFeature', () => {
const feature = defineFeature<HTMLVideoElement>()({
state: () => ({ playing: false }),
getSnapshot: () => ({ playing: false }),
subscribe: () => {},
});
expect(isFeature(feature)).toBe(true);
@@ -21,15 +21,16 @@ describe('store lifecycle integration', () => {
});
},
}),
getSnapshot: ({ target: t }) => ({ count: t.value }),
subscribe: ({ target: t, update, signal }) => {
events.push('subscribe');
t.addEventListener('change', update, { signal });
attach({ target: t, signal, set }) {
events.push('attach-feature');
set({ count: t.value });
t.addEventListener('change', () => set({ count: t.value }), { signal });
signal.addEventListener('abort', () => events.push('unsubscribe'));
},
});
// Cast to any for test access to dynamic properties
const store = createStore({
features: [feature],
onSetup: () => events.push('setup'),
@@ -42,7 +43,7 @@ describe('store lifecycle integration', () => {
targetInstance.value = 5;
const detach = store.attach(targetInstance);
expect(events).toEqual(['setup', 'subscribe', 'attach']);
expect(events).toEqual(['setup', 'attach-feature', 'attach']);
expect(store.state.count).toBe(5);
await store.increment();
@@ -93,8 +94,6 @@ describe('task coordination', () => {
});
},
}),
getSnapshot: () => ({ loading: false }),
subscribe: () => {},
});
const store = createStore({
@@ -134,8 +133,6 @@ describe('task coordination', () => {
});
},
}),
getSnapshot: () => ({ fetching: false }),
subscribe: () => {},
});
const store = createStore({ features: [feature] });
@@ -173,8 +170,6 @@ describe('task coordination', () => {
});
},
}),
getSnapshot: () => ({ running: false }),
subscribe: () => {},
});
const store = createStore({
@@ -218,8 +213,6 @@ describe('task coordination', () => {
});
},
}),
getSnapshot: () => ({ playing: false }),
subscribe: () => {},
});
const store = createStore({ features: [feature] });
@@ -251,8 +244,6 @@ describe('task coordination', () => {
});
},
}),
getSnapshot: () => ({ playing: false }),
subscribe: () => {},
});
const store = createStore({
@@ -287,8 +278,6 @@ describe('task coordination', () => {
});
},
}),
getSnapshot: () => ({ playing: false }),
subscribe: () => {},
});
const store = createStore({ features: [feature] });
@@ -312,14 +301,18 @@ describe('state syncing', () => {
it('multiple features merge state correctly', () => {
const audioFeature = defineFeature<{ volume: number; rate: number }>()({
state: () => ({ volume: 1 }),
getSnapshot: ({ target }) => ({ volume: target.volume }),
subscribe: () => {},
attach({ target, set }) {
set({ volume: target.volume });
},
});
const playbackFeature = defineFeature<{ volume: number; rate: number }>()({
state: () => ({ rate: 1 }),
getSnapshot: ({ target }) => ({ rate: target.rate }),
subscribe: () => {},
attach({ target, set }) {
set({ rate: target.rate });
},
});
const store = createStore({
@@ -355,9 +348,11 @@ describe('immediate execution', () => {
});
},
}),
getSnapshot: ({ target }) => ({ paused: target.paused }),
subscribe: ({ target, update, signal }) => {
target.addEventListener('play', update, { signal });
attach({ target, signal, set }) {
set({ paused: target.paused });
target.addEventListener('play', () => set({ paused: target.paused }), { signal });
},
});
@@ -390,8 +385,6 @@ describe('meta tracing', () => {
});
},
}),
getSnapshot: () => ({ playing: false }),
subscribe: () => {},
});
const store = createStore({ features: [feature] });
@@ -420,8 +413,6 @@ describe('meta tracing', () => {
});
},
}),
getSnapshot: () => ({ count: 0 }),
subscribe: () => {},
});
const store = createStore({
@@ -450,8 +441,6 @@ describe('meta tracing', () => {
});
},
}),
getSnapshot: () => ({ loading: false }),
subscribe: () => {},
});
const store = createStore({ features: [feature] });
@@ -472,45 +461,47 @@ describe('meta tracing', () => {
});
describe('sync actions', () => {
it('target() allows sync mutations without task', () => {
it('task handler allows sync mutations', async () => {
class Target {
volume = 1;
}
const feature = defineFeature<Target>()({
state: ({ target }) => ({
state: ({ task }) => ({
volume: 1,
setVolume(value: number) {
target().volume = value;
return task(({ target }) => {
target.volume = value;
});
},
}),
getSnapshot: ({ target: t }) => ({ volume: t.volume }),
subscribe: () => {},
attach({ target: t, set }) {
set({ volume: t.volume });
},
});
const store = createStore({ features: [feature] });
const targetInstance = new Target();
store.attach(targetInstance);
store.setVolume(0.5);
await store.setVolume(0.5);
expect(targetInstance.volume).toBe(0.5);
});
it('target() throws when not attached', () => {
it('task throws when not attached', async () => {
const feature = defineFeature<unknown>()({
state: ({ target }) => ({
state: ({ task }) => ({
value: 0,
doSomething() {
target();
return task(() => {});
},
}),
getSnapshot: () => ({ value: 0 }),
subscribe: () => {},
});
const store = createStore({ features: [feature] });
expect(() => store.doSomething()).toThrow('NO_TARGET');
await expect(store.doSomething()).rejects.toThrow('NO_TARGET');
});
});
+8 -32
View File
@@ -23,12 +23,6 @@ describe('createState', () => {
expect(state.current.muted).toBe(false);
});
it('reflects changes after set', () => {
const state = createTestState();
state.set('volume', 0.5);
expect(state.current.volume).toBe(0.5);
});
it('reflects changes after patch', () => {
const state = createTestState();
state.patch({ volume: 0.5, muted: true });
@@ -37,24 +31,6 @@ describe('createState', () => {
});
});
describe('set', () => {
it('updates a single key', () => {
const state = createTestState();
state.set('volume', 0.5);
expect(state.current.volume).toBe(0.5);
});
it('does not notify if value is the same', () => {
const state = createTestState();
const listener = vi.fn();
state.subscribe(listener);
state.set('volume', 1); // same as initial
flush();
expect(listener).not.toHaveBeenCalled();
});
});
describe('patch', () => {
it('updates multiple keys', () => {
const state = createTestState();
@@ -81,7 +57,7 @@ describe('createState', () => {
const listener = vi.fn();
state.subscribe(listener);
state.set('volume', 0.5);
state.patch({ volume: 0.5 });
expect(listener).not.toHaveBeenCalled();
await Promise.resolve();
@@ -93,21 +69,21 @@ describe('createState', () => {
const listener = vi.fn();
state.subscribe(listener);
state.set('volume', 0.5);
state.patch({ volume: 0.5 });
expect(listener).not.toHaveBeenCalled();
flush();
expect(listener).toHaveBeenCalledOnce();
});
it('batches multiple mutations into one notification', () => {
it('batches multiple patches into one notification', () => {
const state = createTestState();
const listener = vi.fn();
state.subscribe(listener);
state.set('volume', 0.5);
state.set('muted', true);
state.set('currentTime', 10);
state.patch({ volume: 0.5 });
state.patch({ muted: true });
state.patch({ currentTime: 10 });
flush();
expect(listener).toHaveBeenCalledOnce();
@@ -118,12 +94,12 @@ describe('createState', () => {
const listener = vi.fn();
const unsub = state.subscribe(listener);
state.set('volume', 0.5);
state.patch({ volume: 0.5 });
flush();
expect(listener).toHaveBeenCalledOnce();
unsub();
state.set('volume', 0.3);
state.patch({ volume: 0.3 });
flush();
expect(listener).toHaveBeenCalledOnce(); // still 1
});
+12 -17
View File
@@ -31,14 +31,15 @@ describe('store', () => {
});
},
}),
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: ({ target, update, signal }) => {
target.addEventListener('volumechange', update);
attach({ target, signal, set }) {
const sync = () => set({ volume: target.volume, muted: target.muted });
sync();
target.addEventListener('volumechange', sync);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', update);
target.removeEventListener('volumechange', sync);
});
},
});
@@ -65,8 +66,10 @@ describe('store', () => {
});
},
}),
getSnapshot: ({ target }) => ({ paused: target.paused }),
subscribe: () => {},
attach({ target, set }) {
set({ paused: target.paused });
},
});
describe('creation', () => {
@@ -234,8 +237,6 @@ describe('store', () => {
});
},
}),
getSnapshot: () => ({ value: 0 }),
subscribe: () => {},
});
const store = createStore({
@@ -272,8 +273,6 @@ describe('store', () => {
});
},
}),
getSnapshot: () => ({ value: 0 }),
subscribe: () => {},
});
const store = createStore({
@@ -306,8 +305,6 @@ describe('store', () => {
});
},
}),
getSnapshot: () => ({ value: 0 }),
subscribe: () => {},
});
const store = createStore({
@@ -404,8 +401,6 @@ describe('store', () => {
});
},
}),
getSnapshot: () => ({ value: 0 }),
subscribe: () => {},
});
const store = createStore({