@buenos-nachos/time-sync 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/TimeSync.test.ts +294 -73
- package/src/TimeSync.ts +218 -115
- package/src/index.ts +2 -1
- package/src/utilities.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @buenos-nachos/time-sync
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 122f6c1: Updated `SubscriptionContext.timeSync` type to be readonly and non-nullable, and renamed `SubscriptionContext.isLive` to `SubscriptionContext.isSubscribed`.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
### Breaking Changes
|
|
12
|
+
|
|
13
|
+
- 2f527dd: Changed the default value of `allowDuplicateFunctionCalls` from `false` to `true`
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 5f86fac: Added second parameter to `onUpdate` callback. This value is a value of type `SubscriptionContext` and provides information about the current subscription.
|
|
18
|
+
|
|
3
19
|
## 0.1.2
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/package.json
CHANGED
package/src/TimeSync.test.ts
CHANGED
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
type Configuration,
|
|
5
5
|
refreshRates,
|
|
6
6
|
type Snapshot,
|
|
7
|
+
type SubscriptionContext,
|
|
7
8
|
TimeSync,
|
|
8
9
|
} from "./TimeSync";
|
|
10
|
+
import type { Writeable } from "./utilities";
|
|
9
11
|
|
|
10
12
|
const invalidIntervals: readonly number[] = [
|
|
11
13
|
Number.NaN,
|
|
@@ -116,17 +118,26 @@ describe(TimeSync, () => {
|
|
|
116
118
|
const sync = new TimeSync();
|
|
117
119
|
const onUpdate = vi.fn();
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
const dateBefore = sync.getStateSnapshot().date;
|
|
122
|
+
const unsubscribe = sync.subscribe({
|
|
120
123
|
onUpdate,
|
|
121
124
|
targetRefreshIntervalMs: rate,
|
|
122
125
|
});
|
|
123
126
|
expect(onUpdate).not.toHaveBeenCalled();
|
|
124
127
|
|
|
125
|
-
const dateBefore = sync.getStateSnapshot().date;
|
|
126
128
|
await vi.advanceTimersByTimeAsync(rate);
|
|
127
129
|
const dateAfter = sync.getStateSnapshot().date;
|
|
128
130
|
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
129
|
-
|
|
131
|
+
|
|
132
|
+
const expectedCtx: SubscriptionContext = {
|
|
133
|
+
unsubscribe,
|
|
134
|
+
isSubscribed: true,
|
|
135
|
+
timeSync: sync,
|
|
136
|
+
intervalLastFulfilledAt: dateAfter,
|
|
137
|
+
registeredAt: dateBefore,
|
|
138
|
+
targetRefreshIntervalMs: rate,
|
|
139
|
+
};
|
|
140
|
+
expect(onUpdate).toHaveBeenCalledWith(dateAfter, expectedCtx);
|
|
130
141
|
|
|
131
142
|
const diff = dateAfter.getTime() - dateBefore.getTime();
|
|
132
143
|
expect(diff).toBe(rate);
|
|
@@ -171,7 +182,7 @@ describe(TimeSync, () => {
|
|
|
171
182
|
it("Always dispatches updates in the order that callbacks were first registered", async ({
|
|
172
183
|
expect,
|
|
173
184
|
}) => {
|
|
174
|
-
const sync = new TimeSync();
|
|
185
|
+
const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
|
|
175
186
|
const callOrder: number[] = [];
|
|
176
187
|
|
|
177
188
|
const onUpdate1 = vi.fn(() => {
|
|
@@ -260,7 +271,7 @@ describe(TimeSync, () => {
|
|
|
260
271
|
expect(secondOnUpdate).toHaveBeenCalledTimes(1);
|
|
261
272
|
});
|
|
262
273
|
|
|
263
|
-
it("Calls onUpdate callback
|
|
274
|
+
it("Calls onUpdate callback once for each subscription (even if the same callback was registered multiple times)", async ({
|
|
264
275
|
expect,
|
|
265
276
|
}) => {
|
|
266
277
|
const sync = new TimeSync();
|
|
@@ -274,59 +285,7 @@ describe(TimeSync, () => {
|
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
277
|
-
expect(sharedOnUpdate).toHaveBeenCalledTimes(
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it("Calls onUpdate callback one time total if callback is registered multiple times for different time intervals", async ({
|
|
281
|
-
expect,
|
|
282
|
-
}) => {
|
|
283
|
-
const sync = new TimeSync();
|
|
284
|
-
const sharedOnUpdate = vi.fn();
|
|
285
|
-
|
|
286
|
-
void sync.subscribe({
|
|
287
|
-
onUpdate: sharedOnUpdate,
|
|
288
|
-
targetRefreshIntervalMs: refreshRates.oneHour,
|
|
289
|
-
});
|
|
290
|
-
void sync.subscribe({
|
|
291
|
-
onUpdate: sharedOnUpdate,
|
|
292
|
-
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
293
|
-
});
|
|
294
|
-
void sync.subscribe({
|
|
295
|
-
onUpdate: sharedOnUpdate,
|
|
296
|
-
targetRefreshIntervalMs: refreshRates.oneSecond,
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// Testing like this to ensure that for really, really long spans of
|
|
300
|
-
// time, the no duplicated calls logic still holds up
|
|
301
|
-
await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
|
|
302
|
-
const secondsInOneHour = 3600;
|
|
303
|
-
expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("Calls onUpdate callback one time total if callback is registered multiple times with a mix of redundant/different intervals", async ({
|
|
307
|
-
expect,
|
|
308
|
-
}) => {
|
|
309
|
-
const sync = new TimeSync();
|
|
310
|
-
const sharedOnUpdate = vi.fn();
|
|
311
|
-
|
|
312
|
-
for (let i = 0; i < 10; i++) {
|
|
313
|
-
void sync.subscribe({
|
|
314
|
-
onUpdate: sharedOnUpdate,
|
|
315
|
-
targetRefreshIntervalMs: refreshRates.oneHour,
|
|
316
|
-
});
|
|
317
|
-
void sync.subscribe({
|
|
318
|
-
onUpdate: sharedOnUpdate,
|
|
319
|
-
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
320
|
-
});
|
|
321
|
-
void sync.subscribe({
|
|
322
|
-
onUpdate: sharedOnUpdate,
|
|
323
|
-
targetRefreshIntervalMs: refreshRates.oneSecond,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
|
|
328
|
-
const secondsInOneHour = 3600;
|
|
329
|
-
expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
|
|
288
|
+
expect(sharedOnUpdate).toHaveBeenCalledTimes(3);
|
|
330
289
|
});
|
|
331
290
|
|
|
332
291
|
it("Lets an external system unsubscribe", async ({ expect }) => {
|
|
@@ -405,7 +364,7 @@ describe(TimeSync, () => {
|
|
|
405
364
|
const snap2 = sync.getStateSnapshot();
|
|
406
365
|
expect(snap2.subscriberCount).toBe(30);
|
|
407
366
|
await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
|
|
408
|
-
expect(sharedOnUpdate).toHaveBeenCalledTimes(
|
|
367
|
+
expect(sharedOnUpdate).toHaveBeenCalledTimes(30);
|
|
409
368
|
});
|
|
410
369
|
|
|
411
370
|
it("Speeds up interval when new subscriber is added that is faster than all other subscribers", async ({
|
|
@@ -604,6 +563,132 @@ describe(TimeSync, () => {
|
|
|
604
563
|
const dateAfter = sync.getStateSnapshot().date;
|
|
605
564
|
expect(dateAfter).not.toEqual(dateBefore);
|
|
606
565
|
});
|
|
566
|
+
|
|
567
|
+
it("Mutates the isLive context value to be false on unsubscribe", async ({
|
|
568
|
+
expect,
|
|
569
|
+
}) => {
|
|
570
|
+
const sync = new TimeSync();
|
|
571
|
+
|
|
572
|
+
let ejectedContext: SubscriptionContext | undefined;
|
|
573
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
574
|
+
ejectedContext = ctx;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const unsub = sync.subscribe({
|
|
578
|
+
onUpdate,
|
|
579
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
583
|
+
expect(ejectedContext?.isSubscribed).toBe(true);
|
|
584
|
+
|
|
585
|
+
unsub();
|
|
586
|
+
expect(ejectedContext?.isSubscribed).toBe(false);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe("Subscriptions: context values", () => {
|
|
591
|
+
it("Defaults to exposing the TimeSync instance the subscription was registered with", async ({
|
|
592
|
+
expect,
|
|
593
|
+
}) => {
|
|
594
|
+
const sync = new TimeSync();
|
|
595
|
+
|
|
596
|
+
let ejectedSync: TimeSync | null = null;
|
|
597
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
598
|
+
ejectedSync = ctx.timeSync;
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
void sync.subscribe({
|
|
602
|
+
onUpdate,
|
|
603
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
607
|
+
expect(ejectedSync).toBe(sync);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("Exposes refresh interval used on initialization", async ({
|
|
611
|
+
expect,
|
|
612
|
+
}) => {
|
|
613
|
+
const sync = new TimeSync();
|
|
614
|
+
const interval = refreshRates.oneMinute;
|
|
615
|
+
|
|
616
|
+
let ejectedInterval: number | undefined;
|
|
617
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
618
|
+
ejectedInterval = ctx.targetRefreshIntervalMs;
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
void sync.subscribe({
|
|
622
|
+
onUpdate,
|
|
623
|
+
targetRefreshIntervalMs: interval,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
await vi.advanceTimersByTimeAsync(interval);
|
|
627
|
+
expect(ejectedInterval).toBe(interval);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("Exposes exact same unsubscribe callback as the one returned from the subscribe call", async ({
|
|
631
|
+
expect,
|
|
632
|
+
}) => {
|
|
633
|
+
const sync = new TimeSync();
|
|
634
|
+
|
|
635
|
+
let ejectedUnsub: (() => void) | undefined;
|
|
636
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
637
|
+
ejectedUnsub = ctx.unsubscribe;
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const unsub = sync.subscribe({
|
|
641
|
+
onUpdate,
|
|
642
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
646
|
+
expect(ejectedUnsub).toBe(unsub);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("Exposes when the subscription was first set up", async ({ expect }) => {
|
|
650
|
+
const sync = new TimeSync();
|
|
651
|
+
const start = sync.getStateSnapshot().date;
|
|
652
|
+
|
|
653
|
+
let ejectedDate: Date | undefined;
|
|
654
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
655
|
+
ejectedDate = ctx.registeredAt;
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
void sync.subscribe({
|
|
659
|
+
onUpdate,
|
|
660
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
664
|
+
expect(ejectedDate).toEqual(start);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("Indicates when the last requested interval was fulfilled", async ({
|
|
668
|
+
expect,
|
|
669
|
+
}) => {
|
|
670
|
+
const sync = new TimeSync();
|
|
671
|
+
|
|
672
|
+
const fulfilledValues: (ReadonlyDate | null)[] = [];
|
|
673
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
674
|
+
fulfilledValues.push(ctx.intervalLastFulfilledAt);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
void sync.subscribe({
|
|
678
|
+
onUpdate,
|
|
679
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
680
|
+
});
|
|
681
|
+
void sync.subscribe({
|
|
682
|
+
onUpdate: vi.fn(),
|
|
683
|
+
targetRefreshIntervalMs: refreshRates.thirtySeconds,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
687
|
+
const snapAfter = sync.getStateSnapshot().date;
|
|
688
|
+
|
|
689
|
+
expect(onUpdate).toHaveBeenCalledTimes(2);
|
|
690
|
+
expect(fulfilledValues).toEqual([null, snapAfter]);
|
|
691
|
+
});
|
|
607
692
|
});
|
|
608
693
|
|
|
609
694
|
describe("Subscriptions: custom `minimumRefreshIntervalMs` value", () => {
|
|
@@ -646,11 +731,14 @@ describe(TimeSync, () => {
|
|
|
646
731
|
});
|
|
647
732
|
});
|
|
648
733
|
|
|
649
|
-
describe("Subscriptions:
|
|
650
|
-
it("
|
|
651
|
-
|
|
734
|
+
describe("Subscriptions: turning off duplicate function calls", () => {
|
|
735
|
+
it("Calls onUpdate callback once for each subscription (even if the same callback was registered multiple times)", async ({
|
|
736
|
+
expect,
|
|
737
|
+
}) => {
|
|
738
|
+
const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
|
|
652
739
|
const sharedOnUpdate = vi.fn();
|
|
653
|
-
|
|
740
|
+
|
|
741
|
+
for (let i = 1; i <= 3; i++) {
|
|
654
742
|
void sync.subscribe({
|
|
655
743
|
onUpdate: sharedOnUpdate,
|
|
656
744
|
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
@@ -661,21 +749,104 @@ describe(TimeSync, () => {
|
|
|
661
749
|
expect(sharedOnUpdate).toHaveBeenCalledTimes(1);
|
|
662
750
|
});
|
|
663
751
|
|
|
664
|
-
it("
|
|
665
|
-
|
|
666
|
-
|
|
752
|
+
it("Calls onUpdate callback one time total if callback is registered multiple times for different time intervals", async ({
|
|
753
|
+
expect,
|
|
754
|
+
}) => {
|
|
755
|
+
const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
|
|
756
|
+
const sharedOnUpdate = vi.fn();
|
|
757
|
+
|
|
758
|
+
void sync.subscribe({
|
|
759
|
+
onUpdate: sharedOnUpdate,
|
|
760
|
+
targetRefreshIntervalMs: refreshRates.oneHour,
|
|
761
|
+
});
|
|
762
|
+
void sync.subscribe({
|
|
763
|
+
onUpdate: sharedOnUpdate,
|
|
764
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
765
|
+
});
|
|
766
|
+
void sync.subscribe({
|
|
767
|
+
onUpdate: sharedOnUpdate,
|
|
768
|
+
targetRefreshIntervalMs: refreshRates.oneSecond,
|
|
667
769
|
});
|
|
668
770
|
|
|
771
|
+
// Testing like this to ensure that for really, really long spans of
|
|
772
|
+
// time, the no duplicated calls logic still holds up
|
|
773
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
|
|
774
|
+
const secondsInOneHour = 3600;
|
|
775
|
+
expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("Calls onUpdate callback one time total if callback is registered multiple times with a mix of redundant/different intervals", async ({
|
|
779
|
+
expect,
|
|
780
|
+
}) => {
|
|
781
|
+
const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
|
|
669
782
|
const sharedOnUpdate = vi.fn();
|
|
670
|
-
|
|
783
|
+
|
|
784
|
+
for (let i = 0; i < 10; i++) {
|
|
785
|
+
void sync.subscribe({
|
|
786
|
+
onUpdate: sharedOnUpdate,
|
|
787
|
+
targetRefreshIntervalMs: refreshRates.oneHour,
|
|
788
|
+
});
|
|
671
789
|
void sync.subscribe({
|
|
672
790
|
onUpdate: sharedOnUpdate,
|
|
673
791
|
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
674
792
|
});
|
|
793
|
+
void sync.subscribe({
|
|
794
|
+
onUpdate: sharedOnUpdate,
|
|
795
|
+
targetRefreshIntervalMs: refreshRates.oneSecond,
|
|
796
|
+
});
|
|
675
797
|
}
|
|
676
798
|
|
|
677
|
-
await vi.advanceTimersByTimeAsync(refreshRates.
|
|
678
|
-
|
|
799
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
|
|
800
|
+
const secondsInOneHour = 3600;
|
|
801
|
+
expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("Always exposes the context for the subscription that first set up onUpdate", async ({
|
|
805
|
+
expect,
|
|
806
|
+
}) => {
|
|
807
|
+
const sync = new TimeSync();
|
|
808
|
+
const snapBefore = sync.getStateSnapshot().date;
|
|
809
|
+
|
|
810
|
+
let ejectedContext: SubscriptionContext | undefined;
|
|
811
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
812
|
+
ejectedContext = ctx;
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const unsub = sync.subscribe({
|
|
816
|
+
onUpdate,
|
|
817
|
+
targetRefreshIntervalMs: refreshRates.oneHour,
|
|
818
|
+
});
|
|
819
|
+
void sync.subscribe({
|
|
820
|
+
onUpdate,
|
|
821
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
822
|
+
});
|
|
823
|
+
void sync.subscribe({
|
|
824
|
+
onUpdate,
|
|
825
|
+
targetRefreshIntervalMs: refreshRates.oneSecond,
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
|
|
829
|
+
expect(ejectedContext).toEqual<SubscriptionContext>({
|
|
830
|
+
isSubscribed: true,
|
|
831
|
+
intervalLastFulfilledAt: null,
|
|
832
|
+
registeredAt: snapBefore,
|
|
833
|
+
targetRefreshIntervalMs: refreshRates.oneHour,
|
|
834
|
+
timeSync: sync,
|
|
835
|
+
unsubscribe: unsub,
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const remainingSecondsToOneHour = refreshRates.oneHour - 1000;
|
|
839
|
+
await vi.advanceTimersByTimeAsync(remainingSecondsToOneHour);
|
|
840
|
+
|
|
841
|
+
const snapAfter = sync.getStateSnapshot().date;
|
|
842
|
+
expect(ejectedContext).toEqual<SubscriptionContext>({
|
|
843
|
+
isSubscribed: true,
|
|
844
|
+
intervalLastFulfilledAt: snapAfter,
|
|
845
|
+
registeredAt: snapBefore,
|
|
846
|
+
targetRefreshIntervalMs: refreshRates.oneHour,
|
|
847
|
+
timeSync: sync,
|
|
848
|
+
unsubscribe: unsub,
|
|
849
|
+
});
|
|
679
850
|
});
|
|
680
851
|
});
|
|
681
852
|
|
|
@@ -685,7 +856,11 @@ describe(TimeSync, () => {
|
|
|
685
856
|
}) => {
|
|
686
857
|
const initialDate = setInitialTime("July 4, 1999");
|
|
687
858
|
const minimumRefreshIntervalMs = 5_000_000;
|
|
688
|
-
const sync = new TimeSync({
|
|
859
|
+
const sync = new TimeSync({
|
|
860
|
+
initialDate,
|
|
861
|
+
minimumRefreshIntervalMs,
|
|
862
|
+
allowDuplicateOnUpdateCalls: false,
|
|
863
|
+
});
|
|
689
864
|
|
|
690
865
|
const snap = sync.getStateSnapshot();
|
|
691
866
|
expect(snap).toEqual<Snapshot>({
|
|
@@ -748,9 +923,8 @@ describe(TimeSync, () => {
|
|
|
748
923
|
await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
|
|
749
924
|
|
|
750
925
|
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
751
|
-
expect(onUpdate).toHaveBeenCalledWith(expect.any(Date));
|
|
752
|
-
|
|
753
926
|
const newSnap = sync.getStateSnapshot();
|
|
927
|
+
expect(onUpdate).toHaveBeenCalledWith(newSnap.date, expect.any(Object));
|
|
754
928
|
expect(newSnap).not.toEqual(initialSnap);
|
|
755
929
|
});
|
|
756
930
|
|
|
@@ -818,7 +992,6 @@ describe(TimeSync, () => {
|
|
|
818
992
|
});
|
|
819
993
|
|
|
820
994
|
it("Prevents mutating properties at runtime", ({ expect }) => {
|
|
821
|
-
type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
|
|
822
995
|
const sync = new TimeSync();
|
|
823
996
|
|
|
824
997
|
// We have readonly modifiers on the types, but we need to make sure
|
|
@@ -913,6 +1086,28 @@ describe(TimeSync, () => {
|
|
|
913
1086
|
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
914
1087
|
expect(sharedOnUpdate).not.toHaveBeenCalled();
|
|
915
1088
|
});
|
|
1089
|
+
|
|
1090
|
+
it("Mutates the isLive context value to be false", async ({ expect }) => {
|
|
1091
|
+
const sync = new TimeSync();
|
|
1092
|
+
|
|
1093
|
+
let ejectedContext: SubscriptionContext | undefined;
|
|
1094
|
+
const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
|
|
1095
|
+
if (ejectedContext === undefined) {
|
|
1096
|
+
ejectedContext = ctx;
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
void sync.subscribe({
|
|
1101
|
+
onUpdate,
|
|
1102
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
1106
|
+
expect(ejectedContext?.isSubscribed).toBe(true);
|
|
1107
|
+
|
|
1108
|
+
sync.clearAll();
|
|
1109
|
+
expect(ejectedContext?.isSubscribed).toBe(false);
|
|
1110
|
+
});
|
|
916
1111
|
});
|
|
917
1112
|
|
|
918
1113
|
/**
|
|
@@ -1067,5 +1262,31 @@ describe(TimeSync, () => {
|
|
|
1067
1262
|
const snap3 = sync.getStateSnapshot().subscriberCount;
|
|
1068
1263
|
expect(snap3).toBe(0);
|
|
1069
1264
|
});
|
|
1265
|
+
|
|
1266
|
+
it("Lets consumers detect whether an update corresponds to the subscription they explicitly set up", async ({
|
|
1267
|
+
expect,
|
|
1268
|
+
}) => {
|
|
1269
|
+
const sync = new TimeSync();
|
|
1270
|
+
const innerOnUpdate = vi.fn();
|
|
1271
|
+
|
|
1272
|
+
void sync.subscribe({
|
|
1273
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
1274
|
+
onUpdate: (date, ctx) => {
|
|
1275
|
+
const intervalMatches =
|
|
1276
|
+
date.getTime() === ctx.intervalLastFulfilledAt?.getTime();
|
|
1277
|
+
if (intervalMatches) {
|
|
1278
|
+
innerOnUpdate();
|
|
1279
|
+
}
|
|
1280
|
+
},
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
void sync.subscribe({
|
|
1284
|
+
targetRefreshIntervalMs: refreshRates.thirtySeconds,
|
|
1285
|
+
onUpdate: vi.fn(),
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
1289
|
+
expect(innerOnUpdate).toHaveBeenCalledTimes(1);
|
|
1290
|
+
});
|
|
1070
1291
|
});
|
|
1071
1292
|
});
|
package/src/TimeSync.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ReadonlyDate } from "./ReadonlyDate";
|
|
2
|
+
import type { Writeable } from "./utilities";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A collection of commonly-needed intervals (all defined in milliseconds).
|
|
@@ -26,14 +27,14 @@ export const refreshRates = Object.freeze({
|
|
|
26
27
|
/**
|
|
27
28
|
* The set of readonly options that the TimeSync has been configured with.
|
|
28
29
|
*/
|
|
29
|
-
export
|
|
30
|
+
export interface Configuration {
|
|
30
31
|
/**
|
|
31
32
|
* Indicates whether the TimeSync instance should be frozen for Snapshot
|
|
32
33
|
* tests.
|
|
33
34
|
*
|
|
34
35
|
* Defaults to false.
|
|
35
36
|
*/
|
|
36
|
-
freezeUpdates: boolean;
|
|
37
|
+
readonly freezeUpdates: boolean;
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* The minimum refresh interval (in milliseconds) to use when dispatching
|
|
@@ -48,49 +49,44 @@ export type Configuration = Readonly<{
|
|
|
48
49
|
*
|
|
49
50
|
* Defaults to 200ms.
|
|
50
51
|
*/
|
|
51
|
-
minimumRefreshIntervalMs: number;
|
|
52
|
+
readonly minimumRefreshIntervalMs: number;
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Indicates whether the same `onUpdate` callback (by reference) should be
|
|
55
56
|
* called multiple time if registered by multiple systems.
|
|
56
57
|
*
|
|
57
|
-
* Defaults to false
|
|
58
|
+
* Defaults to true. If this value is flipped to false, each onUpdate
|
|
59
|
+
* callback will receive the subscription context for the FIRST subscriber
|
|
60
|
+
* that registered the onUpdate callback.
|
|
58
61
|
*/
|
|
59
|
-
allowDuplicateOnUpdateCalls: boolean;
|
|
60
|
-
}
|
|
62
|
+
readonly allowDuplicateOnUpdateCalls: boolean;
|
|
63
|
+
}
|
|
61
64
|
|
|
62
65
|
/**
|
|
63
66
|
* The set of options that can be used to instantiate a TimeSync.
|
|
64
67
|
*/
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
freezeUpdates: boolean;
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The Date object to use when initializing TimeSync to make the
|
|
79
|
-
* constructor more pure and deterministic.
|
|
80
|
-
*/
|
|
81
|
-
initialDate: Date;
|
|
82
|
-
}
|
|
83
|
-
>;
|
|
68
|
+
export interface InitOptions extends Configuration {
|
|
69
|
+
/**
|
|
70
|
+
* Indicates whether the TimeSync instance should be frozen for snapshot
|
|
71
|
+
* tests. Highly encouraged that you use this together with
|
|
72
|
+
* `initialDate`.
|
|
73
|
+
*
|
|
74
|
+
* Defaults to false.
|
|
75
|
+
*/
|
|
76
|
+
// Duplicated property to override the LSP comment
|
|
77
|
+
readonly freezeUpdates: boolean;
|
|
84
78
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The Date object to use when initializing TimeSync to make the
|
|
81
|
+
* constructor more pure and deterministic.
|
|
82
|
+
*/
|
|
83
|
+
readonly initialDate: Date;
|
|
84
|
+
}
|
|
89
85
|
|
|
90
86
|
/**
|
|
91
87
|
* An object used to initialize a new subscription for TimeSync.
|
|
92
88
|
*/
|
|
93
|
-
export
|
|
89
|
+
export interface SubscriptionInitOptions {
|
|
94
90
|
/**
|
|
95
91
|
* The maximum update interval that a subscriber needs. A value of
|
|
96
92
|
* Number.POSITIVE_INFINITY indicates that the subscriber does not strictly
|
|
@@ -110,36 +106,93 @@ export type SubscriptionOptions = Readonly<{
|
|
|
110
106
|
* after A, updates will pause completely until a new subscriber gets
|
|
111
107
|
* added, and it has a non-infinite interval.
|
|
112
108
|
*/
|
|
113
|
-
targetRefreshIntervalMs: number;
|
|
109
|
+
readonly targetRefreshIntervalMs: number;
|
|
114
110
|
|
|
115
111
|
/**
|
|
116
112
|
* The callback to call when a new state update needs to be flushed amongst
|
|
117
113
|
* all subscribers.
|
|
118
114
|
*/
|
|
119
|
-
onUpdate: OnTimeSyncUpdate;
|
|
120
|
-
}
|
|
115
|
+
readonly onUpdate: OnTimeSyncUpdate;
|
|
116
|
+
}
|
|
121
117
|
|
|
122
118
|
/**
|
|
123
119
|
* A complete snapshot of the user-relevant internal state from TimeSync. This
|
|
124
120
|
* value is treated as immutable at both runtime and compile time.
|
|
125
121
|
*/
|
|
126
|
-
export
|
|
122
|
+
export interface Snapshot {
|
|
127
123
|
/**
|
|
128
124
|
* The date that was last dispatched to all subscribers.
|
|
129
125
|
*/
|
|
130
|
-
date: ReadonlyDate;
|
|
126
|
+
readonly date: ReadonlyDate;
|
|
131
127
|
|
|
132
128
|
/**
|
|
133
129
|
* The number of subscribers registered with TimeSync.
|
|
134
130
|
*/
|
|
135
|
-
subscriberCount: number;
|
|
131
|
+
readonly subscriberCount: number;
|
|
136
132
|
|
|
137
133
|
/**
|
|
138
134
|
* The configuration options used when instantiating the TimeSync instance.
|
|
139
135
|
* The value is guaranteed to be stable for the entire lifetime of TimeSync.
|
|
140
136
|
*/
|
|
141
|
-
config: Configuration;
|
|
142
|
-
}
|
|
137
|
+
readonly config: Configuration;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* An object with information about a specific subscription registered with
|
|
142
|
+
* TimeSync.
|
|
143
|
+
*
|
|
144
|
+
* For performance reasons, this object has ZERO readonly guarantees enforced at
|
|
145
|
+
* runtime. A few properties are flagged as readonly at the type level, but
|
|
146
|
+
* misuse of this value has a risk of breaking a TimeSync instance's internal
|
|
147
|
+
* state. Proceed with caution.
|
|
148
|
+
*/
|
|
149
|
+
export interface SubscriptionContext {
|
|
150
|
+
/**
|
|
151
|
+
* The interval that the subscription was registered with.
|
|
152
|
+
*/
|
|
153
|
+
readonly targetRefreshIntervalMs: number;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The unsubscribe callback associated with a subscription. This is the same
|
|
157
|
+
* callback returned by `TimeSync.subscribe`.
|
|
158
|
+
*/
|
|
159
|
+
readonly unsubscribe: () => void;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A timestamp of when the subscription was first set up.
|
|
163
|
+
*/
|
|
164
|
+
readonly registeredAt: ReadonlyDate;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A reference to the TimeSync instance that the subscription was registered
|
|
168
|
+
* with.
|
|
169
|
+
*/
|
|
170
|
+
readonly timeSync: TimeSync;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Indicates whether the subscription is still live. Will be mutated to be
|
|
174
|
+
* false when a subscription is
|
|
175
|
+
*/
|
|
176
|
+
isSubscribed: boolean;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Indicates when the last time the subscription had its explicit interval
|
|
180
|
+
* "satisfied".
|
|
181
|
+
*
|
|
182
|
+
* For example, if a subscription is registered for every five minutes, but
|
|
183
|
+
* the active interval is set to fire every second, you may need to know
|
|
184
|
+
* which update actually happened five minutes later.
|
|
185
|
+
*/
|
|
186
|
+
intervalLastFulfilledAt: ReadonlyDate | null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The callback to call when a new state update is ready to be dispatched.
|
|
191
|
+
*/
|
|
192
|
+
export type OnTimeSyncUpdate = (
|
|
193
|
+
newDate: ReadonlyDate,
|
|
194
|
+
context: SubscriptionContext,
|
|
195
|
+
) => void;
|
|
143
196
|
|
|
144
197
|
interface TimeSyncApi {
|
|
145
198
|
/**
|
|
@@ -155,7 +208,7 @@ interface TimeSyncApi {
|
|
|
155
208
|
* @returns An unsubscribe callback. Calling the callback more than once
|
|
156
209
|
* results in a no-op.
|
|
157
210
|
*/
|
|
158
|
-
subscribe: (options:
|
|
211
|
+
subscribe: (options: SubscriptionInitOptions) => () => void;
|
|
159
212
|
|
|
160
213
|
/**
|
|
161
214
|
* Allows an external system to pull an immutable snapshot of some of the
|
|
@@ -179,11 +232,6 @@ interface TimeSyncApi {
|
|
|
179
232
|
clearAll: () => void;
|
|
180
233
|
}
|
|
181
234
|
|
|
182
|
-
type SubscriptionEntry = Readonly<{
|
|
183
|
-
targetInterval: number;
|
|
184
|
-
unsubscribe: () => void;
|
|
185
|
-
}>;
|
|
186
|
-
|
|
187
235
|
/* biome-ignore lint:suspicious/noEmptyBlockStatements -- Rare case where we do
|
|
188
236
|
actually want a completely empty function body. */
|
|
189
237
|
function noOp(..._: readonly unknown[]): void {}
|
|
@@ -238,8 +286,27 @@ export class TimeSync implements TimeSyncApi {
|
|
|
238
286
|
*
|
|
239
287
|
* Each map value should stay sorted by refresh interval, in ascending
|
|
240
288
|
* order.
|
|
289
|
+
*
|
|
290
|
+
* ---
|
|
291
|
+
*
|
|
292
|
+
* This is a rare case where we actually REALLY need the readonly modifier
|
|
293
|
+
* to avoid infinite loops. JavaScript's iterator protocol is really great
|
|
294
|
+
* for making loops simple and type-safe, but because subscriptions have the
|
|
295
|
+
* ability to add more subscriptions, we need to make an immutable version
|
|
296
|
+
* of each array at some point to make sure that we're not iterating through
|
|
297
|
+
* values forever
|
|
298
|
+
*
|
|
299
|
+
* We can choose to do that at one of two points:
|
|
300
|
+
* 1. When adding a new subscription
|
|
301
|
+
* 2. When dispatching a new round of updates
|
|
302
|
+
*
|
|
303
|
+
* Because this library assumes that dispatches will be much more common
|
|
304
|
+
* than new subscriptions (a single subscription that subscribes for one
|
|
305
|
+
* second will receive 360 updates in five minutes), operations should be
|
|
306
|
+
* done to optimize that use case. So we should move the immutability costs
|
|
307
|
+
* to the subscribe and unsubscribe operations.
|
|
241
308
|
*/
|
|
242
|
-
#subscriptions: Map<OnTimeSyncUpdate,
|
|
309
|
+
#subscriptions: Map<OnTimeSyncUpdate, readonly SubscriptionContext[]>;
|
|
243
310
|
|
|
244
311
|
/**
|
|
245
312
|
* The latest public snapshot of TimeSync's internal state. The snapshot
|
|
@@ -271,7 +338,7 @@ export class TimeSync implements TimeSyncApi {
|
|
|
271
338
|
const {
|
|
272
339
|
initialDate,
|
|
273
340
|
freezeUpdates = false,
|
|
274
|
-
allowDuplicateOnUpdateCalls =
|
|
341
|
+
allowDuplicateOnUpdateCalls = true,
|
|
275
342
|
minimumRefreshIntervalMs = defaultMinimumRefreshIntervalMs,
|
|
276
343
|
} = options ?? {};
|
|
277
344
|
|
|
@@ -343,50 +410,57 @@ export class TimeSync implements TimeSyncApi {
|
|
|
343
410
|
return;
|
|
344
411
|
}
|
|
345
412
|
|
|
413
|
+
const dateTime = date.getTime();
|
|
414
|
+
|
|
346
415
|
/**
|
|
347
|
-
* Two things
|
|
348
|
-
* 1.
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
416
|
+
* Two things:
|
|
417
|
+
* 1. Even though the context arrays are defined as readonly (which
|
|
418
|
+
* removes on the worst edge cases during dispatching), the
|
|
419
|
+
* subscriptions map itself is still mutable, so there are a few edge
|
|
420
|
+
* cases we need to deal with. While the risk of infinite loops should
|
|
421
|
+
* be much lower, there's still the risk that an onUpdate callback could
|
|
422
|
+
* add a subscriber for an interval that wasn't registered before, which
|
|
423
|
+
* the iterator protocol will pick up. Need to make a local,
|
|
424
|
+
* fixed-length copy of the map entries before starting iteration. Any
|
|
425
|
+
* subscriptions added during update will just have to wait until the
|
|
426
|
+
* next round of updates.
|
|
354
427
|
*
|
|
355
428
|
* 2. The trade off of the serialization is that we do lose the ability
|
|
356
|
-
* to auto-break the
|
|
429
|
+
* to auto-break the loop if one of the subscribers ends up resetting
|
|
357
430
|
* all state, because we'll still have local copies of entries. We need
|
|
358
431
|
* to check on each iteration to see if we should continue.
|
|
359
432
|
*/
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
433
|
+
const subsBeforeUpdate = this.#subscriptions;
|
|
434
|
+
const entries = Array.from(subsBeforeUpdate);
|
|
435
|
+
outer: for (const [onUpdate, subs] of entries) {
|
|
436
|
+
// Even if duplicate onUpdate calls are disabled, we still need to
|
|
437
|
+
// iterate through everything and update any internal data. If the
|
|
438
|
+
// first context in a sub array gets removed by unsubscribing, we
|
|
439
|
+
// want what was the the second element to still be up to date
|
|
440
|
+
let shouldCallOnUpdate = true;
|
|
441
|
+
for (const context of subs) {
|
|
442
|
+
// We're not doing anything more sophisticated here because
|
|
443
|
+
// we're assuming that any systems that can clear out the
|
|
444
|
+
// subscriptions will handle cleaning up each context, too
|
|
445
|
+
const wasCleared = subsBeforeUpdate.size === 0;
|
|
446
|
+
if (wasCleared) {
|
|
447
|
+
break outer;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const comparisonDate =
|
|
451
|
+
context.intervalLastFulfilledAt ?? context.registeredAt;
|
|
452
|
+
const isIntervalMatch =
|
|
453
|
+
dateTime - comparisonDate.getTime() >=
|
|
454
|
+
context.targetRefreshIntervalMs;
|
|
455
|
+
if (isIntervalMatch) {
|
|
456
|
+
context.intervalLastFulfilledAt = date;
|
|
378
457
|
}
|
|
379
|
-
}
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
458
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
break;
|
|
459
|
+
if (shouldCallOnUpdate) {
|
|
460
|
+
onUpdate(date, context);
|
|
461
|
+
shouldCallOnUpdate = config.allowDuplicateOnUpdateCalls;
|
|
462
|
+
}
|
|
388
463
|
}
|
|
389
|
-
onUpdate(date);
|
|
390
464
|
}
|
|
391
465
|
}
|
|
392
466
|
|
|
@@ -475,7 +549,8 @@ export class TimeSync implements TimeSyncApi {
|
|
|
475
549
|
// This setup requires that every interval array stay sorted. It
|
|
476
550
|
// immediately falls apart if this isn't guaranteed.
|
|
477
551
|
for (const entries of this.#subscriptions.values()) {
|
|
478
|
-
const subFastest =
|
|
552
|
+
const subFastest =
|
|
553
|
+
entries[0]?.targetRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
|
|
479
554
|
if (subFastest < newFastest) {
|
|
480
555
|
newFastest = subFastest;
|
|
481
556
|
}
|
|
@@ -487,7 +562,7 @@ export class TimeSync implements TimeSyncApi {
|
|
|
487
562
|
}
|
|
488
563
|
}
|
|
489
564
|
|
|
490
|
-
subscribe(
|
|
565
|
+
subscribe(options: SubscriptionInitOptions): () => void {
|
|
491
566
|
const { config } = this.#latestSnapshot;
|
|
492
567
|
if (config.freezeUpdates) {
|
|
493
568
|
return noOp;
|
|
@@ -495,7 +570,7 @@ export class TimeSync implements TimeSyncApi {
|
|
|
495
570
|
|
|
496
571
|
// Destructuring properties so that they can't be fiddled with after
|
|
497
572
|
// this function call ends
|
|
498
|
-
const { targetRefreshIntervalMs, onUpdate } =
|
|
573
|
+
const { targetRefreshIntervalMs, onUpdate } = options;
|
|
499
574
|
|
|
500
575
|
const isTargetValid =
|
|
501
576
|
targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
|
|
@@ -507,50 +582,73 @@ export class TimeSync implements TimeSyncApi {
|
|
|
507
582
|
);
|
|
508
583
|
}
|
|
509
584
|
|
|
510
|
-
|
|
585
|
+
// Have to define this as a writeable to avoid a chicken-and-the-egg
|
|
586
|
+
// problem for the unsubscribe callback
|
|
587
|
+
const context: Writeable<SubscriptionContext> = {
|
|
588
|
+
isSubscribed: true,
|
|
589
|
+
timeSync: this,
|
|
590
|
+
unsubscribe: noOp,
|
|
591
|
+
registeredAt: new ReadonlyDate(),
|
|
592
|
+
intervalLastFulfilledAt: null,
|
|
593
|
+
targetRefreshIntervalMs: Math.max(
|
|
594
|
+
config.minimumRefreshIntervalMs,
|
|
595
|
+
targetRefreshIntervalMs,
|
|
596
|
+
),
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// Not reading from context value to decide whether to bail out of
|
|
600
|
+
// unsubscribes in off chance that outside consumer accidentally mutates
|
|
601
|
+
// the value
|
|
511
602
|
let subscribed = true;
|
|
603
|
+
const subsOnSetup = this.#subscriptions;
|
|
512
604
|
const unsubscribe = (): void => {
|
|
513
605
|
if (!subscribed || this.#subscriptions !== subsOnSetup) {
|
|
606
|
+
context.isSubscribed = false;
|
|
514
607
|
subscribed = false;
|
|
515
608
|
return;
|
|
516
609
|
}
|
|
517
610
|
|
|
518
|
-
const
|
|
519
|
-
if (
|
|
611
|
+
const contexts = subsOnSetup.get(onUpdate);
|
|
612
|
+
if (contexts === undefined) {
|
|
520
613
|
return;
|
|
521
614
|
}
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
);
|
|
525
|
-
if (matchIndex === -1) {
|
|
615
|
+
const filtered = contexts.filter((e) => e.unsubscribe !== unsubscribe);
|
|
616
|
+
if (filtered.length === contexts.length) {
|
|
526
617
|
return;
|
|
527
618
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
entries.splice(matchIndex, 1);
|
|
531
|
-
if (entries.length === 0) {
|
|
619
|
+
|
|
620
|
+
if (filtered.length === 0) {
|
|
532
621
|
subsOnSetup.delete(onUpdate);
|
|
533
622
|
this.#updateFastestInterval();
|
|
623
|
+
} else {
|
|
624
|
+
// No need to sort on removal because everything gets sorted as
|
|
625
|
+
// it enters the subscriptions map
|
|
626
|
+
subsOnSetup.set(onUpdate, filtered);
|
|
534
627
|
}
|
|
535
628
|
|
|
536
629
|
void this.#setSnapshot({
|
|
537
630
|
subscriberCount: Math.max(0, this.#latestSnapshot.subscriberCount - 1),
|
|
538
631
|
});
|
|
632
|
+
|
|
633
|
+
context.isSubscribed = false;
|
|
539
634
|
subscribed = false;
|
|
540
635
|
};
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
636
|
+
context.unsubscribe = unsubscribe;
|
|
637
|
+
|
|
638
|
+
let contexts: SubscriptionContext[];
|
|
639
|
+
if (this.#subscriptions.has(onUpdate)) {
|
|
640
|
+
const prev = this.#subscriptions.get(onUpdate) as SubscriptionContext[];
|
|
641
|
+
contexts = [...prev];
|
|
642
|
+
} else {
|
|
643
|
+
contexts = [];
|
|
644
|
+
subsOnSetup.set(onUpdate, contexts);
|
|
546
645
|
}
|
|
547
646
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
647
|
+
subsOnSetup.set(onUpdate, contexts);
|
|
648
|
+
contexts.push(context);
|
|
649
|
+
contexts.sort(
|
|
650
|
+
(e1, e2) => e1.targetRefreshIntervalMs - e2.targetRefreshIntervalMs,
|
|
551
651
|
);
|
|
552
|
-
entries.push({ unsubscribe, targetInterval });
|
|
553
|
-
entries.sort((e1, e2) => e1.targetInterval - e2.targetInterval);
|
|
554
652
|
|
|
555
653
|
void this.#setSnapshot({
|
|
556
654
|
subscriberCount: this.#latestSnapshot.subscriberCount + 1,
|
|
@@ -567,17 +665,22 @@ export class TimeSync implements TimeSyncApi {
|
|
|
567
665
|
clearAll(): void {
|
|
568
666
|
clearInterval(this.#intervalId);
|
|
569
667
|
this.#intervalId = undefined;
|
|
570
|
-
this.#fastestRefreshInterval =
|
|
571
|
-
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
668
|
+
this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
|
|
669
|
+
|
|
670
|
+
// As long as we clean things the internal state, it's safe not to
|
|
671
|
+
// bother calling each unsubscribe callback. Not calling them one by
|
|
672
|
+
// one actually has much better time complexity
|
|
673
|
+
for (const subArray of this.#subscriptions.values()) {
|
|
674
|
+
for (const ctx of subArray) {
|
|
675
|
+
ctx.isSubscribed = false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
this.#subscriptions.clear();
|
|
680
|
+
|
|
681
|
+
// We swap the map out so that the unsubscribe callbacks can detect
|
|
682
|
+
// whether their functionality is still relevant
|
|
579
683
|
this.#subscriptions = new Map();
|
|
580
|
-
subsBefore.clear();
|
|
581
684
|
void this.#setSnapshot({ subscriberCount: 0 });
|
|
582
685
|
}
|
|
583
686
|
}
|
package/src/index.ts
CHANGED
package/src/utilities.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
|