Skip to content

Commit 408509a

Browse files
authored
chore: adding batch function (#22)
1 parent 7286dff commit 408509a

File tree

2 files changed

+191
-17
lines changed

2 files changed

+191
-17
lines changed

src/index.spec.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { derived, DerivedStore, Store, SubscribableStore, writable, get, readable } from './index';
1+
import {
2+
derived,
3+
DerivedStore,
4+
Store,
5+
SubscribableStore,
6+
writable,
7+
get,
8+
readable,
9+
batch,
10+
} from './index';
211
import { from } from 'rxjs';
312
import { Component, Injectable } from '@angular/core';
413
import { TestBed } from '@angular/core/testing';
@@ -221,6 +230,20 @@ describe('stores', () => {
221230
expect(values).toEqual([0, 1]); // unsubscribe worked, value 2 was not received
222231
});
223232

233+
it('should work when changing a store in the listener of the store', () => {
234+
const store = writable(0);
235+
const calls: number[] = [];
236+
const unsubscribe = store.subscribe((n) => {
237+
calls.push(n);
238+
if (n < 10) {
239+
store.set(n + 1);
240+
}
241+
});
242+
expect(calls).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
243+
expect(get(store)).toEqual(10);
244+
unsubscribe();
245+
});
246+
224247
it('should be compatible with rxjs "from" (writable)', () => {
225248
const store = writable(0);
226249
const observable = from(store);
@@ -718,4 +741,114 @@ describe('stores', () => {
718741
expect(cleanUpFn).toHaveBeenCalledWith(3);
719742
});
720743
});
744+
745+
describe('batch', () => {
746+
it('should work with two writables and a derived', () => {
747+
const firstName = writable('Arsène');
748+
const lastName = writable('Lupin');
749+
const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`);
750+
const values: string[] = [];
751+
const unsubscribe = fullName.subscribe((value) => values.push(value));
752+
const expectedRes = {};
753+
const res = batch(() => {
754+
firstName.set('Sherlock');
755+
lastName.set('Holmes');
756+
return expectedRes;
757+
});
758+
expect(values).toEqual(['Arsène Lupin', 'Sherlock Holmes']);
759+
expect(res).toBe(expectedRes);
760+
unsubscribe();
761+
});
762+
763+
it('should not call listeners multiple times', () => {
764+
const store = writable(0);
765+
const calls: number[] = [];
766+
const unsubscribe = store.subscribe((n) => calls.push(n));
767+
expect(calls).toEqual([0]);
768+
batch(() => {
769+
store.set(1);
770+
store.set(2);
771+
expect(calls).toEqual([0]);
772+
});
773+
expect(calls).toEqual([0, 2]);
774+
unsubscribe();
775+
});
776+
777+
it('should allow nested calls', () => {
778+
const store = writable(0);
779+
const calls: number[] = [];
780+
const unsubscribe = store.subscribe((n) => calls.push(n));
781+
expect(calls).toEqual([0]);
782+
batch(() => {
783+
store.set(1);
784+
store.set(2);
785+
batch(() => {
786+
store.set(3);
787+
store.set(4);
788+
});
789+
store.set(5);
790+
batch(() => {
791+
store.set(6);
792+
store.set(7);
793+
});
794+
expect(calls).toEqual([0]);
795+
});
796+
expect(calls).toEqual([0, 7]);
797+
unsubscribe();
798+
});
799+
800+
it('should still call multiple times the listeners registered multiple times', () => {
801+
const store = writable(0);
802+
const calls: number[] = [];
803+
const fn = (n: number) => calls.push(n);
804+
const unsubscribe1 = store.subscribe(fn);
805+
const unsubscribe2 = store.subscribe(fn);
806+
expect(calls).toEqual([0, 0]);
807+
batch(() => {
808+
store.set(1);
809+
store.set(2);
810+
expect(calls).toEqual([0, 0]);
811+
});
812+
expect(calls).toEqual([0, 0, 2, 2]);
813+
unsubscribe1();
814+
unsubscribe2();
815+
});
816+
817+
it('should not call invalidate multiple times', () => {
818+
const store = writable(0);
819+
let invalidateCalls = 0;
820+
const unsubscribe = store.subscribe({
821+
next: () => {},
822+
invalidate: () => {
823+
invalidateCalls++;
824+
},
825+
});
826+
expect(invalidateCalls).toEqual(0);
827+
batch(() => {
828+
expect(invalidateCalls).toEqual(0);
829+
store.set(1);
830+
expect(invalidateCalls).toEqual(1);
831+
store.set(2);
832+
expect(invalidateCalls).toEqual(1);
833+
});
834+
expect(invalidateCalls).toEqual(1);
835+
unsubscribe();
836+
});
837+
838+
it('should not call an unregistered listener', () => {
839+
const store = writable(0);
840+
const calls: number[] = [];
841+
const fn = (n: number) => calls.push(n);
842+
const unsubscribe = store.subscribe(fn);
843+
expect(calls).toEqual([0]);
844+
batch(() => {
845+
store.set(1);
846+
store.set(2);
847+
expect(calls).toEqual([0]);
848+
unsubscribe();
849+
store.set(3);
850+
});
851+
expect(calls).toEqual([0]);
852+
});
853+
});
721854
});

src/index.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,8 @@ const asReadable = <T>(store: Store<T>): Readable<T> => ({
113113
[symbolObservable]: returnThis,
114114
});
115115

116-
const queue: [SubscriberFunction<any>, any][] = [];
117-
118-
function processQueue() {
119-
for (const [subscriberFn, value] of queue) {
120-
subscriberFn(value);
121-
}
122-
queue.length = 0;
123-
}
116+
let willProcessQueue = false;
117+
const queue = new Map<SubscriberObject<any>, any>();
124118

125119
const callUnsubscribe = (unsubscribe: Unsubscriber) =>
126120
typeof unsubscribe === 'function' ? unsubscribe() : unsubscribe.unsubscribe();
@@ -133,6 +127,50 @@ function notEqual(a: any, b: any): boolean {
133127
return true;
134128
}
135129

130+
/**
131+
* Batches multiple changes to stores while calling the provided function,
132+
* preventing derived stores from updating until the function returns,
133+
* to avoid unnecessary recomputations.
134+
* If a store is updated multiple times in the provided function, listeners
135+
* of that store will only be called once when the provided function returns.
136+
* It is possible to have nested calls of batch, in which case only the first
137+
* (outer) call has an effect, inner calls only call the provided function.
138+
*
139+
* @param fn - a function that can update stores. Its return value is
140+
* returned by the batch function.
141+
*
142+
* @example
143+
* Using batch in the following example prevents logging the intermediate "Sherlock Lupin" value.
144+
*
145+
* ```typescript
146+
* const firstName = writable('Arsène');
147+
* const lastName = writable('Lupin');
148+
* const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`);
149+
* fullName.subscribe((name) => console.log(name)); // logs any change to fullName
150+
* batch(() => {
151+
* firstName.set('Sherlock');
152+
* lastName.set('Holmes');
153+
* });
154+
* ```
155+
*/
156+
export const batch = <T>(fn: () => T): T => {
157+
const needsProcessQueue = !willProcessQueue;
158+
if (needsProcessQueue) {
159+
willProcessQueue = true;
160+
}
161+
try {
162+
return fn();
163+
} finally {
164+
if (needsProcessQueue) {
165+
for (const [subscriberObject, value] of queue) {
166+
queue.delete(subscriberObject);
167+
subscriberObject.next(value);
168+
}
169+
willProcessQueue = false;
170+
}
171+
}
172+
};
173+
136174
/**
137175
* A utility function to get the current value from a given store.
138176
* It works by subscribing to a store, capturing the value (synchronously) and unsubscribing just after.
@@ -217,14 +255,15 @@ export abstract class Store<T> implements Readable<T> {
217255
// subscriber not yet initialized
218256
return;
219257
}
220-
const needsProcessQueue = queue.length == 0;
221-
for (const subscriber of this._subscribers) {
222-
subscriber.invalidate();
223-
queue.push([subscriber.next, value]);
224-
}
225-
if (needsProcessQueue) {
226-
processQueue();
227-
}
258+
batch(() => {
259+
for (const subscriber of this._subscribers) {
260+
const needInvalidate = !queue.has(subscriber);
261+
queue.set(subscriber, value);
262+
if (needInvalidate) {
263+
subscriber.invalidate();
264+
}
265+
}
266+
});
228267
}
229268
}
230269

@@ -278,6 +317,8 @@ export abstract class Store<T> implements Readable<T> {
278317

279318
const unsubscribe = () => {
280319
const removed = this._subscribers.delete(subscriberObject);
320+
subscriberObject.next = noop;
321+
subscriberObject.invalidate = noop;
281322
if (removed && this._subscribers.size === 0) {
282323
this._stop();
283324
}

0 commit comments

Comments
 (0)