Skip to content

Commit dc13b34

Browse files
Updates to MergedStateStore
1 parent 6d0df8e commit dc13b34

File tree

1 file changed

+122
-54
lines changed

1 file changed

+122
-54
lines changed

src/store.ts

Lines changed: 122 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,41 @@ 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+
56
export type Unregister = Unsubscribe;
6-
export type Modifier<T> = (nextValue: T, previousValue: T | undefined) => void;
7+
export type Modifier<T> = Handler<T>;
78

89
export const isPatch = <T>(value: ValueOrPatch<T>): value is Patch<T> =>
910
typeof value === 'function';
1011

12+
// eslint-disable-next-line @typescript-eslint/no-empty-function
13+
const noop = () => {};
14+
1115
export class StateStore<T extends Record<string, unknown>> {
1216
protected handlers = new Set<Handler<T>>();
1317
protected modifiers = new Set<Handler<T>>();
1418

15-
constructor(private value: T) {}
19+
constructor(protected value: T) {}
1620

1721
/**
1822
* Allows merging two stores only if their keys differ otherwise there's no way to ensure the data type stability.
1923
*/
2024
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2125
public merge<Q extends StateStore<any>>(
22-
stateStore: Q extends StateStore<infer L> ? (Extract<keyof T, keyof L> extends never ? Q : never) : never,
26+
stateStore: Q extends StateStore<infer L>
27+
? Extract<keyof T, keyof L> extends never
28+
? Q
29+
: never
30+
: never,
2331
) {
2432
return new MergedStateStore<T, Q extends StateStore<infer L> ? L : never>({
25-
parentStore: this,
26-
mergedStore: stateStore,
33+
original: this,
34+
merged: stateStore,
2735
});
2836
}
2937

30-
public next = (newValueOrPatch: ValueOrPatch<T>): void => {
31-
// newValue (or patch output) should never be mutated previous value
38+
public next(newValueOrPatch: ValueOrPatch<T>): void {
39+
// newValue (or patch output) should never be a mutated previous value
3240
const newValue = isPatch(newValueOrPatch)
3341
? newValueOrPatch(this.value)
3442
: newValueOrPatch;
@@ -42,12 +50,15 @@ export class StateStore<T extends Record<string, unknown>> {
4250
this.value = newValue;
4351

4452
this.handlers.forEach((handler) => handler(this.value, oldValue));
45-
};
53+
}
4654

4755
public partialNext = (partial: Partial<T>): void =>
4856
this.next((current) => ({ ...current, ...partial }));
4957

50-
public getLatestValue = (): T => this.value;
58+
public getLatestValue(): T {
59+
return this.value;
60+
}
61+
5162
public subscribe(handler: Handler<T>): Unsubscribe {
5263
handler(this.value, undefined);
5364
this.handlers.add(handler);
@@ -96,74 +107,131 @@ export class StateStore<T extends Record<string, unknown>> {
96107
}
97108
}
98109

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>;
110+
class MergedStateStore<
111+
O extends Record<string, unknown>,
112+
M extends Record<string, unknown>,
113+
> extends StateStore<O & M> {
114+
public readonly original: StateStore<O>;
115+
public readonly merged: StateStore<M>;
116+
private cachedOriginalValue: O;
117+
private cachedMergedValue: M;
118+
119+
constructor({ original, merged }: { original: StateStore<O>; merged: StateStore<M> }) {
120+
const originalValue = original.getLatestValue();
121+
const mergedValue = merged.getLatestValue();
102122

103-
constructor({ parentStore, mergedStore }: { mergedStore: StateStore<L>; parentStore: StateStore<T> }) {
104123
super({
105-
...parentStore.getLatestValue(),
106-
...mergedStore.getLatestValue(),
124+
...originalValue,
125+
...mergedValue,
107126
});
108127

109-
this.parentStore = parentStore;
110-
this.mergedStore = mergedStore;
128+
this.cachedOriginalValue = originalValue;
129+
this.cachedMergedValue = mergedValue;
130+
131+
this.original = original;
132+
this.merged = merged;
111133
}
112134

113-
public subscribe(handler: Handler<T & L>) {
135+
public subscribe(handler: Handler<O & M>) {
114136
const unsubscribeFunctions: Unsubscribe[] = [];
115137

138+
// first subscriber will also register helpers which listen to changes of the
139+
// "original" and "merged" stores, combined outputs will be emitted through super.next
140+
// whenever cached values do not equal (always apart from the initial subscription)
141+
// since the actual handler subscription is registered after helpers, the actual
142+
// handler will run only once
116143
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-
}
144+
const base = (nextValue: O | M) => {
145+
super.next((currentValue) => ({
146+
...currentValue,
147+
...nextValue,
148+
}));
149+
};
150+
151+
unsubscribeFunctions.push(
152+
this.original.subscribe((nextValue) => {
153+
if (nextValue === this.cachedOriginalValue) return;
154+
this.cachedOriginalValue = nextValue;
155+
base(nextValue);
156+
}),
157+
this.merged.subscribe((nextValue) => {
158+
if (nextValue === this.cachedMergedValue) return;
159+
this.cachedMergedValue = nextValue;
160+
base(nextValue);
161+
}),
162+
);
130163
}
131164

132165
unsubscribeFunctions.push(super.subscribe(handler));
133166

134167
return () => {
135-
unsubscribeFunctions.forEach((f) => f());
168+
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
136169
};
137170
}
138171

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)
172+
public getLatestValue() {
173+
// if there are no handlers registered to MergedStore then the local value might be out-of-sync
174+
// pull latest and compare against cached - if they differ, cache latest and produce new combined
175+
if (!this.handlers.size) {
176+
const originalValue = this.original.getLatestValue();
177+
const mergedValue = this.merged.getLatestValue();
178+
179+
if (
180+
originalValue !== this.cachedOriginalValue ||
181+
mergedValue !== this.cachedMergedValue
182+
) {
183+
this.value = {
184+
...originalValue,
185+
...mergedValue,
186+
};
187+
this.cachedMergedValue = mergedValue;
188+
this.cachedOriginalValue = originalValue;
189+
}
190+
}
150191

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`
192+
return super.getLatestValue();
193+
}
153194

154-
// l.subscribe(console.info);
195+
// override original methods and "disable" them
196+
public next = () => {
197+
console.warn(
198+
`${MergedStateStore.name}.next is disabled, call original.next or merged.next instead`,
199+
);
200+
};
201+
public partialNext = () => {
202+
console.warn(
203+
`${MergedStateStore.name}.partialNext is disabled, call original.partialNext or merged.partialNext instead`,
204+
);
205+
};
206+
public registerModifier() {
207+
console.warn(
208+
`${MergedStateStore.name}.registerModifier is disabled, call original.registerModifier or merged.registerModifier instead`,
209+
);
210+
return noop;
211+
}
212+
}
155213

156-
// t.next({ a: 'poof' });
157-
// b.next({ q: 'nah' });
214+
// EXAMPLE:
158215

159216
const Uninitialized = Symbol('uninitialized');
160217

161-
const a = new StateStore<{ hasNext: boolean | typeof Uninitialized, next: string | null | typeof Uninitialized; }>({
218+
const b = new StateStore<{
219+
previous: string | null | symbol;
220+
hasPrevious: boolean | symbol;
221+
}>({
222+
previous: Uninitialized,
223+
hasPrevious: Uninitialized,
224+
});
225+
226+
const a = new StateStore<{
227+
hasNext: boolean | symbol;
228+
next: string | null | symbol;
229+
}>({
162230
next: Uninitialized,
163231
hasNext: Uninitialized,
164-
});
232+
}).merge(b);
165233

166-
a.registerModifier((nextValue) => {
234+
a.original.registerModifier((nextValue) => {
167235
if (typeof nextValue.next === 'string') {
168236
nextValue.hasNext = true;
169237
} else if (nextValue.next === Uninitialized) {
@@ -175,6 +243,6 @@ a.registerModifier((nextValue) => {
175243

176244
a.subscribe((ns) => console.log(ns));
177245

178-
a.partialNext({ next: 'sss' });
179-
a.partialNext({ next: null });
180-
a.partialNext({ next: Uninitialized });
246+
a.original.partialNext({ next: 'next' });
247+
a.original.partialNext({ next: null });
248+
a.original.partialNext({ next: Uninitialized });

0 commit comments

Comments
 (0)