Skip to content

Commit 6e0885f

Browse files
committed
fix: fixing asymmetric diamond dependency problem
1 parent ae35b18 commit 6e0885f

File tree

2 files changed

+105
-20
lines changed

2 files changed

+105
-20
lines changed

src/index.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,22 @@ describe('stores', () => {
664664
unsubscribe();
665665
});
666666

667+
it('should prevent the asymmetric diamond dependency problem', () => {
668+
const a = writable(0);
669+
const b = derived(a, (a) => `b${a}`);
670+
const c = derived([a, b], ([a, b]) => `${a}${b}`);
671+
672+
const values: string[] = [];
673+
674+
const unsubscribe = c.subscribe((value) => {
675+
values.push(value);
676+
});
677+
expect(values).toEqual(['0b0']);
678+
a.set(1);
679+
expect(values).toEqual(['0b0', '1b1']);
680+
unsubscribe();
681+
});
682+
667683
it('should be able to use destructuring', () => {
668684
const store = writable(0);
669685
const derivedStore = derived(store, (value) => {
@@ -835,6 +851,22 @@ describe('stores', () => {
835851
unsubscribe();
836852
});
837853

854+
it('should not call again listeners when the value finally did not change', () => {
855+
const a = writable(0);
856+
const values: number[] = [];
857+
const unsubscribe = a.subscribe((value) => {
858+
values.push(value);
859+
});
860+
expect(values).toEqual([0]);
861+
batch(() => {
862+
a.set(0);
863+
a.set(1);
864+
a.set(0);
865+
});
866+
expect(values).toEqual([0]);
867+
unsubscribe();
868+
});
869+
838870
it('should not call an unregistered listener', () => {
839871
const store = writable(0);
840872
const calls: number[] = [];

src/index.ts

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface SubscriberObject<T> {
2020
error?: any;
2121
complete?: any;
2222
invalidate: () => void;
23+
revalidate: () => void;
2324
}
2425

2526
/**
@@ -110,8 +111,22 @@ const bind = <T>(object: T | null | undefined, fnName: keyof T) => {
110111

111112
const toSubscriberObject = <T>(subscriber: Subscriber<T>): SubscriberObject<T> =>
112113
typeof subscriber === 'function'
113-
? { next: subscriber.bind(null), invalidate: noop }
114-
: { next: bind(subscriber, 'next'), invalidate: bind(subscriber, 'invalidate') };
114+
? { next: subscriber.bind(null), invalidate: noop, revalidate: noop }
115+
: {
116+
next: bind(subscriber, 'next'),
117+
invalidate: bind(subscriber, 'invalidate'),
118+
revalidate: bind(subscriber, 'revalidate'),
119+
};
120+
121+
const callAllSubscribers = <T, U extends keyof SubscriberObject<T>>(
122+
subscribers: Iterable<SubscriberObject<T>>,
123+
fn: U,
124+
...args: Parameters<SubscriberObject<T>[U]>
125+
) => {
126+
for (const subscriber of [...subscribers]) {
127+
subscriber[fn](...args);
128+
}
129+
};
115130

116131
const returnThis = function <T>(this: T): T {
117132
return this;
@@ -123,8 +138,9 @@ const asReadable = <T>(store: Store<T>): Readable<T> => ({
123138
[symbolObservable]: returnThis,
124139
});
125140

141+
const queueProcess = Symbol();
126142
let willProcessQueue = false;
127-
const queue = new Map<SubscriberObject<any>, any>();
143+
const queue = new Set<{ [queueProcess](): void }>();
128144

129145
const callUnsubscribe = (unsubscribe: Unsubscriber) =>
130146
typeof unsubscribe === 'function' ? unsubscribe() : unsubscribe.unsubscribe();
@@ -172,9 +188,9 @@ export const batch = <T>(fn: () => T): T => {
172188
return fn();
173189
} finally {
174190
if (needsProcessQueue) {
175-
for (const [subscriberObject, value] of queue) {
176-
queue.delete(subscriberObject);
177-
subscriberObject.next(value);
191+
for (const store of queue) {
192+
queue.delete(store);
193+
store[queueProcess]();
178194
}
179195
willProcessQueue = false;
180196
}
@@ -233,12 +249,16 @@ export function get<T>(store: SubscribableStore<T>): T {
233249
export abstract class Store<T> implements Readable<T> {
234250
private _subscribers = new Set<SubscriberObject<T>>();
235251
private _cleanupFn: null | Unsubscriber = null;
252+
private _notifiedValue: T;
253+
private _invalidated = false;
236254

237255
/**
238256
*
239257
* @param _value - Initial value of the store
240258
*/
241-
constructor(private _value: T) {}
259+
constructor(private _value: T) {
260+
this._notifiedValue = this._value;
261+
}
242262

243263
private _start() {
244264
this._cleanupFn = this.onUse() || noop;
@@ -252,28 +272,50 @@ export abstract class Store<T> implements Readable<T> {
252272
}
253273
}
254274

275+
private [queueProcess](): void {
276+
const value = this._value;
277+
this._invalidated = false;
278+
if (notEqual(this._notifiedValue, value)) {
279+
this._notifiedValue = value;
280+
callAllSubscribers(this._subscribers, 'next', value);
281+
} else {
282+
callAllSubscribers(this._subscribers, 'revalidate');
283+
}
284+
}
285+
286+
protected invalidate(): void {
287+
if (!this._invalidated) {
288+
this._invalidated = true;
289+
callAllSubscribers(this._subscribers, 'invalidate');
290+
}
291+
}
292+
293+
protected revalidate(): void {
294+
if (this._invalidated) {
295+
batch(() => {
296+
queue.add(this as any);
297+
});
298+
}
299+
}
300+
255301
/**
256302
* Replaces store's state with the provided value.
257303
* Equivalent of {@link Writable.set}, but internal to the store.
258304
*
259305
* @param value - value to be used as the new state of a store.
260306
*/
261307
protected set(value: T): void {
262-
if (notEqual(this._value, value)) {
308+
const different = notEqual(this._value, value);
309+
if (different) {
263310
this._value = value;
264311
if (!this._cleanupFn) {
265-
// subscriber not yet initialized
312+
this._notifiedValue = this._value;
266313
return;
267314
}
268-
batch(() => {
269-
for (const subscriber of this._subscribers) {
270-
const needInvalidate = !queue.has(subscriber);
271-
queue.set(subscriber, value);
272-
if (needInvalidate) {
273-
subscriber.invalidate();
274-
}
275-
}
276-
});
315+
this.invalidate();
316+
}
317+
if (this._invalidated) {
318+
this.revalidate();
277319
}
278320
}
279321

@@ -323,7 +365,10 @@ export abstract class Store<T> implements Readable<T> {
323365
if (this._subscribers.size == 1) {
324366
this._start();
325367
}
326-
subscriberObject.next(this._value);
368+
subscriberObject.next(this._notifiedValue);
369+
if (this._invalidated) {
370+
subscriberObject.invalidate();
371+
}
327372

328373
const unsubscribe = () => {
329374
const removed = this._subscribers.delete(subscriberObject);
@@ -491,6 +536,7 @@ export abstract class DerivedStore<
491536
protected onUse(): Unsubscriber | void {
492537
let initDone = false;
493538
let pending = 0;
539+
let changed = 0;
494540

495541
const stores = this._stores;
496542
const isArray = Array.isArray(stores);
@@ -510,7 +556,8 @@ export abstract class DerivedStore<
510556
};
511557

512558
const callDerive = () => {
513-
if (initDone && !pending) {
559+
if (initDone && !pending && changed) {
560+
changed = 0;
514561
callCleanup();
515562
cleanupFn = this.derive(isArray ? dependantValues : dependantValues[0]) || noop;
516563
}
@@ -520,11 +567,17 @@ export abstract class DerivedStore<
520567
store.subscribe({
521568
next: (v) => {
522569
dependantValues[idx] = v;
570+
changed |= 1 << idx;
523571
pending &= ~(1 << idx);
524572
callDerive();
525573
},
526574
invalidate: () => {
527575
pending |= 1 << idx;
576+
this.invalidate();
577+
},
578+
revalidate: () => {
579+
pending &= ~(1 << idx);
580+
callDerive();
528581
},
529582
})
530583
);

0 commit comments

Comments
 (0)