pending-task-kit 0.1.0 → 0.2.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/dist/react.js CHANGED
@@ -90,8 +90,7 @@ function parseTasksFromStorageValue(value) {
90
90
  }
91
91
  }
92
92
  function readPersistedTasks(storageKey) {
93
- if (typeof localStorage === "undefined") return [];
94
- return parseTasksFromStorageValue(localStorage.getItem(storageKey));
93
+ return parseTasksFromStorageValue(safeGetItem(storageKey));
95
94
  }
96
95
 
97
96
  // src/result-relay.ts
@@ -148,6 +147,10 @@ var PendingTaskPoller = class {
148
147
  this.isChecking = false;
149
148
  this.pendingForce = false;
150
149
  this.stopped = false;
150
+ /** This tab's own most recently reported leadership status, for `onLeaderChange` — tracked
151
+ * here (rather than derived fresh each time from `fence`) purely so that callback fires only
152
+ * on an actual flip, not once per tick it happens to still hold/still lack leadership. */
153
+ this.isLeaderTab = false;
151
154
  /** Task ids that already got their one `finalCheckOnExpiry` attempt, so a repeatedly-failing
152
155
  * final check doesn't get retried every tick. Reset on process restart — worst case that
153
156
  * costs one extra check, never an infinite retry loop.
@@ -177,7 +180,9 @@ var PendingTaskPoller = class {
177
180
  onResult: options.onResult,
178
181
  onCheckError: options.onCheckError,
179
182
  claimResultOnce: options.claimResultOnce,
180
- acceptRelayedResult: options.acceptRelayedResult
183
+ acceptRelayedResult: options.acceptRelayedResult,
184
+ onLeaderChange: options.onLeaderChange,
185
+ onTick: options.onTick
181
186
  };
182
187
  this.ownerId = generatePollOwnerId();
183
188
  this.pollLease = createPollLeaseClaimer(this.options.pollLeaseKey, this.options.pollLeaseTtlMs);
@@ -220,6 +225,7 @@ var PendingTaskPoller = class {
220
225
  stop() {
221
226
  this.stopped = true;
222
227
  this.pendingForce = false;
228
+ this.inFlightAbortController?.abort();
223
229
  if (this.intervalId !== void 0) {
224
230
  clearInterval(this.intervalId);
225
231
  this.intervalId = void 0;
@@ -230,6 +236,7 @@ var PendingTaskPoller = class {
230
236
  }
231
237
  if (this.options.crossTabPollLeaderElection) {
232
238
  void this.releaseLeadership().catch(() => void 0);
239
+ this.setLeaderStatus(false);
233
240
  }
234
241
  }
235
242
  /** Re-check every tracked task right now, bypassing each task's poll interval (e.g. on tab focus). */
@@ -239,6 +246,25 @@ var PendingTaskPoller = class {
239
246
  claimLeadership() {
240
247
  return withTabLock(this.options.pollLeaseKey, () => this.pollLease.claim(this.ownerId));
241
248
  }
249
+ /** Updates `isLeaderTab` and fires `onLeaderChange`, but only on an actual flip — see that
250
+ * option's doc comment, including the "only ever called when `crossTabPollLeaderElection` is
251
+ * on" part, which this enforces itself rather than relying on every call site to remember to
252
+ * guard it (a call site that forgot would otherwise be a real, undetected bug — the whole
253
+ * reason this guard lives here instead of at each of this method's several call sites). Safe
254
+ * to call redundantly (e.g. after every successful claim/reconfirm in a tick, not just the
255
+ * first) since a no-op call is just an equality check. */
256
+ setLeaderStatus(isLeader) {
257
+ if (!this.options.crossTabPollLeaderElection) return;
258
+ if (this.isLeaderTab === isLeader) return;
259
+ this.isLeaderTab = isLeader;
260
+ try {
261
+ this.options.onLeaderChange?.(isLeader);
262
+ } catch (error) {
263
+ queueMicrotask(() => {
264
+ throw error;
265
+ });
266
+ }
267
+ }
242
268
  /**
243
269
  * Re-confirms that poll leadership is still this tab's — and still the *same continuous
244
270
  * tenure* as when `fence` was captured, not just "is nobody else currently holding it" (a
@@ -256,11 +282,11 @@ var PendingTaskPoller = class {
256
282
  * changed hands in between. See `PollLeaseClaimResult`.
257
283
  */
258
284
  async reconfirmLeadership(task, expired, fence) {
259
- if (!this.options.crossTabPollLeaderElection) return fence;
260
285
  if (this.stopped) {
261
286
  if (expired) this.finalCheckAttempted.delete(task.id);
262
287
  return false;
263
288
  }
289
+ if (!this.options.crossTabPollLeaderElection) return fence;
264
290
  let result;
265
291
  try {
266
292
  result = await this.claimLeadership();
@@ -394,8 +420,8 @@ var PendingTaskPoller = class {
394
420
  if (tasks.length === 0) return;
395
421
  this.isChecking = true;
396
422
  const batch = /* @__PURE__ */ new Map();
423
+ const now = Date.now();
397
424
  try {
398
- const now = Date.now();
399
425
  let fence;
400
426
  let leadershipLost = false;
401
427
  for (const task of tasks) {
@@ -408,7 +434,19 @@ var PendingTaskPoller = class {
408
434
  }
409
435
  continue;
410
436
  }
411
- const interval = handler.pollIntervalMs ?? this.options.defaultPollIntervalMs;
437
+ const failureCount = task.failureCount ?? 0;
438
+ let backoffMs;
439
+ if (failureCount > 0) {
440
+ try {
441
+ backoffMs = handler.retryBackoffMs?.(failureCount);
442
+ } catch (retryBackoffMsError) {
443
+ queueMicrotask(() => {
444
+ throw retryBackoffMsError;
445
+ });
446
+ backoffMs = void 0;
447
+ }
448
+ }
449
+ const interval = backoffMs !== void 0 && Number.isFinite(backoffMs) && backoffMs > 0 ? backoffMs : handler.pollIntervalMs ?? this.options.defaultPollIntervalMs;
412
450
  const lastChecked = task.lastCheckedAt ?? task.startedAt;
413
451
  const due = force || now - lastChecked >= interval;
414
452
  const finalAttemptDone = this.finalCheckAttempted.has(task.id);
@@ -434,25 +472,31 @@ var PendingTaskPoller = class {
434
472
  if (expired) this.finalCheckAttempted.delete(task.id);
435
473
  throw error;
436
474
  }
437
- if (!claimed.leader) {
475
+ if (this.stopped || !claimed.leader) {
438
476
  leadershipLost = true;
477
+ this.setLeaderStatus(false);
439
478
  if (expired) this.finalCheckAttempted.delete(task.id);
440
479
  continue;
441
480
  }
442
481
  fence = claimed.fence;
482
+ this.setLeaderStatus(true);
443
483
  }
444
484
  }
445
485
  let result;
486
+ const abortController = new AbortController();
487
+ this.inFlightAbortController = abortController;
446
488
  try {
447
- result = await handler.check(task);
489
+ result = await handler.check(task, abortController.signal);
448
490
  } catch (error) {
449
491
  const reconfirmedOnError = await this.reconfirmLeadership(task, expired, fence);
450
492
  if (reconfirmedOnError === false) {
451
493
  leadershipLost = true;
452
494
  fence = void 0;
495
+ this.setLeaderStatus(false);
453
496
  continue;
454
497
  }
455
498
  fence = reconfirmedOnError;
499
+ this.setLeaderStatus(true);
456
500
  let intercepted;
457
501
  try {
458
502
  intercepted = this.options.onCheckError?.(error, task);
@@ -472,21 +516,25 @@ var PendingTaskPoller = class {
472
516
  continue;
473
517
  }
474
518
  const latest = this.getLatestTask(task);
475
- const failureCount = (latest.failureCount ?? 0) + 1;
476
- if (failureCount >= this.options.maxFailureCount) {
519
+ const failureCount2 = (latest.failureCount ?? 0) + 1;
520
+ if (failureCount2 >= this.options.maxFailureCount) {
477
521
  await this.finalize(task, { status: "error", data: describeError(error) }, handler, batch);
478
522
  } else {
479
- batch.set(task.id, { lastCheckedAt: now, failureCount });
523
+ batch.set(task.id, { lastCheckedAt: now, failureCount: failureCount2 });
480
524
  }
481
525
  continue;
526
+ } finally {
527
+ if (this.inFlightAbortController === abortController) this.inFlightAbortController = void 0;
482
528
  }
483
529
  const reconfirmedOnSuccess = await this.reconfirmLeadership(task, expired, fence);
484
530
  if (reconfirmedOnSuccess === false) {
485
531
  leadershipLost = true;
486
532
  fence = void 0;
533
+ this.setLeaderStatus(false);
487
534
  continue;
488
535
  }
489
536
  fence = reconfirmedOnSuccess;
537
+ this.setLeaderStatus(true);
490
538
  if (result.status === "pending") {
491
539
  const latest = this.getLatestTask(task);
492
540
  batch.set(task.id, {
@@ -502,8 +550,23 @@ var PendingTaskPoller = class {
502
550
  await this.finalize(task, { status: result.status, data: result.data }, handler, batch);
503
551
  }
504
552
  } finally {
505
- this.flushBatch(batch);
506
553
  this.isChecking = false;
554
+ try {
555
+ this.flushBatch(batch);
556
+ } catch (error) {
557
+ queueMicrotask(() => {
558
+ throw error;
559
+ });
560
+ }
561
+ if (this.options.onTick) {
562
+ try {
563
+ this.options.onTick({ durationMs: Date.now() - now, taskCount: tasks.length });
564
+ } catch (error) {
565
+ queueMicrotask(() => {
566
+ throw error;
567
+ });
568
+ }
569
+ }
507
570
  if (this.pendingForce && !this.stopped) {
508
571
  this.pendingForce = false;
509
572
  this.runTickSafely(true);
@@ -524,7 +587,9 @@ function usePendingTaskPoller(options) {
524
587
  onResult: (detail) => optionsRef.current.onResult?.(detail),
525
588
  onCheckError: (error, task) => optionsRef.current.onCheckError?.(error, task),
526
589
  claimResultOnce: (task) => optionsRef.current.claimResultOnce ? optionsRef.current.claimResultOnce(task) : true,
527
- acceptRelayedResult: (detail) => optionsRef.current.acceptRelayedResult?.(detail) ?? true
590
+ acceptRelayedResult: (detail) => optionsRef.current.acceptRelayedResult?.(detail) ?? true,
591
+ onLeaderChange: (isLeader) => optionsRef.current.onLeaderChange?.(isLeader),
592
+ onTick: (info) => optionsRef.current.onTick?.(info)
528
593
  });
529
594
  poller.start();
530
595
  const handleVisibility = () => {