@buenos-nachos/time-sync 0.4.1 → 0.5.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @buenos-nachos/time-sync
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5fdc201: Updated wording on `Snapshot.date` to be less misleading.
8
+
9
+ ## 0.5.0
10
+
11
+ ### Breaking Changes
12
+
13
+ - c3986e9: revamped all state management and APIs to be based on monotonic time
14
+ - c3986e9: Removed `registeredAt` and `intervalLastFulfilledAt` properties from `SubscriptionContext` and added monotonic `registeredAtMs`
15
+ - c3986e9: Added monotonic `lastUpdatedAt` property to `Snapshot` type.
16
+
3
17
  ## 0.4.1
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buenos-nachos/time-sync",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": "Michael Smith <hello@nachos.dev> (https://www.nachos.dev)",
@@ -7,7 +7,6 @@ import {
7
7
  type SubscriptionContext,
8
8
  TimeSync,
9
9
  } from "./TimeSync";
10
- import type { Writeable } from "./utilities";
11
10
 
12
11
  const invalidIntervals: readonly number[] = [
13
12
  Number.NaN,
@@ -132,9 +131,8 @@ describe(TimeSync, () => {
132
131
  const expectedCtx: SubscriptionContext = {
133
132
  unsubscribe,
134
133
  timeSync: sync,
135
- intervalLastFulfilledAt: dateAfter,
136
- registeredAt: dateBefore,
137
- targetRefreshIntervalMs: rate,
134
+ refreshIntervalMs: rate,
135
+ registeredAtMs: 0,
138
136
  };
139
137
  expect(onUpdate).toHaveBeenCalledWith(dateAfter, expectedCtx);
140
138
 
@@ -588,20 +586,19 @@ describe(TimeSync, () => {
588
586
  expect,
589
587
  }) => {
590
588
  const sync = new TimeSync();
591
- const interval = refreshRates.oneMinute;
592
589
 
593
590
  let ejectedInterval: number | undefined;
594
591
  const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
595
- ejectedInterval = ctx.targetRefreshIntervalMs;
592
+ ejectedInterval = ctx.refreshIntervalMs;
596
593
  });
597
594
 
598
- void sync.subscribe({
595
+ sync.subscribe({
599
596
  onUpdate,
600
- targetRefreshIntervalMs: interval,
597
+ targetRefreshIntervalMs: refreshRates.oneMinute,
601
598
  });
602
599
 
603
- await vi.advanceTimersByTimeAsync(interval);
604
- expect(ejectedInterval).toBe(interval);
600
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
601
+ expect(ejectedInterval).toBe(refreshRates.oneMinute);
605
602
  });
606
603
 
607
604
  it("Exposes exact same unsubscribe callback as the one returned from the subscribe call", async ({
@@ -623,48 +620,32 @@ describe(TimeSync, () => {
623
620
  expect(ejectedUnsub).toBe(unsub);
624
621
  });
625
622
 
626
- it("Exposes when the subscription was first set up", async ({ expect }) => {
623
+ it("Exposes when the subscription was first set up, relative to the TimeSync instantiation", async ({
624
+ expect,
625
+ }) => {
627
626
  const sync = new TimeSync();
628
- const start = sync.getStateSnapshot().date;
629
-
630
- let ejectedDate: Date | undefined;
631
- const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
632
- ejectedDate = ctx.registeredAt;
633
- });
634
627
 
628
+ let ejectedSetupTime1: number | undefined;
635
629
  void sync.subscribe({
636
- onUpdate,
637
630
  targetRefreshIntervalMs: refreshRates.oneMinute,
631
+ onUpdate: (_, ctx) => {
632
+ ejectedSetupTime1 = ctx.registeredAtMs;
633
+ },
638
634
  });
639
635
 
640
636
  await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
641
- expect(ejectedDate).toEqual(start);
642
- });
643
-
644
- it("Indicates when the last requested interval was fulfilled", async ({
645
- expect,
646
- }) => {
647
- const sync = new TimeSync();
648
-
649
- const fulfilledValues: (ReadonlyDate | null)[] = [];
650
- const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
651
- fulfilledValues.push(ctx.intervalLastFulfilledAt);
652
- });
637
+ expect(ejectedSetupTime1).toEqual(0);
653
638
 
639
+ let ejectedSetupTime2: number | undefined;
654
640
  void sync.subscribe({
655
- onUpdate,
656
641
  targetRefreshIntervalMs: refreshRates.oneMinute,
657
- });
658
- void sync.subscribe({
659
- onUpdate: vi.fn(),
660
- targetRefreshIntervalMs: refreshRates.thirtySeconds,
642
+ onUpdate: (_, ctx) => {
643
+ ejectedSetupTime2 = ctx.registeredAtMs;
644
+ },
661
645
  });
662
646
 
663
647
  await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
664
- const snapAfter = sync.getStateSnapshot().date;
665
-
666
- expect(onUpdate).toHaveBeenCalledTimes(2);
667
- expect(fulfilledValues).toEqual([null, snapAfter]);
648
+ expect(ejectedSetupTime2).toEqual(refreshRates.oneMinute);
668
649
  });
669
650
  });
670
651
 
@@ -782,45 +763,45 @@ describe(TimeSync, () => {
782
763
  expect,
783
764
  }) => {
784
765
  const sync = new TimeSync();
785
- const snapBefore = sync.getStateSnapshot().date;
786
766
 
787
767
  let ejectedContext: SubscriptionContext | undefined;
788
768
  const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
789
769
  ejectedContext = ctx;
790
770
  });
791
771
 
772
+ // Registering all three with the exact same callback. That way, if either
773
+ // of the others get processed, their contexts should overwrite the
774
+ // ejected context and make the tests fail
792
775
  const unsub = sync.subscribe({
793
776
  onUpdate,
794
777
  targetRefreshIntervalMs: refreshRates.oneHour,
795
778
  });
796
- void sync.subscribe({
779
+ sync.subscribe({
797
780
  onUpdate,
798
781
  targetRefreshIntervalMs: refreshRates.oneMinute,
799
782
  });
800
- void sync.subscribe({
783
+ sync.subscribe({
801
784
  onUpdate,
802
785
  targetRefreshIntervalMs: refreshRates.oneSecond,
803
786
  });
804
787
 
805
788
  await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
806
789
  expect(ejectedContext).toEqual<SubscriptionContext>({
807
- intervalLastFulfilledAt: null,
808
- registeredAt: snapBefore,
809
- targetRefreshIntervalMs: refreshRates.oneHour,
790
+ refreshIntervalMs: refreshRates.oneHour,
810
791
  timeSync: sync,
811
792
  unsubscribe: unsub,
793
+ registeredAtMs: 0,
812
794
  });
813
795
 
814
- const remainingSecondsToOneHour = refreshRates.oneHour - 1000;
796
+ const remainingSecondsToOneHour =
797
+ refreshRates.oneHour - refreshRates.oneSecond;
815
798
  await vi.advanceTimersByTimeAsync(remainingSecondsToOneHour);
816
799
 
817
- const snapAfter = sync.getStateSnapshot().date;
818
800
  expect(ejectedContext).toEqual<SubscriptionContext>({
819
- intervalLastFulfilledAt: snapAfter,
820
- registeredAt: snapBefore,
821
- targetRefreshIntervalMs: refreshRates.oneHour,
801
+ refreshIntervalMs: refreshRates.oneHour,
822
802
  timeSync: sync,
823
803
  unsubscribe: unsub,
804
+ registeredAtMs: 0,
824
805
  });
825
806
  });
826
807
  });
@@ -841,6 +822,7 @@ describe(TimeSync, () => {
841
822
  expect(snap).toEqual<Snapshot>({
842
823
  date: initialDate,
843
824
  subscriberCount: 0,
825
+ lastUpdatedAtMs: null,
844
826
  config: {
845
827
  freezeUpdates: false,
846
828
  minimumRefreshIntervalMs,
@@ -971,6 +953,7 @@ describe(TimeSync, () => {
971
953
 
972
954
  // We have readonly modifiers on the types, but we need to make sure
973
955
  // nothing can break at runtime
956
+ type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
974
957
  const snap = sync.getStateSnapshot() as Writeable<Snapshot>;
975
958
  const config = snap.config as Writeable<Configuration>;
976
959
  const copyBeforeMutations = { ...snap, config: { ...config } };
@@ -978,6 +961,7 @@ describe(TimeSync, () => {
978
961
  const mutationSource: Snapshot = {
979
962
  date: new ReadonlyDate("April 1, 1970"),
980
963
  subscriberCount: Number.POSITIVE_INFINITY,
964
+ lastUpdatedAtMs: 1_000_000,
981
965
  config: {
982
966
  freezeUpdates: true,
983
967
  minimumRefreshIntervalMs: Number.POSITIVE_INFINITY,
@@ -989,6 +973,9 @@ describe(TimeSync, () => {
989
973
  () => {
990
974
  snap.date = mutationSource.date;
991
975
  },
976
+ () => {
977
+ snap.lastUpdatedAtMs = mutationSource.lastUpdatedAtMs;
978
+ },
992
979
  () => {
993
980
  snap.subscriberCount = mutationSource.subscriberCount;
994
981
  },
@@ -1071,7 +1058,7 @@ describe(TimeSync, () => {
1071
1058
  * Not sure how to codify that in tests yet, but ideally it should be.
1072
1059
  */
1073
1060
  describe("Freezing updates on init", () => {
1074
- it("Never updates internal state, no matter how many subscribers subscribe", ({
1061
+ it("Always updates internal state to reflect subscription count", async ({
1075
1062
  expect,
1076
1063
  }) => {
1077
1064
  const initialDate = new Date("August 25, 1832");
@@ -1085,8 +1072,9 @@ describe(TimeSync, () => {
1085
1072
  });
1086
1073
  }
1087
1074
 
1075
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1088
1076
  const snap = sync.getStateSnapshot();
1089
- expect(snap.subscriberCount).toBe(0);
1077
+ expect(snap.subscriberCount).toBe(1000);
1090
1078
  expect(snap.date).toEqual(initialDate);
1091
1079
  });
1092
1080
  });
@@ -1216,30 +1204,29 @@ describe(TimeSync, () => {
1216
1204
  expect(snap3).toBe(0);
1217
1205
  });
1218
1206
 
1219
- it("Lets consumers detect whether an update corresponds to the subscription they explicitly set up", async ({
1207
+ it("Does not break update cadence if system time jumps around", async ({
1220
1208
  expect,
1221
1209
  }) => {
1222
- const sync = new TimeSync();
1223
- const innerOnUpdate = vi.fn();
1210
+ const initialDate = setInitialTime("2022-03-15T08:00:00Z");
1211
+ const sync = new TimeSync({ initialDate });
1212
+ const thirtyMinutes = 30 * refreshRates.oneMinute;
1224
1213
 
1225
- void sync.subscribe({
1226
- targetRefreshIntervalMs: refreshRates.oneMinute,
1227
- onUpdate: (date, ctx) => {
1228
- const intervalMatches =
1229
- date.getTime() === ctx.intervalLastFulfilledAt?.getTime();
1230
- if (intervalMatches) {
1231
- innerOnUpdate();
1232
- }
1233
- },
1234
- });
1214
+ const onUpdate = vi.fn();
1215
+ sync.subscribe({ onUpdate, targetRefreshIntervalMs: thirtyMinutes });
1235
1216
 
1236
- void sync.subscribe({
1237
- targetRefreshIntervalMs: refreshRates.thirtySeconds,
1238
- onUpdate: vi.fn(),
1239
- });
1217
+ await vi.advanceTimersByTimeAsync(thirtyMinutes);
1218
+ expect(onUpdate).toHaveBeenCalledTimes(1);
1240
1219
 
1241
- await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1242
- expect(innerOnUpdate).toHaveBeenCalledTimes(1);
1220
+ // Go one hour into the past and then advance 30 minutes to go back to
1221
+ // where we started
1222
+ vi.setSystemTime("2022-03-15T07:30:00Z");
1223
+ await vi.advanceTimersByTimeAsync(thirtyMinutes);
1224
+ expect(onUpdate).toHaveBeenCalledTimes(2);
1225
+
1226
+ // Go one day into the past and then advance another 30 minutes
1227
+ vi.setSystemTime("2022-03-14T08:00:00Z");
1228
+ await vi.advanceTimersByTimeAsync(thirtyMinutes);
1229
+ expect(onUpdate).toHaveBeenCalledTimes(3);
1243
1230
  });
1244
1231
  });
1245
1232
  });
package/src/TimeSync.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { ReadonlyDate } from "./ReadonlyDate";
2
- import type { Writeable } from "./utilities";
3
2
 
4
3
  /**
5
4
  * A collection of commonly-needed intervals (all defined in milliseconds).
@@ -114,10 +113,20 @@ export interface SubscriptionInitOptions {
114
113
  */
115
114
  export interface Snapshot {
116
115
  /**
117
- * The date that was last dispatched to all subscribers.
116
+ * The date that TimeSync last processed. This will always match the date that
117
+ * was last dispatched to all subscribers, but if no updates have been issued,
118
+ * this value will match the date used to instantiate the TimeSync.
118
119
  */
119
120
  readonly date: ReadonlyDate;
120
121
 
122
+ /**
123
+ * The monotonic milliseconds that elapsed between the TimeSync being
124
+ * instantiated and the last update being dispatched.
125
+ *
126
+ * Will be null if no updates have ever been dispatched.
127
+ */
128
+ readonly lastUpdatedAtMs: number | null;
129
+
121
130
  /**
122
131
  * The number of subscribers registered with TimeSync.
123
132
  */
@@ -132,44 +141,33 @@ export interface Snapshot {
132
141
 
133
142
  /**
134
143
  * An object with information about a specific subscription registered with
135
- * TimeSync.
136
- *
137
- * For performance reasons, this object has ZERO readonly guarantees enforced at
138
- * runtime. All properties are defined as readonly at the type level, but an
139
- * accidental mutation can still slip through.
144
+ * TimeSync. The entire context is frozen at runtime.
140
145
  */
141
146
  export interface SubscriptionContext {
142
147
  /**
143
- * The interval that the subscription was registered with.
144
- */
145
- readonly targetRefreshIntervalMs: number;
146
-
147
- /**
148
- * The unsubscribe callback associated with a subscription. This is the same
149
- * callback returned by `TimeSync.subscribe`.
148
+ * A reference to the TimeSync instance that the subscription was registered
149
+ * with.
150
150
  */
151
- readonly unsubscribe: () => void;
151
+ readonly timeSync: TimeSync;
152
152
 
153
153
  /**
154
- * A timestamp of when the subscription was first set up.
154
+ * The effective interval that the subscription is updating at. This may be a
155
+ * value larger than than the target refresh interval, depending on whether
156
+ * TimeSync was configured with a minimum refresh value.
155
157
  */
156
- readonly registeredAt: ReadonlyDate;
158
+ readonly refreshIntervalMs: number;
157
159
 
158
160
  /**
159
- * A reference to the TimeSync instance that the subscription was registered
160
- * with.
161
+ * The unsubscribe callback associated with a subscription. This is the same
162
+ * callback returned by `TimeSync.subscribe`.
161
163
  */
162
- readonly timeSync: TimeSync;
164
+ readonly unsubscribe: () => void;
163
165
 
164
166
  /**
165
- * Indicates when the last time the subscription had its explicit interval
166
- * "satisfied".
167
- *
168
- * For example, if a subscription is registered for every five minutes, but
169
- * the active interval is set to fire every second, you may need to know
170
- * which update actually happened five minutes later.
167
+ * The monotonic milliseconds that elapsed between the TimeSync being
168
+ * instantiated and the subscription being registered.
171
169
  */
172
- readonly intervalLastFulfilledAt: ReadonlyDate | null;
170
+ readonly registeredAtMs: number;
173
171
  }
174
172
 
175
173
  /**
@@ -226,9 +224,25 @@ interface TimeSyncApi {
226
224
  clearAll: () => void;
227
225
  }
228
226
 
229
- /* biome-ignore lint:suspicious/noEmptyBlockStatements -- Rare case where we do
230
- actually want a completely empty function body. */
231
- function noOp(..._: readonly unknown[]): void {}
227
+ /**
228
+ * Even though both the browser and the server are able to give monotonic times
229
+ * that are at least as precise as a nanosecond, we're using milliseconds for
230
+ * consistency with useInterval, which cannot be more precise than a
231
+ * millisecond.
232
+ */
233
+ function getMonotonicTimeMs(): number {
234
+ // If we're on the server, we can use process.hrtime, which is defined for
235
+ // Node, Deno, and Bun
236
+ if (typeof window === "undefined") {
237
+ const timeInNanoseconds = process.hrtime.bigint();
238
+ return Number(timeInNanoseconds / 1000n);
239
+ }
240
+
241
+ // Otherwise, we need to get the high-resolution timestamp from the browser.
242
+ // This value is fractional and goes to nine decimal places
243
+ const highResTimestamp = window.performance.now();
244
+ return Math.floor(highResTimestamp);
245
+ }
232
246
 
233
247
  /**
234
248
  * This function is just a convenience for us to sidestep some problems around
@@ -243,8 +257,13 @@ function noOp(..._: readonly unknown[]): void {}
243
257
  * to worry about mismatches.
244
258
  */
245
259
  function freezeSnapshot(snap: Snapshot): Snapshot {
246
- Object.freeze(snap.config);
247
- return Object.freeze(snap);
260
+ if (!Object.isFrozen(snap.config)) {
261
+ Object.freeze(snap.config);
262
+ }
263
+ if (!Object.isFrozen(snap)) {
264
+ Object.freeze(snap);
265
+ }
266
+ return snap;
248
267
  }
249
268
 
250
269
  const defaultMinimumRefreshIntervalMs = 200;
@@ -285,6 +304,12 @@ const defaultMinimumRefreshIntervalMs = 200;
285
304
  * some parts of the screen.)
286
305
  */
287
306
  export class TimeSync implements TimeSyncApi {
307
+ /**
308
+ * The monotonic time in milliseconds from when the TimeSync instance was
309
+ * first instantiated.
310
+ */
311
+ readonly #initializedAtMs: number;
312
+
288
313
  /**
289
314
  * Stores all refresh intervals actively associated with an onUpdate
290
315
  * callback (along with their associated unsubscribe callbacks).
@@ -333,7 +358,8 @@ export class TimeSync implements TimeSyncApi {
333
358
 
334
359
  /**
335
360
  * Used for both its intended purpose (creating interval), but also as a
336
- * janky version of setTimeout.
361
+ * janky version of setTimeout. Also, all versions of setInterval are
362
+ * monotonic, so we don't have to do anything special for it.
337
363
  *
338
364
  * There are a few times when we need timeout-like logic, but if we use
339
365
  * setInterval for everything, we have fewer IDs to juggle, and less risk of
@@ -365,22 +391,30 @@ export class TimeSync implements TimeSyncApi {
365
391
  this.#subscriptions = new Map();
366
392
  this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
367
393
  this.#intervalId = undefined;
394
+ this.#initializedAtMs = getMonotonicTimeMs();
395
+
396
+ let date: ReadonlyDate;
397
+ if (initialDate instanceof ReadonlyDate) {
398
+ date = initialDate;
399
+ } else if (initialDate instanceof Date) {
400
+ date = new ReadonlyDate(initialDate);
401
+ } else {
402
+ date = new ReadonlyDate();
403
+ }
368
404
 
369
- // Not defined inline to avoid wonkiness that Object.freeze introduces
370
- // when you rename a property on a frozen object
371
- const initialSnapshot: Snapshot = {
405
+ this.#latestSnapshot = freezeSnapshot({
406
+ date,
372
407
  subscriberCount: 0,
373
- date: initialDate ? new ReadonlyDate(initialDate) : new ReadonlyDate(),
374
- config: Object.freeze({
408
+ lastUpdatedAtMs: null,
409
+ config: {
375
410
  freezeUpdates,
376
411
  minimumRefreshIntervalMs,
377
412
  allowDuplicateOnUpdateCalls,
378
- }),
379
- };
380
- this.#latestSnapshot = Object.freeze(initialSnapshot);
413
+ },
414
+ });
381
415
  }
382
416
 
383
- #processSubscriptionUpdate(): void {
417
+ #notifyAllSubscriptions(): void {
384
418
  // It's more important that we copy the date object into a separate
385
419
  // variable here than normal, because need make sure the `this` context
386
420
  // can't magically change between updates and cause subscribers to
@@ -395,8 +429,6 @@ export class TimeSync implements TimeSyncApi {
395
429
  return;
396
430
  }
397
431
 
398
- const dateTime = date.getTime();
399
-
400
432
  /**
401
433
  * Two things:
402
434
  * 1. Even though the context arrays are defined as readonly (which
@@ -416,32 +448,20 @@ export class TimeSync implements TimeSyncApi {
416
448
  * to check on each iteration to see if we should continue.
417
449
  */
418
450
  const subsBeforeUpdate = this.#subscriptions;
419
- const entries = Array.from(subsBeforeUpdate);
420
- outer: for (const [onUpdate, subs] of entries) {
421
- // Even if duplicate onUpdate calls are disabled, we still need to
422
- // iterate through everything and update any internal data. If the
423
- // first context in a sub array gets removed by unsubscribing, we
424
- // want what was the the second element to still be up to date
425
- let shouldCallOnUpdate = true;
426
- for (const ctx of subs as readonly Writeable<SubscriptionContext>[]) {
451
+ const localEntries = Array.from(subsBeforeUpdate);
452
+ outer: for (const [onUpdate, subs] of localEntries) {
453
+ for (const ctx of subs) {
427
454
  // We're not doing anything more sophisticated here because
428
455
  // we're assuming that any systems that can clear out the
429
456
  // subscriptions will handle cleaning up each context, too
430
- const wasCleared = subsBeforeUpdate.size === 0;
431
- if (wasCleared) {
457
+ const wasClearedBetweenUpdates = subsBeforeUpdate.size === 0;
458
+ if (wasClearedBetweenUpdates) {
432
459
  break outer;
433
460
  }
434
461
 
435
- const comparisonDate = ctx.intervalLastFulfilledAt ?? ctx.registeredAt;
436
- const isIntervalMatch =
437
- dateTime - comparisonDate.getTime() >= ctx.targetRefreshIntervalMs;
438
- if (isIntervalMatch) {
439
- ctx.intervalLastFulfilledAt = date;
440
- }
441
-
442
- if (shouldCallOnUpdate) {
443
- onUpdate(date, ctx);
444
- shouldCallOnUpdate = config.allowDuplicateOnUpdateCalls;
462
+ onUpdate(date, ctx);
463
+ if (!config.allowDuplicateOnUpdateCalls) {
464
+ continue outer;
445
465
  }
446
466
  }
447
467
  }
@@ -456,7 +476,7 @@ export class TimeSync implements TimeSyncApi {
456
476
  * is one of them.
457
477
  */
458
478
  readonly #onTick = (): void => {
459
- const { config, date } = this.#latestSnapshot;
479
+ const { config } = this.#latestSnapshot;
460
480
  if (config.freezeUpdates) {
461
481
  // Defensive step to make sure that an invalid tick wasn't started
462
482
  clearInterval(this.#intervalId);
@@ -464,59 +484,57 @@ export class TimeSync implements TimeSyncApi {
464
484
  return;
465
485
  }
466
486
 
467
- const newDate = new ReadonlyDate();
468
- if (newDate.getTime() === date.getTime()) {
469
- return;
470
- }
471
-
487
+ // onTick is expected to be called in response to monotonic time changes
488
+ // (either from calculating them manually to decide when to call onTick
489
+ // synchronously or from letting setInterval handle the calls). So while
490
+ // this edge case should basically be impossible, we need to make sure that
491
+ // we always dispatch a date, even if its time is exactly the same.
472
492
  this.#latestSnapshot = freezeSnapshot({
473
493
  ...this.#latestSnapshot,
474
- date: newDate,
494
+ date: new ReadonlyDate(),
495
+ lastUpdatedAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
475
496
  });
476
- this.#processSubscriptionUpdate();
497
+ this.#notifyAllSubscriptions();
477
498
  };
478
499
 
479
500
  #onFastestIntervalChange(): void {
480
501
  const fastest = this.#fastestRefreshInterval;
481
- const { date, config } = this.#latestSnapshot;
502
+ const { lastUpdatedAtMs, config } = this.#latestSnapshot;
503
+
482
504
  const updatesShouldStop =
483
- config.freezeUpdates || fastest === Number.POSITIVE_INFINITY;
505
+ config.freezeUpdates ||
506
+ this.#subscriptions.size === 0 ||
507
+ fastest === Number.POSITIVE_INFINITY;
484
508
  if (updatesShouldStop) {
485
509
  clearInterval(this.#intervalId);
486
510
  this.#intervalId = undefined;
487
511
  return;
488
512
  }
489
513
 
490
- const elapsed = Date.now() - date.getTime();
514
+ const newTime = getMonotonicTimeMs();
515
+ const elapsed = newTime - (lastUpdatedAtMs ?? this.#initializedAtMs);
491
516
  const timeBeforeNextUpdate = fastest - elapsed;
492
517
 
493
- // Clear previous interval sight unseen just to be on the safe side
518
+ // Clear previous interval no matter what just to be on the safe side
494
519
  clearInterval(this.#intervalId);
495
520
 
496
521
  if (timeBeforeNextUpdate <= 0) {
497
- const newDate = new ReadonlyDate();
498
- if (newDate.getTime() !== date.getTime()) {
499
- this.#latestSnapshot = freezeSnapshot({
500
- ...this.#latestSnapshot,
501
- date: newDate,
502
- });
503
- this.#processSubscriptionUpdate();
504
- }
505
-
522
+ this.#onTick();
506
523
  this.#intervalId = setInterval(this.#onTick, fastest);
507
524
  return;
508
525
  }
509
526
 
510
527
  // Most common case for this branch is the very first subscription
511
528
  // getting added, but there's still the small chance that the fastest
512
- // interval could change right after an update got flushed
529
+ // interval could change right after an update got flushed, so there would
530
+ // be zero elapsed time to worry about
513
531
  if (timeBeforeNextUpdate === fastest) {
514
532
  this.#intervalId = setInterval(this.#onTick, timeBeforeNextUpdate);
515
533
  return;
516
534
  }
517
535
 
518
- // Otherwise, use interval as pseudo-timeout, and then go back to using
519
- // it as a normal interval afterwards
536
+ // Otherwise, use setInterval as pseudo-timeout to resolve the remaining
537
+ // time as a one-time update, and then go back to using normal intervals
520
538
  this.#intervalId = setInterval(() => {
521
539
  clearInterval(this.#intervalId);
522
540
 
@@ -537,14 +555,13 @@ export class TimeSync implements TimeSyncApi {
537
555
  return;
538
556
  }
539
557
 
540
- const prevFastest = this.#fastestRefreshInterval;
541
- let newFastest = Number.POSITIVE_INFINITY;
542
-
543
558
  // This setup requires that every interval array stay sorted. It
544
559
  // immediately falls apart if this isn't guaranteed.
545
- for (const entries of this.#subscriptions.values()) {
560
+ const prevFastest = this.#fastestRefreshInterval;
561
+ let newFastest = Number.POSITIVE_INFINITY;
562
+ for (const contexts of this.#subscriptions.values()) {
546
563
  const subFastest =
547
- entries[0]?.targetRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
564
+ contexts[0]?.refreshIntervalMs ?? Number.POSITIVE_INFINITY;
548
565
  if (subFastest < newFastest) {
549
566
  newFastest = subFastest;
550
567
  }
@@ -557,14 +574,10 @@ export class TimeSync implements TimeSyncApi {
557
574
  }
558
575
 
559
576
  subscribe(options: SubscriptionInitOptions): () => void {
560
- const { config } = this.#latestSnapshot;
561
- if (config.freezeUpdates) {
562
- return noOp;
563
- }
564
-
565
577
  // Destructuring properties so that they can't be fiddled with after
566
578
  // this function call ends
567
579
  const { targetRefreshIntervalMs, onUpdate } = options;
580
+ const { minimumRefreshIntervalMs } = this.#latestSnapshot.config;
568
581
 
569
582
  const isTargetValid =
570
583
  targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
@@ -576,71 +589,72 @@ export class TimeSync implements TimeSyncApi {
576
589
  );
577
590
  }
578
591
 
579
- // Have to define this as a writeable to avoid a chicken-and-the-egg
580
- // problem for the unsubscribe callback
581
- const context: Writeable<SubscriptionContext> = {
592
+ const subsOnSetup = this.#subscriptions;
593
+ let subscribed = true;
594
+ const ctx: SubscriptionContext = {
582
595
  timeSync: this,
583
- unsubscribe: noOp,
584
- registeredAt: new ReadonlyDate(),
585
- intervalLastFulfilledAt: null,
586
- targetRefreshIntervalMs: Math.max(
587
- config.minimumRefreshIntervalMs,
596
+ registeredAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
597
+ refreshIntervalMs: Math.max(
598
+ minimumRefreshIntervalMs,
588
599
  targetRefreshIntervalMs,
589
600
  ),
590
- };
591
-
592
- // Not reading from context value to decide whether to bail out of
593
- // unsubscribes in off chance that outside consumer accidentally mutates
594
- // the value
595
- let subscribed = true;
596
- const subsOnSetup = this.#subscriptions;
597
- const unsubscribe = (): void => {
598
- if (!subscribed || this.#subscriptions !== subsOnSetup) {
599
- subscribed = false;
600
- return;
601
- }
602
601
 
603
- const contexts = subsOnSetup.get(onUpdate);
604
- if (contexts === undefined) {
605
- return;
606
- }
607
- const filtered = contexts.filter((e) => e.unsubscribe !== unsubscribe);
608
- if (filtered.length === contexts.length) {
609
- return;
610
- }
611
-
612
- if (filtered.length === 0) {
613
- subsOnSetup.delete(onUpdate);
614
- this.#updateFastestInterval();
615
- } else {
616
- // No need to sort on removal because everything gets sorted as
617
- // it enters the subscriptions map
618
- subsOnSetup.set(onUpdate, filtered);
619
- }
620
-
621
- this.#latestSnapshot = freezeSnapshot({
622
- ...this.#latestSnapshot,
623
- subscriberCount: Math.max(0, this.#latestSnapshot.subscriberCount - 1),
624
- });
625
-
626
- subscribed = false;
602
+ unsubscribe: () => {
603
+ // Not super conventional, but basically using try/finally as a form of
604
+ // Go's defer. There are a lot of branches we need to worry about for
605
+ // the unsubscribe callback, and we need to make sure we flip subscribed
606
+ // to false after each one
607
+ try {
608
+ if (!subscribed || this.#subscriptions !== subsOnSetup) {
609
+ return;
610
+ }
611
+ const contexts = subsOnSetup.get(onUpdate);
612
+ if (contexts === undefined) {
613
+ return;
614
+ }
615
+ const filtered = contexts.filter(
616
+ (c) => c.unsubscribe !== ctx.unsubscribe,
617
+ );
618
+ if (filtered.length === contexts.length) {
619
+ return;
620
+ }
621
+
622
+ const dropped = Math.max(0, this.#latestSnapshot.subscriberCount - 1);
623
+ this.#latestSnapshot = freezeSnapshot({
624
+ ...this.#latestSnapshot,
625
+ subscriberCount: dropped,
626
+ });
627
+
628
+ if (filtered.length > 0) {
629
+ // No need to sort on removal because everything gets sorted as
630
+ // it enters the subscriptions map
631
+ subsOnSetup.set(onUpdate, filtered);
632
+ } else {
633
+ subsOnSetup.delete(onUpdate);
634
+ }
635
+
636
+ this.#updateFastestInterval();
637
+ } finally {
638
+ subscribed = false;
639
+ }
640
+ },
627
641
  };
628
- context.unsubscribe = unsubscribe;
629
-
630
- let contexts: SubscriptionContext[];
631
- if (this.#subscriptions.has(onUpdate)) {
632
- const prev = this.#subscriptions.get(onUpdate) as SubscriptionContext[];
633
- contexts = [...prev];
642
+ Object.freeze(ctx);
643
+
644
+ // We need to make sure that each array for tracking subscriptions is
645
+ // readonly, and because dispatching updates should be far more common than
646
+ // adding subscriptions, we're placing the immutable copying here to
647
+ // minimize overall pressure on the system.
648
+ let newContexts: SubscriptionContext[];
649
+ const prevContexts = subsOnSetup.get(onUpdate);
650
+ if (prevContexts !== undefined) {
651
+ newContexts = [...prevContexts, ctx];
634
652
  } else {
635
- contexts = [];
636
- subsOnSetup.set(onUpdate, contexts);
653
+ newContexts = [ctx];
637
654
  }
638
655
 
639
- subsOnSetup.set(onUpdate, contexts);
640
- contexts.push(context);
641
- contexts.sort(
642
- (e1, e2) => e1.targetRefreshIntervalMs - e2.targetRefreshIntervalMs,
643
- );
656
+ subsOnSetup.set(onUpdate, newContexts);
657
+ newContexts.sort((c1, c2) => c1.refreshIntervalMs - c2.refreshIntervalMs);
644
658
 
645
659
  this.#latestSnapshot = freezeSnapshot({
646
660
  ...this.#latestSnapshot,
@@ -648,7 +662,7 @@ export class TimeSync implements TimeSyncApi {
648
662
  });
649
663
 
650
664
  this.#updateFastestInterval();
651
- return unsubscribe;
665
+ return ctx.unsubscribe;
652
666
  }
653
667
 
654
668
  getStateSnapshot(): Snapshot {
@@ -668,6 +682,7 @@ export class TimeSync implements TimeSyncApi {
668
682
  // We swap the map out so that the unsubscribe callbacks can detect
669
683
  // whether their functionality is still relevant
670
684
  this.#subscriptions = new Map();
685
+
671
686
  this.#latestSnapshot = freezeSnapshot({
672
687
  ...this.#latestSnapshot,
673
688
  subscriberCount: 0,
package/src/utilities.ts DELETED
@@ -1 +0,0 @@
1
- export type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };