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

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/index.html CHANGED
@@ -360,7 +360,33 @@
360
360
  });
361
361
  });
362
362
 
363
-
363
+ // WEBDEV-8322 repro: fail only create_token (via ?failAction=create_token),
364
+ // leaving renew_loan (and every other action) succeeding normally —
365
+ // see actions-handler-service.js's shouldReturnError.
366
+ document.querySelectorAll('.failCreateToken input[type=checkbox]').forEach((item) => {
367
+ if (window.location.href.indexOf('?failAction=create_token') !== -1) {
368
+ item.checked = true;
369
+ }
370
+ item.addEventListener('click', (e) => {
371
+ console.log(`[Demo] fail-create_token-only mode ${e.target.checked ? 'enabled' : 'disabled'}`);
372
+
373
+ var searchParams = new URLSearchParams(window.location.search);
374
+ var separate = '';
375
+ if (e.target.checked) {
376
+ separate = '?';
377
+ searchParams.set('failAction', 'create_token');
378
+ } else {
379
+ separate = '';
380
+ searchParams.delete('failAction');
381
+ }
382
+
383
+ if (history.pushState) {
384
+ var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + separate + searchParams;
385
+ window.history.pushState({path:newurl},'',newurl);
386
+ }
387
+ });
388
+ });
389
+
364
390
  document.querySelectorAll('.userState input[type=checkbox]').forEach((item) => {
365
391
  item.addEventListener('click', (e) => {
366
392
  if (e.target.checked) {
@@ -726,6 +752,17 @@
726
752
  </span>
727
753
  </td>
728
754
  </tr>
755
+ <tr colspan='5'>
756
+ <td class="failCreateToken">
757
+ <label><input type="checkbox" name="failCreateToken" value="failCreateToken" />Fail create_token only (renew_loan still succeeds)</label>
758
+ <span style="color: #666; font-size: 11px; display: block; padding: 0 10px 10px;">
759
+ WEBDEV-8322 repro: unlike the checkbox above, this leaves renew_loan
760
+ succeeding and fails only the create_token call right after it — turn
761
+ this on, then run scenario 2 → 3 (or 2 → 4) below. Expect the error
762
+ modal, and the bar/timer resetting to Borrow/0 (no retry).
763
+ </span>
764
+ </td>
765
+ </tr>
729
766
  <tr>
730
767
  <td class="userState">
731
768
  <label><input type="checkbox" name="user" value="isAdmin" />isAdmin</label>
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-alpha15",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -18,7 +18,6 @@ export default async function ActionsHandlerService(options) {
18
18
  let baseHost = '/services/loans/loan';
19
19
  const location = window?.location;
20
20
 
21
- // return error reponse when not production and has ?error=true param...
22
21
  const tokenError = 'loan token not found. please try again later.';
23
22
  const borrowError =
24
23
  'This book is not available to borrow at this time. Please try again later.';
@@ -29,9 +28,18 @@ export default async function ActionsHandlerService(options) {
29
28
  'renew_loan',
30
29
  'return_loan',
31
30
  ];
31
+ const searchParams = new URLSearchParams(location?.search);
32
+ // ?error=true fails every erroneous action (e.g. simulates the book
33
+ // already being borrowed by someone else). ?failAction=create_token (or
34
+ // any action name) fails ONLY that action, so a renew_loan can succeed
35
+ // and the immediately-following create_token can be made to fail —
36
+ // reproducing WEBDEV-8322's "renewed, but create_token failed" bug
37
+ // without needing a real book.
32
38
  const shouldReturnError =
33
- new URLSearchParams(location?.search).get('error') === 'true' &&
34
- location?.hostname !== 'archive.org';
39
+ location?.hostname !== 'archive.org' &&
40
+ erroneousActions.includes(option?.action) &&
41
+ (searchParams.get('error') === 'true' ||
42
+ searchParams.get('failAction') === option?.action);
35
43
 
36
44
  const testHostname = ['localhost', 'internetarchive.github.io'];
37
45
  let isTest = false;
@@ -51,7 +59,7 @@ export default async function ActionsHandlerService(options) {
51
59
  })
52
60
  .then(async response => {
53
61
  // intentional error on localhost
54
- if (shouldReturnError && erroneousActions.includes(option?.action)) {
62
+ if (shouldReturnError) {
55
63
  return {
56
64
  success: false,
57
65
  error: option?.action === 'create_token' ? tokenError : borrowError,
@@ -19,26 +19,6 @@ export class LoanTokenPoller {
19
19
 
20
20
  this.loanTokenInterval = undefined;
21
21
 
22
- /**
23
- * Lending::do_renew() (backend) deletes the old loan record and
24
- * writes a brand-new one on every renewal — this poller's initial
25
- * create_token call (fired right after a renewal re-establishes the
26
- * reading session) can race that write's propagation and come back
27
- * with "you do not currently have this book borrowed" even though
28
- * the loan is genuinely valid. Retry a few times with backoff instead
29
- * of giving up on the first attempt.
30
- */
31
- this.maxTokenRetries = 3;
32
- this.tokenRetryDelay = 1000; // ms, multiplied by attempt number
33
-
34
- /**
35
- * Handle for a pending retry, so teardown can cancel it. It's a bare
36
- * setTimeout rather than an IALendingIntervals entry, so without this
37
- * a retry scheduled just before the loan is returned would still fire
38
- * its create_token afterwards.
39
- */
40
- this.retryTimeout = undefined;
41
-
42
22
  /**
43
23
  * loan analytics instance
44
24
  * @see loan-analytics.js
@@ -50,8 +30,6 @@ export class LoanTokenPoller {
50
30
 
51
31
  disconnectedCallback() {
52
32
  window?.IALendingIntervals?.clearTokenPoller();
53
- clearTimeout(this.retryTimeout);
54
- this.retryTimeout = undefined;
55
33
  }
56
34
 
57
35
  async bookAccessed() {
@@ -84,24 +62,21 @@ export class LoanTokenPoller {
84
62
  /**
85
63
  * @param {boolean} isInitial - the first create_token call right after
86
64
  * bookAccessed()/a renewal, as opposed to a routine interval tick.
87
- * @param {number} retryCount - internal, counts retries of the initial call.
88
65
  */
89
- async handleLoanTokenPoller(isInitial = false, retryCount = 0) {
66
+ async handleLoanTokenPoller(isInitial = false) {
90
67
  const action = 'create_token';
91
68
  log('[LoanTokenPoller] create_token requested', {
92
69
  identifier: this.identifier,
93
70
  isInitial,
94
- retryCount,
95
71
  });
96
72
  ActionsHandlerService({
97
73
  identifier: this.identifier,
98
74
  action,
99
- error: data => this.handleTokenError(data, isInitial, retryCount),
75
+ error: data => this.handleTokenError(data, isInitial),
100
76
  success: () => {
101
77
  log('[LoanTokenPoller] create_token succeeded', {
102
78
  identifier: this.identifier,
103
79
  isInitial,
104
- retryCount,
105
80
  });
106
81
  if (isInitial) this.successCallback();
107
82
  },
@@ -109,58 +84,23 @@ export class LoanTokenPoller {
109
84
  }
110
85
 
111
86
  /**
112
- * Decide whether a create_token failure should be retried (a stale
113
- * read of the loan record right after a renewal, see the constructor
114
- * comment) or reported via errorCallback. Split out from
87
+ * Reports a create_token failure via errorCallback — no retry, it's
88
+ * treated as terminal on the first failure. Split out from
115
89
  * handleLoanTokenPoller so it's directly testable without needing to
116
90
  * mock the network call.
117
91
  *
118
92
  * @param {Object} data - the error payload from ActionsHandlerService.
119
93
  * @param {boolean} isInitial
120
- * @param {number} retryCount
121
94
  */
122
- handleTokenError(data, isInitial, retryCount) {
95
+ handleTokenError(data, isInitial) {
123
96
  const action = 'create_token';
124
- const isStaleLoanReadError =
125
- typeof data?.error === 'string' &&
126
- /do not currently have this book borrowed/i.test(data.error);
127
97
 
128
98
  log('[LoanTokenPoller] create_token failed', {
129
99
  identifier: this.identifier,
130
100
  isInitial,
131
- retryCount,
132
- isStaleLoanReadError,
133
101
  error: data?.error,
134
102
  });
135
103
 
136
- // The renewal write can be mid-propagation for an interval refresh too,
137
- // not just the initial call — a routine tick landing moments after a
138
- // renewal hits the same race. Retry both; isInitial only decides how a
139
- // final failure is reported, not whether it's worth retrying.
140
- if (isStaleLoanReadError && retryCount < this.maxTokenRetries) {
141
- const delay = this.tokenRetryDelay * (retryCount + 1);
142
- log(
143
- '[LoanTokenPoller] retrying create_token after stale-loan-read error',
144
- {
145
- identifier: this.identifier,
146
- nextRetryCount: retryCount + 1,
147
- delay,
148
- }
149
- );
150
- clearTimeout(this.retryTimeout);
151
- this.retryTimeout = setTimeout(() => {
152
- this.retryTimeout = undefined;
153
- this.handleLoanTokenPoller(isInitial, retryCount + 1);
154
- }, delay);
155
- return;
156
- }
157
-
158
- log('[LoanTokenPoller] giving up on create_token, reporting error', {
159
- identifier: this.identifier,
160
- isInitial,
161
- retryCount,
162
- });
163
-
164
104
  // isInitial rides along so the consumer can distinguish "the book won't
165
105
  // open at all" from "a mid-read refresh blipped" — see
166
106
  // IABookActions.handleLendingActionError.
@@ -97,24 +97,17 @@ 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 recovering from a loan that genuinely lapsed (set by
101
+ * autoRenewExpiredLoan), as opposed to a routine pre-expiry top-up.
102
+ * Only the recovery case needs BookReader re-initialized — see
104
103
  * handleLoanAutoRenewed.
105
104
  */
106
105
  this.recoveringFromLoanExpiry = false;
107
106
 
108
107
  this.warningModalOpen = false;
109
108
 
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
- */
109
+ /** Once dismissed, don't re-show the warning modal every tick — only
110
+ * a real renewal (handleLoanAutoRenewed) re-arms it. */
118
111
  this.warningModalDismissed = false;
119
112
 
120
113
  /**
@@ -148,9 +141,9 @@ export default class IABookActions extends LitElement {
148
141
  }
149
142
 
150
143
  disconnectedCallback() {
151
- // clear all intervals for lending system
144
+ super.disconnectedCallback();
152
145
  window?.IALendingIntervals?.clearAll();
153
-
146
+ this.tokenPoller?.disconnectedCallback();
154
147
  this.sentryCaptureMsg(sentryLogs.disconnectedCallback);
155
148
  this.disconnectResizeObserver();
156
149
  }
@@ -189,12 +182,8 @@ export default class IABookActions extends LitElement {
189
182
  }
190
183
 
191
184
  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.
185
+ // Pause the countdown for the renew_loan round-trip, for every
186
+ // renewal path — cleared by handleLoanAutoRenewed().
198
187
  this.loanRenewInProgress = true;
199
188
  window.IALendingIntervals.clearAll();
200
189
  }
@@ -269,13 +258,8 @@ export default class IABookActions extends LitElement {
269
258
  'browsingExpired' in this.lendingStatus &&
270
259
  this.lendingStatus?.browsingExpired;
271
260
  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.
261
+ // Auto-return must not visibly change the bar — unless nothing's
262
+ // rendered yet (fresh load of an already-expired loan).
279
263
  if (this.primaryActions?.length) {
280
264
  log('[IABookActions] browsing expired — leaving action bar untouched');
281
265
  } else {
@@ -305,18 +289,18 @@ export default class IABookActions extends LitElement {
305
289
 
306
290
  if (!this.applyLendingActions()) return;
307
291
 
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.
292
+ // primaryActions/borrowType/etc are plain fields, not reactive
293
+ // properties, so nothing pushes them to the rendered template (and
294
+ // from there to <collapsible-action-group>) on its own. Do that now,
295
+ // unconditionally — the early returns below (not borrowed, title bar)
296
+ // must not skip it, or the action bar visibly goes stale (e.g. a
297
+ // create_token failure resetting to Borrow never actually shows it).
298
+ this.requestUpdate();
299
+
300
+ // Don't (re)start the countdown mid-renewal — secondsLeftOnLoan could
301
+ // still be stale until handleLoanAutoRenewed() confirms it.
315
302
  if (this.borrowType === 'browsed' && !this.loanRenewInProgress) {
316
- // start timer for loan-renew
317
303
  await this.startTimerCountdown();
318
-
319
- // start timer for browsed.
320
304
  await this.startBrowseTimer();
321
305
  }
322
306
 
@@ -326,15 +310,8 @@ export default class IABookActions extends LitElement {
326
310
  return;
327
311
  }
328
312
 
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
- */
313
+ // Wait until any in-flight renewal is confirmed before (re)starting the
314
+ // poller, so create_token isn't called against a not-yet-renewed loan.
338
315
  setTimeout(() => {
339
316
  if (
340
317
  !hasExpired &&
@@ -345,29 +322,12 @@ export default class IABookActions extends LitElement {
345
322
  this.startLoanTokenPoller();
346
323
  }
347
324
  }, 100);
348
-
349
- this.requestUpdate();
350
325
  }
351
326
 
352
327
  /**
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
- *
328
+ * Is this BookReader's own init-time jump to the last-read page, rather
329
+ * than a real patron interaction (which fires the identical event)? Only
330
+ * an explicit `false` counts; a missing flag must be treated as real.
371
331
  * @param {Event} event - the BookReader:userAction event
372
332
  * @returns {boolean}
373
333
  */
@@ -393,14 +353,9 @@ export default class IABookActions extends LitElement {
393
353
  return;
394
354
  }
395
355
 
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.
356
+ // Capture before autoRenewExpiredLoan() runs — it optimistically
357
+ // flips browsingExpired to false, which would otherwise also let
358
+ // autoLoanRenewChecker() run and clobber the in-flight renewal.
404
359
  const wasExpired = this.lendingStatus.browsingExpired;
405
360
 
406
361
  log('[IABookActions] BookReader:userAction received', {
@@ -408,7 +363,6 @@ export default class IABookActions extends LitElement {
408
363
  browsingExpired: wasExpired,
409
364
  });
410
365
 
411
- // If the loan expired while the tab stayed visible, auto-renew on page turn
412
366
  if (wasExpired) {
413
367
  this.autoRenewExpiredLoan();
414
368
  }
@@ -418,11 +372,8 @@ export default class IABookActions extends LitElement {
418
372
  }
419
373
  });
420
374
 
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
- */
375
+ // A tab in the background can have its intervals throttled/paused, so
376
+ // re-check status when the patron comes back to it.
426
377
  document.addEventListener('visibilitychange', async () => {
427
378
  if (document.hidden) {
428
379
  log('[IABookActions] visibilitychange: tab backgrounded');
@@ -454,14 +405,9 @@ export default class IABookActions extends LitElement {
454
405
  if (secondsLeft >= this.timerExecutionSeconds) {
455
406
  this.loanStatusCheckInterval(Number(secondsLeft));
456
407
  } 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.
408
+ // Loan expired while away — silently renew. Only clear
409
+ // intervals; disconnectedCallback() would drop the resize
410
+ // observer for a session that's still continuing.
465
411
  window?.IALendingIntervals?.clearAll();
466
412
  this.autoRenewExpiredLoan();
467
413
  }
@@ -481,14 +427,8 @@ export default class IABookActions extends LitElement {
481
427
  * @param {Boolean} hasPageChanged
482
428
  */
483
429
  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.
430
+ // Re-entrancy guard against rapid BookReader:userAction events (e.g. a
431
+ // single scroll firing several in quick succession).
492
432
  if (this.loanRenewInProgress) return;
493
433
 
494
434
  this.loanRenewHelper = new LoanRenewHelper(
@@ -503,21 +443,15 @@ export default class IABookActions extends LitElement {
503
443
  }
504
444
 
505
445
  /**
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().
446
+ * Silently attempt to renew a browse loan that has expired, on
447
+ * visibilitychange or a page turn. handleLoanAutoRenewed()/
448
+ * handleLendingActionError() handle the outcome either way.
513
449
  */
514
450
  autoRenewExpiredLoan() {
515
451
  if (this.loanRenewInProgress) {
516
452
  log(
517
453
  '[IABookActions] autoRenewExpiredLoan: skipped, renewal already in progress',
518
- {
519
- identifier: this.identifier,
520
- }
454
+ { identifier: this.identifier }
521
455
  );
522
456
  return;
523
457
  }
@@ -529,16 +463,8 @@ export default class IABookActions extends LitElement {
529
463
 
530
464
  this.modal?.closeModal();
531
465
 
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.
466
+ // Optimistically flip browsingExpired so the bar stays in the reading
467
+ // state during the renew_loan round-trip, instead of showing "Borrow".
542
468
  this.lendingStatus = {
543
469
  ...this.lendingStatus,
544
470
  browsingExpired: false,
@@ -572,7 +498,6 @@ export default class IABookActions extends LitElement {
572
498
 
573
499
  log('[IABookActions] showWarningModal');
574
500
 
575
- // Capture texts and secondsLeft before resetting loanRenewResult
576
501
  const {
577
502
  texts: warningTexts,
578
503
  secondsLeft: rawSecondsLeft,
@@ -584,7 +509,6 @@ export default class IABookActions extends LitElement {
584
509
  secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
585
510
  }
586
511
 
587
- // clear modal and reset renew state
588
512
  this.modal.customModalContent = nothing;
589
513
  this.modal?.closeModal();
590
514
  this.loanRenewResult = { texts: '', renewNow: false };
@@ -666,9 +590,8 @@ export default class IABookActions extends LitElement {
666
590
  'This book has been returned due to inactivity.';
667
591
 
668
592
  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.
593
+ // Release the warning-modal re-entrancy guard, or it stays latched for
594
+ // the life of the component.
672
595
  this.warningModalOpen = false;
673
596
 
674
597
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
@@ -776,31 +699,16 @@ export default class IABookActions extends LitElement {
776
699
  }
777
700
 
778
701
  /**
779
- * Execute after auto loan renewed is completed
780
- * - show success message
781
- * - then change the remaining time
782
- * - reset timer state
702
+ * Runs after a loan renewal completes (successfully or not): shows the
703
+ * outcome, updates the remaining time, and resets renewal-in-flight state.
783
704
  * @param {object} event
784
705
  */
785
706
  async handleLoanAutoRenewed({ detail }) {
786
707
  const activeLoan = detail?.data?.loan;
787
708
 
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
- */
709
+ // Treat anything but a confirmed renewal as a failure, or
710
+ // loanRenewInProgress stays latched forever (autoLoanRenewChecker/
711
+ // autoRenewExpiredLoan both no-op on it).
804
712
  if (!activeLoan?.renewal) {
805
713
  this.loanRenewInProgress = false;
806
714
  this.recoveringFromLoanExpiry = false;
@@ -816,7 +724,6 @@ export default class IABookActions extends LitElement {
816
724
  }
817
725
 
818
726
  if (this.loanRenewResult.renewNow) {
819
- // Now, let's reset loan duration & this.lendingStatus
820
727
  const loanTime = await this.localCache.get(`${this.identifier}-loanTime`);
821
728
 
822
729
  // number of seconds left in current loan
@@ -834,19 +741,8 @@ export default class IABookActions extends LitElement {
834
741
  });
835
742
 
836
743
  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.
744
+ // Only now is it safe to re-initialize BookReader — not for a
745
+ // routine top-up, where that would be disruptive.
850
746
  this.postInitComplete = false;
851
747
  }
852
748
 
@@ -870,46 +766,34 @@ export default class IABookActions extends LitElement {
870
766
  this.loanRenewInProgress = false;
871
767
  }
872
768
 
873
- /**
874
- * start timer countdown interval after loan auto-renewed
875
- *
876
- * @memberof IABookActions
877
- */
769
+ /** Start the countdown interval; ticks read the live secondsLeftOnLoan
770
+ * each time rather than a value frozen at start, so drift can't compound
771
+ * across ticks (see loanStatusCheckInterval). */
878
772
  async startTimerCountdown() {
879
773
  window?.IALendingIntervals?.clearTimerCountdown();
880
-
881
- const secondsLeft = Number(this.lendingStatus.secondsLeftOnLoan);
882
774
  this.timeWhenTimerStart = new Date();
883
775
 
884
776
  window.IALendingIntervals.timerCountdown = setInterval(async () => {
885
- // interval execution block
886
- await this.loanStatusCheckInterval(secondsLeft);
777
+ await this.loanStatusCheckInterval(
778
+ Number(this.lendingStatus.secondsLeftOnLoan)
779
+ );
887
780
  }, this.timerExecutionSeconds * 1000);
888
781
  }
889
782
 
890
783
  /**
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
- *
784
+ * Runs on every countdown tick: resyncs against wall-clock time, attempts
785
+ * a renewal near expiry, and clears the timers once the loan expires.
896
786
  * @param {Number} secondsLeftOnLoan
897
787
  */
898
788
  async loanStatusCheckInterval(secondsLeftOnLoan) {
899
789
  let secondsLeft = secondsLeftOnLoan;
900
790
  secondsLeft -= this.timerExecutionSeconds;
901
- secondsLeft = Math.round(secondsLeft); // round number
791
+ secondsLeft = Math.round(secondsLeft);
902
792
 
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
793
  const resyncd = this.reSyncTimerIfGoneOff(secondsLeft);
907
-
908
794
  if (resyncd.hasSynced) {
909
795
  secondsLeft = resyncd.whatShouldLeft;
910
- log('[IABookActions] timer: timer re-synced', {
911
- secondsLeft,
912
- });
796
+ log('[IABookActions] timer: timer re-synced', { secondsLeft });
913
797
  }
914
798
 
915
799
  log('[IABookActions] timer', {
@@ -917,19 +801,20 @@ export default class IABookActions extends LitElement {
917
801
  whatIsleft: secondsLeft,
918
802
  });
919
803
 
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
- */
804
+ // Re-anchor every tick so drift from wall-clock time can't compound
805
+ // across ticks that don't happen to trigger a resync above.
806
+ this.timeWhenTimerStart = new Date();
807
+ this.lendingStatus = { ...this.lendingStatus, secondsLeftOnLoan: secondsLeft };
808
+
809
+ // 10 minutes out: start checking for a renewal. 0: show the "about to
810
+ // auto-return" warning. @see IABookActions::bindLoanRenewEvents
926
811
  if (secondsLeft <= this.loanRenewTimeConfig.loanRenewAtLast) {
927
812
  await this.loanRenewAttempt(secondsLeft);
928
813
  }
929
814
 
930
- // clear interval in secondsLeft if less
931
815
  if (secondsLeft <= this.timerExecutionSeconds) {
932
- this.disconnectedCallback();
816
+ window?.IALendingIntervals?.clearAll();
817
+ this.tokenPoller?.disconnectedCallback();
933
818
  this.sentryCaptureMsg(sentryLogs.clearOneHourTimer);
934
819
  }
935
820
  }
@@ -984,13 +869,8 @@ export default class IABookActions extends LitElement {
984
869
  */
985
870
  async loanRenewAttempt(secondsLeft) {
986
871
  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
- */
872
+ // Under 50s left there isn't enough time for the renew_loan round-trip
873
+ // and create_token to load images, so just expire the loan.
994
874
  if (loanSecondsLeft < 50) {
995
875
  log('[IABookActions] loanRenewAttempt: < 50s left, expiring loan');
996
876
  await this.browseHasExpired();
@@ -999,17 +879,13 @@ export default class IABookActions extends LitElement {
999
879
 
1000
880
  await this.autoLoanRenewChecker(false);
1001
881
 
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.
882
+ // Once dismissed, don't re-show the warning on every subsequent tick —
883
+ // only an actual renewal (handleLoanAutoRenewed) re-arms it.
1005
884
  if (
1006
885
  this.loanRenewResult.renewNow === false &&
1007
886
  !this.warningModalDismissed
1008
887
  ) {
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
- */
888
+ // Compensate for the 50s buffer above by warning a minute early.
1013
889
  loanSecondsLeft -= 60;
1014
890
  this.loanRenewResult.secondsLeft = loanSecondsLeft;
1015
891
 
@@ -1032,17 +908,9 @@ export default class IABookActions extends LitElement {
1032
908
  this.handleLendingActionError(eventObj);
1033
909
  };
1034
910
 
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
- */
911
+ // Tear down any previous poller first, so only one is ever controlling
912
+ // window.IALendingIntervals.tokenPoller at a time.
913
+ this.tokenPoller?.disconnectedCallback();
1046
914
  this.tokenPoller = new LoanTokenPoller(
1047
915
  this.identifier,
1048
916
  this.borrowType,
@@ -1062,13 +930,12 @@ export default class IABookActions extends LitElement {
1062
930
  }
1063
931
 
1064
932
  /**
1065
- * handle lending errors occure during different operation like
1066
- * browse book, borrow book, borrowed book token etc...
1067
- *
933
+ * Handles lending errors from any action (browse_book, borrow_book,
934
+ * create_token, renew_loan, etc).
1068
935
  * @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
936
+ * @param {Object} event
937
+ * @param {string} event.detail.action
938
+ * @param {string} event.detail.data.error
1072
939
  */
1073
940
  handleLendingActionError(event) {
1074
941
  this.disableActionGroup = false;
@@ -1087,42 +954,29 @@ export default class IABookActions extends LitElement {
1087
954
  });
1088
955
 
1089
956
  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.
1094
- window?.IALendingIntervals?.clearTokenPoller();
1095
-
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
- */
957
+ // Only the INITIAL token matters enough to interrupt the patron — a
958
+ // routine interval refresh stays silent; the pages already loaded
959
+ // are still served, and the next scheduled poll tries again on its
960
+ // own without any special retry.
1107
961
  if (!isInitial) {
1108
962
  log(
1109
- '[IABookActions] create_token failed on interval refresh — staying silent, poller will retry'
963
+ '[IABookActions] create_token failed on interval refresh — staying silent'
1110
964
  );
1111
965
  return;
1112
966
  }
1113
967
 
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.
968
+ // The book never opened, and we don't retry — reset the bar to
969
+ // Borrow and the timer to 0 rather than leaving a red "Return now"
970
+ // with a stale countdown for a session that isn't accessible.
971
+ window?.IALendingIntervals?.clearAll();
972
+ this.tokenPoller?.disconnectedCallback();
1117
973
  this.lendingStatus = {
1118
974
  ...this.lendingStatus,
1119
975
  user_has_browsed: false,
1120
976
  available_to_browse: true,
977
+ secondsLeftOnLoan: 0,
1121
978
  };
1122
979
 
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
980
  // showErrorModal has dedicated create_token messaging (refresh
1127
981
  // button + support email).
1128
982
  if (errorMsg) this.showErrorModal(errorMsg, action);
@@ -1131,11 +985,8 @@ export default class IABookActions extends LitElement {
1131
985
  this.loanRenewInProgress = false;
1132
986
  this.recoveringFromLoanExpiry = false;
1133
987
 
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.
988
+ // The loan was NOT renewed — reflect that immediately (show Borrow,
989
+ // clear the stale timer) rather than leaving a frozen "Return now".
1139
990
  this.lendingStatus = {
1140
991
  ...this.lendingStatus,
1141
992
  user_has_browsed: false,
@@ -1143,11 +994,8 @@ export default class IABookActions extends LitElement {
1143
994
  secondsLeftOnLoan: 0,
1144
995
  };
1145
996
 
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).
997
+ // Refresh the page on dismissal so the client picks up whatever the
998
+ // server now authoritatively considers true (any error message).
1151
999
  this.showLoanUnavailableModal(errorMsg);
1152
1000
  } else {
1153
1001
  // Every other action failure genuinely affects loan state.
@@ -1,4 +1,4 @@
1
- import { expect, aTimeout } from '@open-wc/testing';
1
+ import { expect } from '@open-wc/testing';
2
2
  import Sinon from 'sinon';
3
3
 
4
4
  import { LoanTokenPoller } from '../../../src/core/services/loan-token-poller.js';
@@ -57,15 +57,9 @@ describe('Get Loan Token', () => {
57
57
  });
58
58
  });
59
59
 
60
- describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
61
- // Lending::do_renew() (backend) deletes the old loan record and writes
62
- // a brand-new one on every renewal, so the initial create_token call
63
- // right after a renewal can race that write's propagation and come
64
- // back with this exact error even though the loan is genuinely valid.
65
- const staleLoanReadError = {
66
- error: 'You do not currently have this book borrowed.',
67
- };
68
-
60
+ describe('handleTokenError (no retry)', () => {
61
+ // A create_token failure is treated as terminal on the first attempt —
62
+ // no retry, whether it's the "stale loan read" race or anything else.
69
63
  const makeTokenPoller = () =>
70
64
  new LoanTokenPoller(
71
65
  'identifier1',
@@ -75,126 +69,43 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
75
69
  2000
76
70
  );
77
71
 
78
- it('retries the initial call on a stale-loan-read error instead of failing immediately', () => {
72
+ it('reports the error immediately, without retrying, on the initial call', () => {
79
73
  const tokenPoller = makeTokenPoller();
80
74
  const handleLoanTokenPollerSpy = Sinon.spy(
81
75
  tokenPoller,
82
76
  'handleLoanTokenPoller'
83
77
  );
84
78
  const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
85
- const clock = Sinon.useFakeTimers();
86
-
87
- try {
88
- tokenPoller.handleTokenError(staleLoanReadError, true, 0);
89
-
90
- expect(errorCallbackSpy.called).to.be.false;
91
-
92
- clock.tick(tokenPoller.tokenRetryDelay);
93
-
94
- expect(handleLoanTokenPollerSpy.calledWith(true, 1)).to.be.true;
95
- } finally {
96
- clock.restore();
97
- }
98
- });
99
-
100
- it('gives up and calls errorCallback after exhausting retries', () => {
101
- const tokenPoller = makeTokenPoller();
102
- const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
103
79
 
104
80
  tokenPoller.handleTokenError(
105
- staleLoanReadError,
106
- true,
107
- tokenPoller.maxTokenRetries
81
+ { error: 'You do not currently have this book borrowed.' },
82
+ true
108
83
  );
109
84
 
110
85
  expect(errorCallbackSpy.calledOnce).to.be.true;
86
+ expect(handleLoanTokenPollerSpy.called).to.be.false;
111
87
  });
112
88
 
113
- it('retries a routine (non-initial) poll on the same error too', () => {
114
- // A routine tick landing moments after a renewal hits the very same
115
- // write-propagation race as the initial call, so it's equally worth
116
- // retrying. isInitial only decides how a FINAL failure is reported.
117
- const tokenPoller = makeTokenPoller();
118
- const handleLoanTokenPollerSpy = Sinon.spy(
119
- tokenPoller,
120
- 'handleLoanTokenPoller'
121
- );
122
- const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
123
- const clock = Sinon.useFakeTimers();
124
-
125
- try {
126
- tokenPoller.handleTokenError(staleLoanReadError, false, 0);
127
-
128
- expect(errorCallbackSpy.called).to.be.false;
129
-
130
- clock.tick(tokenPoller.tokenRetryDelay);
131
-
132
- expect(handleLoanTokenPollerSpy.calledWith(false, 1)).to.be.true;
133
- } finally {
134
- clock.restore();
135
- }
136
- });
137
-
138
- it('does not retry a genuinely different error, even on the initial call', () => {
89
+ it('reports the error immediately on a routine (non-initial) poll too', () => {
139
90
  const tokenPoller = makeTokenPoller();
140
91
  const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
141
92
 
142
93
  tokenPoller.handleTokenError(
143
- { error: 'loan token not found. please try again later.' },
144
- true,
145
- 0
94
+ { error: 'You do not currently have this book borrowed.' },
95
+ false
146
96
  );
147
97
 
148
98
  expect(errorCallbackSpy.calledOnce).to.be.true;
149
99
  });
150
- });
151
-
152
- describe('LoanTokenPoller - WEBDEV-8322 review fixes', () => {
153
- it('retries a stale-loan-read error on interval refreshes too, not just the initial call', async () => {
154
- const poller = new LoanTokenPoller('foo', 'browsed', () => {}, () => {}, 120);
155
- poller.disconnectedCallback(); // stop the real poller started in the ctor
156
-
157
- const retrySpy = Sinon.stub(poller, 'handleLoanTokenPoller');
158
- poller.tokenRetryDelay = 1;
159
-
160
- poller.handleTokenError(
161
- { error: 'you do not currently have this book borrowed' },
162
- false, // interval refresh
163
- 0
164
- );
165
- await aTimeout(30);
166
-
167
- expect(retrySpy.calledOnce).to.be.true;
168
- expect(retrySpy.firstCall.args).to.deep.equal([false, 1]);
169
- poller.disconnectedCallback();
170
- });
171
100
 
172
101
  it('passes isInitial through to the error callback', () => {
173
102
  const errorCallback = Sinon.spy();
174
103
  const poller = new LoanTokenPoller('foo', 'browsed', () => {}, errorCallback, 120);
175
104
  poller.disconnectedCallback();
176
105
 
177
- poller.handleTokenError({ error: 'something else went wrong' }, true, 0);
106
+ poller.handleTokenError({ error: 'something else went wrong' }, true);
178
107
 
179
108
  expect(errorCallback.calledOnce).to.be.true;
180
109
  expect(errorCallback.firstCall.args[0].detail.isInitial).to.be.true;
181
110
  });
182
-
183
- it('cancels a pending retry on teardown', async () => {
184
- const poller = new LoanTokenPoller('foo', 'browsed', () => {}, () => {}, 120);
185
- const retrySpy = Sinon.stub(poller, 'handleLoanTokenPoller');
186
- poller.tokenRetryDelay = 20;
187
-
188
- poller.handleTokenError(
189
- { error: 'you do not currently have this book borrowed' },
190
- true,
191
- 0
192
- );
193
- expect(poller.retryTimeout).to.not.be.undefined;
194
-
195
- poller.disconnectedCallback();
196
- await aTimeout(60);
197
-
198
- expect(retrySpy.called).to.be.false;
199
- });
200
111
  });
@@ -1101,14 +1101,11 @@ describe('handleLendingActionError - create_token failures must not stop the cou
1101
1101
  expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
1102
1102
  });
1103
1103
 
1104
- it('shows the error modal for a create_token failure that reaches here (retries already exhausted)', async () => {
1105
- // By the time handleLendingActionError runs for a create_token
1106
- // failure, LoanTokenPoller has already retried and given up (see
1107
- // loan-token-poller.js's handleTokenError). For the INITIAL token
1108
- // that's terminal — BookReader can't be initialized and the book
1109
- // won't open — so the patron must be told rather than left staring
1110
- // at nothing. (An interval refresh failing is handled separately and
1111
- // stays silent; see the WEBDEV-8322 review fixes below.)
1104
+ it('shows the error modal and resets to Borrow/0 on a failed INITIAL create_token (no retry)', async () => {
1105
+ // For the INITIAL token, BookReader never got page images — the book
1106
+ // never opened. We don't retry, so the bar must reflect that honestly
1107
+ // (Borrow, 0 left) instead of staying red with a stale countdown for a
1108
+ // session that isn't actually accessible.
1112
1109
  const el = await fixture(
1113
1110
  container({
1114
1111
  userid: '@user1',
@@ -1136,6 +1133,40 @@ describe('handleLendingActionError - create_token failures must not stop the cou
1136
1133
  expect(showErrorModalSpy.calledOnceWith(errorMsg, 'create_token')).to.be
1137
1134
  .true;
1138
1135
  expect(el.lendingStatus.user_has_browsed).to.be.false;
1136
+ expect(el.lendingStatus.available_to_browse).to.be.true;
1137
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
1138
+ });
1139
+
1140
+ it('stops the countdown/expiry timers on a failed INITIAL create_token — nothing to read yet', async () => {
1141
+ // Unlike the interval-refresh case above (pages already loaded, keep
1142
+ // ticking), an initial create_token failure means BookReader never got
1143
+ // page images and we're resetting the bar to Borrow — both timers
1144
+ // should stop rather than keep counting down a dead session.
1145
+ const el = await fixture(
1146
+ container({
1147
+ userid: '@user1',
1148
+ identifier: 'foobar',
1149
+ lendingStatus: {
1150
+ user_has_browsed: true,
1151
+ browsingExpired: false,
1152
+ secondsLeftOnLoan: 100,
1153
+ },
1154
+ })
1155
+ );
1156
+ await el.updateComplete;
1157
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
1158
+ expect(window.IALendingIntervals.browseExpireTimeout).to.not.equal(0);
1159
+
1160
+ el.handleLendingActionError({
1161
+ detail: {
1162
+ action: 'create_token',
1163
+ isInitial: true,
1164
+ data: { error: 'loan token not found. please try again later.' },
1165
+ },
1166
+ });
1167
+
1168
+ expect(window.IALendingIntervals.timerCountdown).to.equal(0);
1169
+ expect(window.IALendingIntervals.browseExpireTimeout).to.equal(0);
1139
1170
  });
1140
1171
 
1141
1172
  it('still clears everything on a renew_loan failure', async () => {
@@ -1511,11 +1542,14 @@ describe('WEBDEV-8322 review fixes', () => {
1511
1542
  expect(el.lendingStatus.user_has_browsed).to.be.true;
1512
1543
  });
1513
1544
 
1514
- it('surfaces a failed initial create_token, which blocks opening the book', async () => {
1545
+ it('tears down the previous poller before starting a new one', async () => {
1546
+ // Regression: startLoanTokenPoller() always replaced this.tokenPoller
1547
+ // without cancelling the old instance's pending internal retry timer,
1548
+ // which could later fire against stale state.
1515
1549
  const el = await fixture(
1516
1550
  container({
1517
1551
  userid: '@user1',
1518
- identifier: 'token-initial',
1552
+ identifier: 'poller-teardown',
1519
1553
  lendingStatus: {
1520
1554
  is_lendable: true,
1521
1555
  available_to_browse: true,
@@ -1526,7 +1560,34 @@ describe('WEBDEV-8322 review fixes', () => {
1526
1560
  })
1527
1561
  );
1528
1562
  await el.updateComplete;
1529
- const spy = Sinon.spy(el, 'showErrorModal');
1563
+
1564
+ el.startLoanTokenPoller();
1565
+ const firstPoller = el.tokenPoller;
1566
+ const disconnectSpy = Sinon.spy(firstPoller, 'disconnectedCallback');
1567
+
1568
+ el.startLoanTokenPoller();
1569
+
1570
+ expect(disconnectSpy.calledOnce).to.be.true;
1571
+ expect(el.tokenPoller).to.not.equal(firstPoller);
1572
+ });
1573
+
1574
+ it('never restarts the poller on a create_token failure — no retry', async () => {
1575
+ const el = await fixture(
1576
+ container({
1577
+ userid: '@user1',
1578
+ identifier: 'token-exhausted',
1579
+ lendingStatus: {
1580
+ is_lendable: true,
1581
+ available_to_browse: true,
1582
+ user_has_browsed: true,
1583
+ browsingExpired: false,
1584
+ secondsLeftOnLoan: 300,
1585
+ },
1586
+ })
1587
+ );
1588
+ await el.updateComplete;
1589
+
1590
+ const startSpy = Sinon.spy(el, 'startLoanTokenPoller');
1530
1591
 
1531
1592
  el.handleLendingActionError({
1532
1593
  detail: {
@@ -1535,9 +1596,7 @@ describe('WEBDEV-8322 review fixes', () => {
1535
1596
  data: { error: 'loan token not found. please try again later.' },
1536
1597
  },
1537
1598
  });
1538
- await el.updateComplete;
1539
1599
 
1540
- expect(spy.calledOnce).to.be.true;
1541
- expect(el.lendingStatus.user_has_browsed).to.be.false;
1600
+ expect(startSpy.called).to.be.false;
1542
1601
  });
1543
1602
  });