@internetarchive/ia-book-actions 1.4.4-alpha13 → 1.4.4-alpha14

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/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.4-alpha13",
10
+ "version": "1.4.4-alpha14",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -97,24 +97,29 @@ export default class IABookActions extends LitElement {
97
97
  this.loanRenewInProgress = false;
98
98
 
99
99
  /**
100
- * True while a renewal in flight is recovering from a loan that
101
- * genuinely lapsed (set by autoRenewExpiredLoan), as opposed to a
102
- * routine pre-expiry top-up. Only the recovery case needs BookReader
103
- * re-initialized once the renewal is confirmed — see
100
+ * True while a create_token error modal is up and the token poller is
101
+ * retrying in the background. Cleared by startLoanTokenPoller()'s
102
+ * successCallback. See handleLendingActionError.
103
+ */
104
+ this.awaitingTokenRecovery = false;
105
+
106
+ /** Bounds how many times handleLendingActionError restarts the poller
107
+ * for one outage, so a genuinely-lost loan can't retry forever. */
108
+ this.tokenRecoveryAttempts = 0;
109
+ this.maxTokenRecoveryAttempts = 3;
110
+
111
+ /**
112
+ * True while recovering from a loan that genuinely lapsed (set by
113
+ * autoRenewExpiredLoan), as opposed to a routine pre-expiry top-up.
114
+ * Only the recovery case needs BookReader re-initialized — see
104
115
  * handleLoanAutoRenewed.
105
116
  */
106
117
  this.recoveringFromLoanExpiry = false;
107
118
 
108
119
  this.warningModalOpen = false;
109
120
 
110
- /**
111
- * Once the patron has seen & dismissed the informational warning modal,
112
- * don't nag them again with the same modal every timer tick — only a
113
- * real renewal (via interacting with the book) clears this, giving the
114
- * next pre-expiry window its own single warning.
115
- * @see loanRenewAttempt
116
- * @see handleLoanAutoRenewed
117
- */
121
+ /** Once dismissed, don't re-show the warning modal every tick — only
122
+ * a real renewal (handleLoanAutoRenewed) re-arms it. */
118
123
  this.warningModalDismissed = false;
119
124
 
120
125
  /**
@@ -148,9 +153,9 @@ export default class IABookActions extends LitElement {
148
153
  }
149
154
 
150
155
  disconnectedCallback() {
151
- // clear all intervals for lending system
156
+ super.disconnectedCallback();
152
157
  window?.IALendingIntervals?.clearAll();
153
-
158
+ this.tokenPoller?.disconnectedCallback();
154
159
  this.sentryCaptureMsg(sentryLogs.disconnectedCallback);
155
160
  this.disconnectResizeObserver();
156
161
  }
@@ -189,12 +194,8 @@ export default class IABookActions extends LitElement {
189
194
  }
190
195
 
191
196
  if (changed.has('loanRenewResult') && this.loanRenewResult.renewNow) {
192
- // Any renewal — not just the auto-return recovery path — pauses the
193
- // countdown for the renew_loan round-trip. Flag it here, centrally,
194
- // so every path (interaction during active reading, periodic
195
- // checker, auto-return recovery) gets the same "renewing" signal
196
- // instead of just looking frozen until handleLoanAutoRenewed() clears
197
- // this and restarts the timer.
197
+ // Pause the countdown for the renew_loan round-trip, for every
198
+ // renewal path — cleared by handleLoanAutoRenewed().
198
199
  this.loanRenewInProgress = true;
199
200
  window.IALendingIntervals.clearAll();
200
201
  }
@@ -269,13 +270,8 @@ export default class IABookActions extends LitElement {
269
270
  'browsingExpired' in this.lendingStatus &&
270
271
  this.lendingStatus?.browsingExpired;
271
272
  if (hasExpired) {
272
- // Per the ticket, auto-return must not visibly change anything — the
273
- // action bar stays exactly as it looked while reading. But that only
274
- // applies when there IS a rendered bar to preserve. On a fresh page
275
- // load of an already-expired loan nothing has been computed yet, so
276
- // skipping this would leave the patron staring at a blank bar with no
277
- // way to re-borrow. In that case compute the normal (Borrow) actions
278
- // before short-circuiting the rest of the lifecycle below.
273
+ // Auto-return must not visibly change the bar — unless nothing's
274
+ // rendered yet (fresh load of an already-expired loan).
279
275
  if (this.primaryActions?.length) {
280
276
  log('[IABookActions] browsing expired — leaving action bar untouched');
281
277
  } else {
@@ -305,18 +301,15 @@ export default class IABookActions extends LitElement {
305
301
 
306
302
  if (!this.applyLendingActions()) return;
307
303
 
308
- // Don't (re)start the countdown while a renewal is in flight — the
309
- // optimistic browsingExpired flip in autoRenewExpiredLoan() (which
310
- // triggers this same setupLendingToolbarActions() call) happens before
311
- // renewal is confirmed, so secondsLeftOnLoan could still be a stale
312
- // value from before the renewal. Wait for handleLoanAutoRenewed() to
313
- // confirm the real value and clear loanRenewInProgress before showing
314
- // a countdown again.
315
- if (this.borrowType === 'browsed' && !this.loanRenewInProgress) {
316
- // start timer for loan-renew
304
+ // Don't (re)start the countdown mid-renewal (stale secondsLeftOnLoan)
305
+ // or while recovering a create_token failure (timers stopped
306
+ // deliberately — see handleLendingActionError).
307
+ if (
308
+ this.borrowType === 'browsed' &&
309
+ !this.loanRenewInProgress &&
310
+ !this.awaitingTokenRecovery
311
+ ) {
317
312
  await this.startTimerCountdown();
318
-
319
- // start timer for browsed.
320
313
  await this.startBrowseTimer();
321
314
  }
322
315
 
@@ -326,15 +319,8 @@ export default class IABookActions extends LitElement {
326
319
  return;
327
320
  }
328
321
 
329
- /**
330
- * tokenPoller determines if user has loan token for this book
331
- * - if book is going to renew, need to wait until renew is completed:
332
- * loanRenewInProgress is set before renew_loan is even dispatched
333
- * (including the optimistic pre-confirmation window) and only
334
- * clears once the renewal is confirmed, so starting the poller here
335
- * mid-renewal would call create_token against the loan record
336
- * before it's actually renewed.
337
- */
322
+ // Wait until any in-flight renewal is confirmed before (re)starting the
323
+ // poller, so create_token isn't called against a not-yet-renewed loan.
338
324
  setTimeout(() => {
339
325
  if (
340
326
  !hasExpired &&
@@ -350,24 +336,9 @@ export default class IABookActions extends LitElement {
350
336
  }
351
337
 
352
338
  /**
353
- * Is this `BookReader:userAction` BookReader's own init-time jump to the
354
- * patron's last-read page, rather than something the patron just did?
355
- *
356
- * BookReader.updateFirstIndex() fires `userAction` unconditionally, and
357
- * init() reaches it via updateFromParams() — so a plain page refresh
358
- * looks identical to a page turn. Auto-renewing off that would silently
359
- * re-borrow a lapsed book with no patron input at all.
360
- *
361
- * BookReader.trigger() passes the BookReader instance as
362
- * `event.detail.props`, and init() holds `init.initComplete === false`
363
- * for exactly the span that contains that init-time jump, setting it to
364
- * true immediately before firing PostInit. So the flag is an exact,
365
- * timing-independent marker of "this came from init".
366
- *
367
- * Only an explicit `false` suppresses the event: a BookReader too old to
368
- * expose the flag, or a synthetic event with no detail, must still be
369
- * treated as a genuine interaction.
370
- *
339
+ * Is this BookReader's own init-time jump to the last-read page, rather
340
+ * than a real patron interaction (which fires the identical event)? Only
341
+ * an explicit `false` counts; a missing flag must be treated as real.
371
342
  * @param {Event} event - the BookReader:userAction event
372
343
  * @returns {boolean}
373
344
  */
@@ -393,14 +364,9 @@ export default class IABookActions extends LitElement {
393
364
  return;
394
365
  }
395
366
 
396
- // Capture before autoRenewExpiredLoan() runs — it synchronously flips
397
- // lendingStatus.browsingExpired to false as an optimistic UI update,
398
- // so checking the live property afterward would always see it as
399
- // false and wrongly let autoLoanRenewChecker() run too. That second
400
- // call would then overwrite loanRenewResult.renewNow (set true by
401
- // autoRenewExpiredLoan) back to false, since the loanTime cache entry
402
- // was already deleted by browseHasExpired() — silently killing the
403
- // in-flight renewal and leaving the countdown stuck.
367
+ // Capture before autoRenewExpiredLoan() runs — it optimistically
368
+ // flips browsingExpired to false, which would otherwise also let
369
+ // autoLoanRenewChecker() run and clobber the in-flight renewal.
404
370
  const wasExpired = this.lendingStatus.browsingExpired;
405
371
 
406
372
  log('[IABookActions] BookReader:userAction received', {
@@ -408,7 +374,6 @@ export default class IABookActions extends LitElement {
408
374
  browsingExpired: wasExpired,
409
375
  });
410
376
 
411
- // If the loan expired while the tab stayed visible, auto-renew on page turn
412
377
  if (wasExpired) {
413
378
  this.autoRenewExpiredLoan();
414
379
  }
@@ -418,11 +383,8 @@ export default class IABookActions extends LitElement {
418
383
  }
419
384
  });
420
385
 
421
- /**
422
- * auto-renew interval may stale when tab is in background,
423
- * so when user open that tab again,
424
- * the [visibilitychange] event can trigger timer to show relavent messages
425
- */
386
+ // A tab in the background can have its intervals throttled/paused, so
387
+ // re-check status when the patron comes back to it.
426
388
  document.addEventListener('visibilitychange', async () => {
427
389
  if (document.hidden) {
428
390
  log('[IABookActions] visibilitychange: tab backgrounded');
@@ -443,6 +405,10 @@ export default class IABookActions extends LitElement {
443
405
 
444
406
  if (this.borrowType !== 'browsed') return;
445
407
 
408
+ // Timers are deliberately stopped during create_token recovery
409
+ // (see handleLendingActionError) — leave them alone here.
410
+ if (this.awaitingTokenRecovery) return;
411
+
446
412
  if (this.lendingStatus.browsingExpired === false) {
447
413
  const loanTime = await this.localCache.get(
448
414
  `${this.identifier}-loanTime`
@@ -454,14 +420,9 @@ export default class IABookActions extends LitElement {
454
420
  if (secondsLeft >= this.timerExecutionSeconds) {
455
421
  this.loanStatusCheckInterval(Number(secondsLeft));
456
422
  } else {
457
- // Loan expired while user was away — try to silently renew.
458
- // Only stop the stale intervals here; this used to call
459
- // disconnectedCallback(), which was harmless back when the
460
- // following modal navigated away, but the reading session now
461
- // continues. Tearing down would drop the resize observer for
462
- // good (it's only re-added on firstUpdated / sharedObserver
463
- // change), leaving the bar unable to respond to viewport
464
- // changes, and would report a bogus disconnect to Sentry.
423
+ // Loan expired while away — silently renew. Only clear
424
+ // intervals; disconnectedCallback() would drop the resize
425
+ // observer for a session that's still continuing.
465
426
  window?.IALendingIntervals?.clearAll();
466
427
  this.autoRenewExpiredLoan();
467
428
  }
@@ -481,15 +442,11 @@ export default class IABookActions extends LitElement {
481
442
  * @param {Boolean} hasPageChanged
482
443
  */
483
444
  async autoLoanRenewChecker(hasPageChanged = false) {
484
- // Guards against re-entrancy the same way autoRenewExpiredLoan() does.
485
- // BookReader:userAction can fire several times in quick succession
486
- // (e.g. a single scroll gesture), and this method has no debouncing of
487
- // its own — without this guard, each event would spin up its own
488
- // LoanRenewHelper concurrently, and whichever's async localCache reads
489
- // resolve last would clobber this.loanRenewResult, potentially
490
- // re-triggering the whole renew_loan/create_token flow while a
491
- // renewal from an earlier event is already in flight or just landed.
492
- if (this.loanRenewInProgress) return;
445
+ // Re-entrancy guard against rapid BookReader:userAction events, and
446
+ // against restarting a renewal while still recovering the last one's
447
+ // create_token (loanRenewInProgress clears as soon as renew_loan
448
+ // itself succeeds, before that's known).
449
+ if (this.loanRenewInProgress || this.awaitingTokenRecovery) return;
493
450
 
494
451
  this.loanRenewHelper = new LoanRenewHelper(
495
452
  hasPageChanged,
@@ -503,20 +460,18 @@ export default class IABookActions extends LitElement {
503
460
  }
504
461
 
505
462
  /**
506
- * Attempt to automatically renew a browse loan that has expired.
507
- * Called on visibilitychange (user returns to tab) or on page turn when
508
- * the loan has expired but the book may still be available.
509
- *
510
- * Reuses the same renew_loan path as the automatic loan-renew checker.
511
- * On success: handleLoanAutoRenewed() resets the timer seamlessly.
512
- * On error: handleLendingActionError() shows showLoanUnavailableModal().
463
+ * Silently attempt to renew a browse loan that has expired, on
464
+ * visibilitychange or a page turn. handleLoanAutoRenewed()/
465
+ * handleLendingActionError() handle the outcome either way.
513
466
  */
514
467
  autoRenewExpiredLoan() {
515
- if (this.loanRenewInProgress) {
468
+ if (this.loanRenewInProgress || this.awaitingTokenRecovery) {
516
469
  log(
517
470
  '[IABookActions] autoRenewExpiredLoan: skipped, renewal already in progress',
518
471
  {
519
472
  identifier: this.identifier,
473
+ loanRenewInProgress: this.loanRenewInProgress,
474
+ awaitingTokenRecovery: this.awaitingTokenRecovery,
520
475
  }
521
476
  );
522
477
  return;
@@ -529,16 +484,8 @@ export default class IABookActions extends LitElement {
529
484
 
530
485
  this.modal?.closeModal();
531
486
 
532
- // Optimistically flip browsingExpired back to false so the action bar
533
- // stays red (patronIsReadingAction) while the renew_loan request is in
534
- // flight, instead of showing the blue "Borrow" state. Deliberately NOT
535
- // resetting secondsLeftOnLoan here — the renewal hasn't been confirmed
536
- // yet (it can take several seconds, or genuinely fail), so showing a
537
- // full hour before we know the outcome would be misleading. The real
538
- // value is set once handleLoanAutoRenewed() confirms success;
539
- // loanRenewInProgress (already true above) is the signal consumers
540
- // should use to show a "renewing" state instead of trusting the
541
- // countdown during this window.
487
+ // Optimistically flip browsingExpired so the bar stays in the reading
488
+ // state during the renew_loan round-trip, instead of showing "Borrow".
542
489
  this.lendingStatus = {
543
490
  ...this.lendingStatus,
544
491
  browsingExpired: false,
@@ -572,7 +519,6 @@ export default class IABookActions extends LitElement {
572
519
 
573
520
  log('[IABookActions] showWarningModal');
574
521
 
575
- // Capture texts and secondsLeft before resetting loanRenewResult
576
522
  const {
577
523
  texts: warningTexts,
578
524
  secondsLeft: rawSecondsLeft,
@@ -584,7 +530,6 @@ export default class IABookActions extends LitElement {
584
530
  secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
585
531
  }
586
532
 
587
- // clear modal and reset renew state
588
533
  this.modal.customModalContent = nothing;
589
534
  this.modal?.closeModal();
590
535
  this.loanRenewResult = { texts: '', renewNow: false };
@@ -666,9 +611,8 @@ export default class IABookActions extends LitElement {
666
611
  'This book has been returned due to inactivity.';
667
612
 
668
613
  this.modal?.closeModal();
669
- // We just closed whatever was on screen, including the warning modal —
670
- // release the re-entrancy guard so a later pre-expiry window can show
671
- // it again. Otherwise it stays latched for the life of the component.
614
+ // Release the warning-modal re-entrancy guard, or it stays latched for
615
+ // the life of the component.
672
616
  this.warningModalOpen = false;
673
617
 
674
618
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
@@ -776,31 +720,16 @@ export default class IABookActions extends LitElement {
776
720
  }
777
721
 
778
722
  /**
779
- * Execute after auto loan renewed is completed
780
- * - show success message
781
- * - then change the remaining time
782
- * - reset timer state
723
+ * Runs after a loan renewal completes (successfully or not): shows the
724
+ * outcome, updates the remaining time, and resets renewal-in-flight state.
783
725
  * @param {object} event
784
726
  */
785
727
  async handleLoanAutoRenewed({ detail }) {
786
728
  const activeLoan = detail?.data?.loan;
787
729
 
788
- /**
789
- * Defensive: ActionsHandler only dispatches this event for a CONFIRMED
790
- * renewal, but treat anything else as a failure rather than a success.
791
- *
792
- * A `renewal: false` loan reaching the success path below is actively
793
- * harmful: setBrowseTimeSession() never ran, so the loanTime read comes
794
- * back undefined, and the NaN fallback then invents a full fresh hour —
795
- * putting a brand-new countdown on screen alongside the "someone else
796
- * has now borrowed it" modal.
797
- *
798
- * Clearing loanRenewInProgress here matters just as much. Leaving it set
799
- * latches the component: autoLoanRenewChecker() and
800
- * autoRenewExpiredLoan() both no-op on it, and neither the countdown nor
801
- * the token poller will restart — the loan sits "active" forever with a
802
- * dead timer.
803
- */
730
+ // Treat anything but a confirmed renewal as a failure, or
731
+ // loanRenewInProgress stays latched forever (autoLoanRenewChecker/
732
+ // autoRenewExpiredLoan both no-op on it).
804
733
  if (!activeLoan?.renewal) {
805
734
  this.loanRenewInProgress = false;
806
735
  this.recoveringFromLoanExpiry = false;
@@ -816,7 +745,6 @@ export default class IABookActions extends LitElement {
816
745
  }
817
746
 
818
747
  if (this.loanRenewResult.renewNow) {
819
- // Now, let's reset loan duration & this.lendingStatus
820
748
  const loanTime = await this.localCache.get(`${this.identifier}-loanTime`);
821
749
 
822
750
  // number of seconds left in current loan
@@ -834,19 +762,8 @@ export default class IABookActions extends LitElement {
834
762
  });
835
763
 
836
764
  if (this.recoveringFromLoanExpiry) {
837
- // The loan genuinely lapsed, and renew_loan has now CONFIRMED it's
838
- // renewed server-side — only now is it safe to let BookReader
839
- // re-initialize (create_token was minting its access cookie off
840
- // the loan's expiry; doing this before confirmation risked reading
841
- // the still-expired record). loanRenewInProgress already kept the
842
- // token poller from restarting early, so it'll pick up fresh here
843
- // once lendingStatus below re-triggers setupLendingToolbarActions().
844
- // recoveringFromLoanExpiry itself is consumed/reset there, not
845
- // here — this ONLY applies to an actual recovery, not a routine
846
- // renewal (re-running br.init() on every routine top-up would be
847
- // needlessly disruptive). create_token's own retry logic (see
848
- // LoanTokenPoller) handles the datanode write-propagation race
849
- // for both recovery and routine renewals.
765
+ // Only now is it safe to re-initialize BookReader — not for a
766
+ // routine top-up, where that would be disruptive.
850
767
  this.postInitComplete = false;
851
768
  }
852
769
 
@@ -870,46 +787,34 @@ export default class IABookActions extends LitElement {
870
787
  this.loanRenewInProgress = false;
871
788
  }
872
789
 
873
- /**
874
- * start timer countdown interval after loan auto-renewed
875
- *
876
- * @memberof IABookActions
877
- */
790
+ /** Start the countdown interval; ticks read the live secondsLeftOnLoan
791
+ * each time rather than a value frozen at start, so drift can't compound
792
+ * across ticks (see loanStatusCheckInterval). */
878
793
  async startTimerCountdown() {
879
794
  window?.IALendingIntervals?.clearTimerCountdown();
880
-
881
- const secondsLeft = Number(this.lendingStatus.secondsLeftOnLoan);
882
795
  this.timeWhenTimerStart = new Date();
883
796
 
884
797
  window.IALendingIntervals.timerCountdown = setInterval(async () => {
885
- // interval execution block
886
- await this.loanStatusCheckInterval(secondsLeft);
798
+ await this.loanStatusCheckInterval(
799
+ Number(this.lendingStatus.secondsLeftOnLoan)
800
+ );
887
801
  }, this.timerExecutionSeconds * 1000);
888
802
  }
889
803
 
890
804
  /**
891
- * setInterval execution function do following things
892
- * - if setInterval timer gone off, let's resync
893
- * - loan-renew attempt to renew the loan OR show relavant message
894
- * - if loan has expired, clear interval timers
895
- *
805
+ * Runs on every countdown tick: resyncs against wall-clock time, attempts
806
+ * a renewal near expiry, and clears the timers once the loan expires.
896
807
  * @param {Number} secondsLeftOnLoan
897
808
  */
898
809
  async loanStatusCheckInterval(secondsLeftOnLoan) {
899
810
  let secondsLeft = secondsLeftOnLoan;
900
811
  secondsLeft -= this.timerExecutionSeconds;
901
- secondsLeft = Math.round(secondsLeft); // round number
812
+ secondsLeft = Math.round(secondsLeft);
902
813
 
903
- // re-sync timer if gone off because of background window
904
- // side effect: updates this.lendingStatus & kicks off lifecycle,
905
- // if really updated, escape from here
906
814
  const resyncd = this.reSyncTimerIfGoneOff(secondsLeft);
907
-
908
815
  if (resyncd.hasSynced) {
909
816
  secondsLeft = resyncd.whatShouldLeft;
910
- log('[IABookActions] timer: timer re-synced', {
911
- secondsLeft,
912
- });
817
+ log('[IABookActions] timer: timer re-synced', { secondsLeft });
913
818
  }
914
819
 
915
820
  log('[IABookActions] timer', {
@@ -917,19 +822,20 @@ export default class IABookActions extends LitElement {
917
822
  whatIsleft: secondsLeft,
918
823
  });
919
824
 
920
- /**
921
- * execute from last 10th minutes to 0th minute
922
- * - 10th - to check if user has viewed
923
- * - till 0th - to show warning msg with remaining time to auto expired
924
- * @see IABookActions::bindLoanRenewEvents
925
- */
825
+ // Re-anchor every tick so drift from wall-clock time can't compound
826
+ // across ticks that don't happen to trigger a resync above.
827
+ this.timeWhenTimerStart = new Date();
828
+ this.lendingStatus = { ...this.lendingStatus, secondsLeftOnLoan: secondsLeft };
829
+
830
+ // 10 minutes out: start checking for a renewal. 0: show the "about to
831
+ // auto-return" warning. @see IABookActions::bindLoanRenewEvents
926
832
  if (secondsLeft <= this.loanRenewTimeConfig.loanRenewAtLast) {
927
833
  await this.loanRenewAttempt(secondsLeft);
928
834
  }
929
835
 
930
- // clear interval in secondsLeft if less
931
836
  if (secondsLeft <= this.timerExecutionSeconds) {
932
- this.disconnectedCallback();
837
+ window?.IALendingIntervals?.clearAll();
838
+ this.tokenPoller?.disconnectedCallback();
933
839
  this.sentryCaptureMsg(sentryLogs.clearOneHourTimer);
934
840
  }
935
841
  }
@@ -984,13 +890,8 @@ export default class IABookActions extends LitElement {
984
890
  */
985
891
  async loanRenewAttempt(secondsLeft) {
986
892
  let loanSecondsLeft = secondsLeft;
987
- /**
988
- * auto-renew is not possible in last seconds (let say 50 second) because,
989
- * 1. less time to execute ajax call
990
- * 2. less time to write loan on datanodes
991
- * 3. less time to load images by create_token api
992
- * so if seconds left is < 50, just expire the loan
993
- */
893
+ // Under 50s left there isn't enough time for the renew_loan round-trip
894
+ // and create_token to load images, so just expire the loan.
994
895
  if (loanSecondsLeft < 50) {
995
896
  log('[IABookActions] loanRenewAttempt: < 50s left, expiring loan');
996
897
  await this.browseHasExpired();
@@ -999,17 +900,13 @@ export default class IABookActions extends LitElement {
999
900
 
1000
901
  await this.autoLoanRenewChecker(false);
1001
902
 
1002
- // show warning modal with remaining time to auto returned it.
1003
- // once the patron has dismissed it, don't re-show it on every subsequent
1004
- // tick — only an actual renewal (see handleLoanAutoRenewed) re-arms it.
903
+ // Once dismissed, don't re-show the warning on every subsequent tick —
904
+ // only an actual renewal (handleLoanAutoRenewed) re-arms it.
1005
905
  if (
1006
906
  this.loanRenewResult.renewNow === false &&
1007
907
  !this.warningModalDismissed
1008
908
  ) {
1009
- /**
1010
- * so compensate for the 50 second buffer to handle above race conditions
1011
- * let's reduce 1 min from warning texts and early return the book when 1 min left.
1012
- */
909
+ // Compensate for the 50s buffer above by warning a minute early.
1013
910
  loanSecondsLeft -= 60;
1014
911
  this.loanRenewResult.secondsLeft = loanSecondsLeft;
1015
912
 
@@ -1022,27 +919,49 @@ export default class IABookActions extends LitElement {
1022
919
  * @see LoanTokenPoller
1023
920
  */
1024
921
  startLoanTokenPoller() {
1025
- const successCallback = () => {
922
+ const successCallback = async () => {
1026
923
  if (!this.postInitComplete) {
1027
924
  this.lendingBarPostInit();
1028
925
  }
1029
926
  this.postInitComplete = true;
927
+
928
+ if (this.awaitingTokenRecovery) {
929
+ log(
930
+ '[IABookActions] create_token recovered — closing error modal, resuming timer',
931
+ { identifier: this.identifier }
932
+ );
933
+ this.awaitingTokenRecovery = false;
934
+ this.tokenRecoveryAttempts = 0;
935
+ this.modal?.closeModal();
936
+ this.modal.removeAttribute('id');
937
+ this.modal.customModalContent = nothing;
938
+
939
+ // Recompute the true remaining time from the cache — not however
940
+ // long the outage lasted — before resuming the stopped timers.
941
+ const loanTime = await this.localCache.get(
942
+ `${this.identifier}-loanTime`
943
+ );
944
+ const rawSecondsLeft = Math.round((loanTime - new Date()) / 1000);
945
+ const secondsLeft =
946
+ Number.isFinite(rawSecondsLeft) && rawSecondsLeft > 0
947
+ ? rawSecondsLeft
948
+ : this.loanRenewTimeConfig.loanTotalTime;
949
+ this.lendingStatus = {
950
+ ...this.lendingStatus,
951
+ secondsLeftOnLoan: secondsLeft,
952
+ };
953
+ await this.startTimerCountdown();
954
+ await this.startBrowseTimer();
955
+ }
1030
956
  };
1031
957
  const errorCallback = eventObj => {
1032
958
  this.handleLendingActionError(eventObj);
1033
959
  };
1034
960
 
1035
- /**
1036
- * LoanTokenPoller is a class that polls the loan token
1037
- * it takes 5 params
1038
- * 1. this.identifier
1039
- * 2. this.borrowType
1040
- * 3. successCallback - it used to
1041
- * - disptach lendingFlow::PostInit event
1042
- * - initialize bookreader using br.init()
1043
- * 4. errorCallback
1044
- * 5. tokenPollerDelay
1045
- */
961
+ // Tear down any previous poller first — otherwise its own pending
962
+ // internal retry timeout (see LoanTokenPoller.handleTokenError) is
963
+ // never cancelled and can fire later against stale state.
964
+ this.tokenPoller?.disconnectedCallback();
1046
965
  this.tokenPoller = new LoanTokenPoller(
1047
966
  this.identifier,
1048
967
  this.borrowType,
@@ -1062,13 +981,12 @@ export default class IABookActions extends LitElement {
1062
981
  }
1063
982
 
1064
983
  /**
1065
- * handle lending errors occure during different operation like
1066
- * browse book, borrow book, borrowed book token etc...
1067
- *
984
+ * Handles lending errors from any action (browse_book, borrow_book,
985
+ * create_token, renew_loan, etc).
1068
986
  * @event IABookActions#lendingActionError
1069
- * @param {Object} event - The employee who is responsible for the project
1070
- * @param {string} event.detail.action - action when error occurred like 'browseBook', 'borrowBook'
1071
- * @param {string} event.detail.data.error - error message
987
+ * @param {Object} event
988
+ * @param {string} event.detail.action
989
+ * @param {string} event.detail.data.error
1072
990
  */
1073
991
  handleLendingActionError(event) {
1074
992
  this.disableActionGroup = false;
@@ -1087,23 +1005,12 @@ export default class IABookActions extends LitElement {
1087
1005
  });
1088
1006
 
1089
1007
  if (action === 'create_token') {
1090
- // A create_token hiccup only affects BookReader's page-image access
1091
- // token — it says nothing about how much time is left on the loan
1092
- // itself, so it must not stop the reading countdown. Only clear the
1093
- // token poller so it can be retried.
1008
+ // A create_token hiccup says nothing about how much time is left on
1009
+ // the loan, so it must not stop the countdown by itself.
1094
1010
  window?.IALendingIntervals?.clearTokenPoller();
1095
1011
 
1096
- /**
1097
- * Only the INITIAL token matters to the patron: without it BookReader
1098
- * can't be initialized at all, so the book genuinely won't open and
1099
- * they need to know. A routine interval refresh failing is a
1100
- * different thing — the patron is already reading, the pages they
1101
- * have are still served, and LoanTokenPoller will try again on the
1102
- * next tick. Interrupting a mid-read session with a blocking modal
1103
- * over a transient blip (the recurring 405s QA is seeing, say) is
1104
- * worse than the blip. This is why the original code suppressed
1105
- * create_token errors outright; keep that for the interval case.
1106
- */
1012
+ // Only the INITIAL token matters enough to interrupt the patron —
1013
+ // a routine interval refresh stays silent and just retries next tick.
1107
1014
  if (!isInitial) {
1108
1015
  log(
1109
1016
  '[IABookActions] create_token failed on interval refresh — staying silent, poller will retry'
@@ -1111,18 +1018,26 @@ export default class IABookActions extends LitElement {
1111
1018
  return;
1112
1019
  }
1113
1020
 
1114
- // The loan itself is fine — just the token failed — but the action
1115
- // bar must reflect that the book isn't openable right now rather
1116
- // than silently doing nothing.
1117
- this.lendingStatus = {
1118
- ...this.lendingStatus,
1119
- user_has_browsed: false,
1120
- available_to_browse: true,
1121
- };
1021
+ // The loan itself is still active (do_renew succeeded) — don't touch
1022
+ // lendingStatus, which would collapse borrowType to null and strand
1023
+ // the poller from ever restarting. Still nothing to read right now
1024
+ // though, so stop the countdown timers too; startLoanTokenPoller()'s
1025
+ // successCallback resumes them once access recovers.
1026
+ this.awaitingTokenRecovery = true;
1027
+ window?.IALendingIntervals?.clearTimerCountdown();
1028
+ window?.IALendingIntervals?.clearBrowseExpireTimeout();
1029
+
1030
+ // Bounded: a genuinely-lost (not just slow-to-propagate) loan would
1031
+ // otherwise retry this forever.
1032
+ this.tokenRecoveryAttempts += 1;
1033
+ if (this.tokenRecoveryAttempts <= this.maxTokenRecoveryAttempts) {
1034
+ this.startLoanTokenPoller();
1035
+ } else {
1036
+ log('[IABookActions] create_token recovery attempts exhausted', {
1037
+ identifier: this.identifier,
1038
+ });
1039
+ }
1122
1040
 
1123
- // LoanTokenPoller has already exhausted its retries for the
1124
- // transient datanode-propagation race by the time we get here (or
1125
- // the error was never that), so this is a genuine failure.
1126
1041
  // showErrorModal has dedicated create_token messaging (refresh
1127
1042
  // button + support email).
1128
1043
  if (errorMsg) this.showErrorModal(errorMsg, action);
@@ -1131,11 +1046,8 @@ export default class IABookActions extends LitElement {
1131
1046
  this.loanRenewInProgress = false;
1132
1047
  this.recoveringFromLoanExpiry = false;
1133
1048
 
1134
- // The loan was NOT actually renewed — reflect that immediately
1135
- // (show Borrow, and clear the stale timer value) instead of leaving
1136
- // the action bar showing "Return now" with a frozen countdown from
1137
- // before the failed attempt, which falsely implies the book is
1138
- // still actively being read.
1049
+ // The loan was NOT renewed — reflect that immediately (show Borrow,
1050
+ // clear the stale timer) rather than leaving a frozen "Return now".
1139
1051
  this.lendingStatus = {
1140
1052
  ...this.lendingStatus,
1141
1053
  user_has_browsed: false,
@@ -1143,11 +1055,8 @@ export default class IABookActions extends LitElement {
1143
1055
  secondsLeftOnLoan: 0,
1144
1056
  };
1145
1057
 
1146
- // Show the real error and refresh the page on dismissal (the
1147
- // modal's Okay button) so the client picks up whatever the server
1148
- // now authoritatively considers true — matching on "not available"
1149
- // to guess the reason was fragile since the server can return any
1150
- // message (e.g. a lending limit).
1058
+ // Refresh the page on dismissal so the client picks up whatever the
1059
+ // server now authoritatively considers true (any error message).
1151
1060
  this.showLoanUnavailableModal(errorMsg);
1152
1061
  } else {
1153
1062
  // Every other action failure genuinely affects loan state.
@@ -755,6 +755,35 @@ describe('autoRenewExpiredLoan', () => {
755
755
  expect(el.loanRenewResult.renewNow).to.be.true;
756
756
  });
757
757
 
758
+ it('does not start a new renewal while awaiting create_token recovery from the last one', async () => {
759
+ // loanRenewInProgress clears as soon as renew_loan itself succeeds
760
+ // (see handleLoanAutoRenewed), before create_token is known to work.
761
+ // A visibilitychange/userAction landing in that window must not kick
762
+ // off ANOTHER renewal — that repeats the do_renew() delete+create
763
+ // write for no reason. See WEBDEV-8322 production log, 2026-09-28.
764
+ const el = await fixture(
765
+ container({
766
+ userid: '@user1',
767
+ identifier: 'foobar',
768
+ lendingStatus: {
769
+ user_has_browsed: true,
770
+ browsingExpired: true,
771
+ },
772
+ })
773
+ );
774
+ await el.updateComplete;
775
+
776
+ el.loanRenewInProgress = false;
777
+ el.awaitingTokenRecovery = true;
778
+
779
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
780
+ el.autoRenewExpiredLoan();
781
+
782
+ expect(spy.callCount).to.equal(1);
783
+ expect(el.loanRenewInProgress).to.be.false;
784
+ expect(el.loanRenewResult.renewNow).to.be.false;
785
+ });
786
+
758
787
  it('clears loanRenewInProgress after a successful loanAutoRenewed event', async () => {
759
788
  const el = await fixture(
760
789
  container({
@@ -1135,7 +1164,49 @@ describe('handleLendingActionError - create_token failures must not stop the cou
1135
1164
 
1136
1165
  expect(showErrorModalSpy.calledOnceWith(errorMsg, 'create_token')).to.be
1137
1166
  .true;
1138
- expect(el.lendingStatus.user_has_browsed).to.be.false;
1167
+ // The loan itself is still active (do_renew succeeded) — flipping this
1168
+ // to false used to collapse borrowType to null in
1169
+ // getCurrentLendingActions(), which made setupLendingToolbarActions()
1170
+ // bail out before ever restarting the token poller, silently
1171
+ // stranding the patron until the countdown force-returned the book.
1172
+ // See WEBDEV-8322 production log, 2026-09-28.
1173
+ expect(el.lendingStatus.user_has_browsed).to.be.true;
1174
+ expect(el.awaitingTokenRecovery).to.be.true;
1175
+ });
1176
+
1177
+ it('stops the countdown/expiry timers on a failed INITIAL create_token — nothing to read yet', async () => {
1178
+ // Unlike the interval-refresh case above (pages already loaded, keep
1179
+ // ticking), an initial create_token failure means BookReader never
1180
+ // got page images — there's nothing to read. Letting the countdown
1181
+ // keep running just re-syncs on every single tick against a broken
1182
+ // session (see WEBDEV-8322 QA log, 2026-09-28) until the loan
1183
+ // eventually force-expires. Stop both timers instead; they resume
1184
+ // once startLoanTokenPoller()'s successCallback confirms recovery.
1185
+ const el = await fixture(
1186
+ container({
1187
+ userid: '@user1',
1188
+ identifier: 'foobar',
1189
+ lendingStatus: {
1190
+ user_has_browsed: true,
1191
+ browsingExpired: false,
1192
+ secondsLeftOnLoan: 100,
1193
+ },
1194
+ })
1195
+ );
1196
+ await el.updateComplete;
1197
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
1198
+ expect(window.IALendingIntervals.browseExpireTimeout).to.not.equal(0);
1199
+
1200
+ el.handleLendingActionError({
1201
+ detail: {
1202
+ action: 'create_token',
1203
+ isInitial: true,
1204
+ data: { error: 'loan token not found. please try again later.' },
1205
+ },
1206
+ });
1207
+
1208
+ expect(window.IALendingIntervals.timerCountdown).to.equal(0);
1209
+ expect(window.IALendingIntervals.browseExpireTimeout).to.equal(0);
1139
1210
  });
1140
1211
 
1141
1212
  it('still clears everything on a renew_loan failure', async () => {
@@ -1538,6 +1609,104 @@ describe('WEBDEV-8322 review fixes', () => {
1538
1609
  await el.updateComplete;
1539
1610
 
1540
1611
  expect(spy.calledOnce).to.be.true;
1541
- expect(el.lendingStatus.user_has_browsed).to.be.false;
1612
+ // Still reading — see the identical assertion above for why this must
1613
+ // stay true (WEBDEV-8322 production log, 2026-09-28).
1614
+ expect(el.lendingStatus.user_has_browsed).to.be.true;
1615
+ expect(el.awaitingTokenRecovery).to.be.true;
1616
+ });
1617
+
1618
+ it('does not resume the countdown via visibilitychange while awaiting create_token recovery', async () => {
1619
+ // Regression: visibilitychange's resync path didn't check
1620
+ // awaitingTokenRecovery, so backgrounding/foregrounding the tab during
1621
+ // a create_token outage could resume the timers that
1622
+ // handleLendingActionError had deliberately stopped, and auto-return a
1623
+ // loan the server still considers valid.
1624
+ const el = await fixture(
1625
+ container({
1626
+ userid: '@user1',
1627
+ identifier: 'foobar',
1628
+ lendingStatus: {
1629
+ user_has_browsed: true,
1630
+ browsingExpired: false,
1631
+ secondsLeftOnLoan: 300,
1632
+ },
1633
+ })
1634
+ );
1635
+ await el.updateComplete;
1636
+ el.awaitingTokenRecovery = true;
1637
+
1638
+ const spy = Sinon.spy(el, 'loanStatusCheckInterval');
1639
+ Object.defineProperty(document, 'hidden', {
1640
+ value: false,
1641
+ configurable: true,
1642
+ });
1643
+ document.dispatchEvent(new Event('visibilitychange'));
1644
+ await aTimeout(100);
1645
+
1646
+ expect(spy.called).to.be.false;
1647
+ });
1648
+
1649
+ it('tears down the previous poller before starting a new one', async () => {
1650
+ // Regression: startLoanTokenPoller() always replaced this.tokenPoller
1651
+ // without cancelling the old instance's pending internal retry timer,
1652
+ // which could later fire against stale state.
1653
+ const el = await fixture(
1654
+ container({
1655
+ userid: '@user1',
1656
+ identifier: 'poller-teardown',
1657
+ lendingStatus: {
1658
+ is_lendable: true,
1659
+ available_to_browse: true,
1660
+ user_has_browsed: true,
1661
+ browsingExpired: false,
1662
+ secondsLeftOnLoan: 300,
1663
+ },
1664
+ })
1665
+ );
1666
+ await el.updateComplete;
1667
+
1668
+ el.startLoanTokenPoller();
1669
+ const firstPoller = el.tokenPoller;
1670
+ const disconnectSpy = Sinon.spy(firstPoller, 'disconnectedCallback');
1671
+
1672
+ el.startLoanTokenPoller();
1673
+
1674
+ expect(disconnectSpy.calledOnce).to.be.true;
1675
+ expect(el.tokenPoller).to.not.equal(firstPoller);
1676
+ });
1677
+
1678
+ it('stops restarting the poller after maxTokenRecoveryAttempts consecutive create_token failures', async () => {
1679
+ // Regression: an outer restart loop with no cap meant a genuinely-lost
1680
+ // (not just slow-to-propagate) loan retried forever.
1681
+ const el = await fixture(
1682
+ container({
1683
+ userid: '@user1',
1684
+ identifier: 'token-exhausted',
1685
+ lendingStatus: {
1686
+ is_lendable: true,
1687
+ available_to_browse: true,
1688
+ user_has_browsed: true,
1689
+ browsingExpired: false,
1690
+ secondsLeftOnLoan: 300,
1691
+ },
1692
+ })
1693
+ );
1694
+ await el.updateComplete;
1695
+
1696
+ const startSpy = Sinon.spy(el, 'startLoanTokenPoller');
1697
+ const errorMsg = 'loan token not found. please try again later.';
1698
+
1699
+ for (let i = 0; i < el.maxTokenRecoveryAttempts + 1; i += 1) {
1700
+ el.handleLendingActionError({
1701
+ detail: {
1702
+ action: 'create_token',
1703
+ isInitial: true,
1704
+ data: { error: errorMsg },
1705
+ },
1706
+ });
1707
+ }
1708
+
1709
+ expect(startSpy.callCount).to.equal(el.maxTokenRecoveryAttempts);
1710
+ expect(el.tokenRecoveryAttempts).to.equal(el.maxTokenRecoveryAttempts + 1);
1542
1711
  });
1543
1712
  });