Skip to content

Commit 6d0df8e

Browse files
Initial commit
1 parent 2703018 commit 6d0df8e

File tree

1 file changed

+115
-7
lines changed

1 file changed

+115
-7
lines changed

src/store.ts

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,31 @@ export type Patch<T> = (value: T) => T;
22
export type ValueOrPatch<T> = T | Patch<T>;
33
export type Handler<T> = (nextValue: T, previousValue: T | undefined) => void;
44
export type Unsubscribe = () => void;
5+
export type Unregister = Unsubscribe;
6+
export type Modifier<T> = (nextValue: T, previousValue: T | undefined) => void;
57

68
export const isPatch = <T>(value: ValueOrPatch<T>): value is Patch<T> =>
79
typeof value === 'function';
810

911
export class StateStore<T extends Record<string, unknown>> {
10-
private handlerSet = new Set<Handler<T>>();
12+
protected handlers = new Set<Handler<T>>();
13+
protected modifiers = new Set<Handler<T>>();
1114

1215
constructor(private value: T) {}
1316

17+
/**
18+
* Allows merging two stores only if their keys differ otherwise there's no way to ensure the data type stability.
19+
*/
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21+
public merge<Q extends StateStore<any>>(
22+
stateStore: Q extends StateStore<infer L> ? (Extract<keyof T, keyof L> extends never ? Q : never) : never,
23+
) {
24+
return new MergedStateStore<T, Q extends StateStore<infer L> ? L : never>({
25+
parentStore: this,
26+
mergedStore: stateStore,
27+
});
28+
}
29+
1430
public next = (newValueOrPatch: ValueOrPatch<T>): void => {
1531
// newValue (or patch output) should never be mutated previous value
1632
const newValue = isPatch(newValueOrPatch)
@@ -20,24 +36,25 @@ export class StateStore<T extends Record<string, unknown>> {
2036
// do not notify subscribers if the value hasn't changed
2137
if (newValue === this.value) return;
2238

39+
this.modifiers.forEach((modifier) => modifier(newValue, this.value));
40+
2341
const oldValue = this.value;
2442
this.value = newValue;
2543

26-
this.handlerSet.forEach((handler) => handler(this.value, oldValue));
44+
this.handlers.forEach((handler) => handler(this.value, oldValue));
2745
};
2846

2947
public partialNext = (partial: Partial<T>): void =>
3048
this.next((current) => ({ ...current, ...partial }));
3149

3250
public getLatestValue = (): T => this.value;
33-
34-
public subscribe = (handler: Handler<T>): Unsubscribe => {
51+
public subscribe(handler: Handler<T>): Unsubscribe {
3552
handler(this.value, undefined);
36-
this.handlerSet.add(handler);
53+
this.handlers.add(handler);
3754
return () => {
38-
this.handlerSet.delete(handler);
55+
this.handlers.delete(handler);
3956
};
40-
};
57+
}
4158

4259
public subscribeWithSelector = <
4360
O extends Readonly<Record<string, unknown>> | Readonly<unknown[]>,
@@ -69,4 +86,95 @@ export class StateStore<T extends Record<string, unknown>> {
6986

7087
return this.subscribe(wrappedHandler);
7188
};
89+
90+
public registerModifier(modifier: Modifier<T>): Unregister {
91+
this.modifiers.add(modifier);
92+
93+
return () => {
94+
this.modifiers.delete(modifier);
95+
};
96+
}
7297
}
98+
99+
class MergedStateStore<T extends Record<string, unknown>, L extends Record<string, unknown>> extends StateStore<T & L> {
100+
private readonly parentStore: StateStore<T>;
101+
private readonly mergedStore: StateStore<L>;
102+
103+
constructor({ parentStore, mergedStore }: { mergedStore: StateStore<L>; parentStore: StateStore<T> }) {
104+
super({
105+
...parentStore.getLatestValue(),
106+
...mergedStore.getLatestValue(),
107+
});
108+
109+
this.parentStore = parentStore;
110+
this.mergedStore = mergedStore;
111+
}
112+
113+
public subscribe(handler: Handler<T & L>) {
114+
const unsubscribeFunctions: Unsubscribe[] = [];
115+
116+
if (!this.handlers.size) {
117+
// FIXME: should we subscribe to the changes of the parent store or should we let it die
118+
// and make MergedStateStore the next "parent"?
119+
for (const store of [this.parentStore, this.mergedStore]) {
120+
// TODO: maybe allow "resolver" (how the two states should be merged)
121+
const unsubscribe = store.subscribe((nv) => {
122+
this.next((cv) => ({
123+
...cv,
124+
...nv,
125+
}));
126+
});
127+
128+
unsubscribeFunctions.push(unsubscribe);
129+
}
130+
}
131+
132+
unsubscribeFunctions.push(super.subscribe(handler));
133+
134+
return () => {
135+
unsubscribeFunctions.forEach((f) => f());
136+
};
137+
}
138+
139+
// TODO: getLatestValue should remain the same unless either of the source values changed (cached)
140+
141+
// TODO: make `next` support only T type (maybe?) (only if we go with subscribing only to mergedStore)
142+
}
143+
144+
// const a = new StateStore<{ a: string }>({ a: 'yooo' });
145+
// const b = new StateStore<{ a: number, q: string; }>({ q: 'yooo', a: 2 });
146+
// const c = new StateStore<{ q: string }>({ q: 'yooo' });
147+
148+
// const d = a.merge(b); // error
149+
// const e = a.merge(c); // no error (keys differ)
150+
151+
// TODO: decide
152+
// const d = a.merge(b); // state/type of `a` gets copied to the new merged state and gets garbage collected, `d` becomes new `a`
153+
154+
// l.subscribe(console.info);
155+
156+
// t.next({ a: 'poof' });
157+
// b.next({ q: 'nah' });
158+
159+
const Uninitialized = Symbol('uninitialized');
160+
161+
const a = new StateStore<{ hasNext: boolean | typeof Uninitialized, next: string | null | typeof Uninitialized; }>({
162+
next: Uninitialized,
163+
hasNext: Uninitialized,
164+
});
165+
166+
a.registerModifier((nextValue) => {
167+
if (typeof nextValue.next === 'string') {
168+
nextValue.hasNext = true;
169+
} else if (nextValue.next === Uninitialized) {
170+
nextValue.hasNext = Uninitialized;
171+
} else {
172+
nextValue.hasNext = false;
173+
}
174+
});
175+
176+
a.subscribe((ns) => console.log(ns));
177+
178+
a.partialNext({ next: 'sss' });
179+
a.partialNext({ next: null });
180+
a.partialNext({ next: Uninitialized });

0 commit comments

Comments
 (0)