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

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-alpha11",
10
+ "version": "1.4.4-alpha13",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -4,9 +4,14 @@ export const infoIcon = html`<svg
4
4
  height="20"
5
5
  width="20"
6
6
  viewBox="0 0 100 100"
7
- xmlns="http://www.w3.org/2000/svg">
8
- <g fill="none" fill-rule="evenodd"><path d="m0 0h100v100h-100z" fill="#fff"/>
9
- <path d="m49.8315487 0h.1702245c6.7356878 0 13.1853038 1.31117332 19.3488483 3.93351997 6.1635444 2.62234664 11.4854233 6.15778963 15.9656369 10.60632903 4.4802135 4.4485394 8.0478347 9.7522946 10.7028636 15.9112655 2.655029 6.1589709 3.980878 12.6038012 3.9789971 19.3344909.0567419 6.6716279-1.1702933 13.0585776-3.6811042 19.1608491-2.510811 6.1022715-6.106803 11.5206067-10.7879759 16.2550055-9.7027949 9.7522946-21.4884754 14.6851412-35.3570414 14.79854h-.1702244c-6.7333236 0-13.1829397-1.3111733-19.3488483-3.93352-6.1659087-2.6223466-11.4877876-6.1577896-15.9656369-10.606329s-8.04547055-9.7522946-10.7028637-15.9112655c-2.65739314-6.1589709-3.9844243-12.6038012-3.98254337-19.3344909-.05674149-6.6716279 1.17029325-13.0585776 3.68110421-19.1608491 2.51081095-6.1022715 6.10680292-11.5206067 10.78797586-16.2550055 9.7027949-9.75229456 21.4884754-14.68514123 35.3570414-14.79854zm12.6566146 26.4757998c1.6745578-1.6828001 2.5118367-3.6747334 2.5118367-5.9757998 0-2.4126333-.8095238-4.4324583-2.4285714-6.059475s-3.6289796-2.440525-6.0297959-2.440525c-2.4008164 0-4.4107483.8135083-6.029796 2.440525-1.6745578 1.6270167-2.5118367 3.6468417-2.5118367 6.059475 0 2.1871753.8372789 4.1791086 2.5118367 5.9757998 1.6745579 1.6828001 3.6844898 2.5242002 6.029796 2.5242002 2.3453061 0 4.3274829-.8414001 5.9465306-2.5242002zm-12.1370589 52.7776981-1.2815282-.9486968c0-.4588935.398855-1.8938272 1.196565-4.3048011l12.7338588-39-23.0745876 3.6164609.2548896 3.873251c0-.1141289.4554971-.1997256 1.3664914-.2567901.9109942 0 1.623741.1723823 2.1382404.5171468.5121392.2306356.7965299.6039323.8531721 1.1198902 0 .8607225-.6549247 3.2134431-1.9647739 7.0581619l-8.1175252 24.1061729c-1.0242785 3.2716963-1.5080967 5.5388203-1.4514546 6.8013717.0566421 1.6643804.8826732 2.9839963 2.4780932 3.9588477 1.2532071.803658 2.8769482 1.205487 4.8712231 1.205487h.5982825c1.7653464-.0570645 3.8445846-.3875629 6.2377146-.9914952 2.3931299-.6039323 4.3590839-1.3933242 5.8978617-2.3681756 1.3098493-.803658 2.3919499-1.5359853 3.2463021-2.1969821.8543521-.6609968 1.3959925-1.1341564 1.6249211-1.4194788l.3433929-.3459533-2.8214861-3.3596708-2.9914125 2.0650205c-.79771.4588935-1.5104568.7454047-2.1382404.8595337z" fill="#000" fill-rule="nonzero"/>
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ >
9
+ <g fill="none" fill-rule="evenodd">
10
+ <path d="m0 0h100v100h-100z" fill="#fff" />
11
+ <path
12
+ d="m49.8315487 0h.1702245c6.7356878 0 13.1853038 1.31117332 19.3488483 3.93351997 6.1635444 2.62234664 11.4854233 6.15778963 15.9656369 10.60632903 4.4802135 4.4485394 8.0478347 9.7522946 10.7028636 15.9112655 2.655029 6.1589709 3.980878 12.6038012 3.9789971 19.3344909.0567419 6.6716279-1.1702933 13.0585776-3.6811042 19.1608491-2.510811 6.1022715-6.106803 11.5206067-10.7879759 16.2550055-9.7027949 9.7522946-21.4884754 14.6851412-35.3570414 14.79854h-.1702244c-6.7333236 0-13.1829397-1.3111733-19.3488483-3.93352-6.1659087-2.6223466-11.4877876-6.1577896-15.9656369-10.606329s-8.04547055-9.7522946-10.7028637-15.9112655c-2.65739314-6.1589709-3.9844243-12.6038012-3.98254337-19.3344909-.05674149-6.6716279 1.17029325-13.0585776 3.68110421-19.1608491 2.51081095-6.1022715 6.10680292-11.5206067 10.78797586-16.2550055 9.7027949-9.75229456 21.4884754-14.68514123 35.3570414-14.79854zm12.6566146 26.4757998c1.6745578-1.6828001 2.5118367-3.6747334 2.5118367-5.9757998 0-2.4126333-.8095238-4.4324583-2.4285714-6.059475s-3.6289796-2.440525-6.0297959-2.440525c-2.4008164 0-4.4107483.8135083-6.029796 2.440525-1.6745578 1.6270167-2.5118367 3.6468417-2.5118367 6.059475 0 2.1871753.8372789 4.1791086 2.5118367 5.9757998 1.6745579 1.6828001 3.6844898 2.5242002 6.029796 2.5242002 2.3453061 0 4.3274829-.8414001 5.9465306-2.5242002zm-12.1370589 52.7776981-1.2815282-.9486968c0-.4588935.398855-1.8938272 1.196565-4.3048011l12.7338588-39-23.0745876 3.6164609.2548896 3.873251c0-.1141289.4554971-.1997256 1.3664914-.2567901.9109942 0 1.623741.1723823 2.1382404.5171468.5121392.2306356.7965299.6039323.8531721 1.1198902 0 .8607225-.6549247 3.2134431-1.9647739 7.0581619l-8.1175252 24.1061729c-1.0242785 3.2716963-1.5080967 5.5388203-1.4514546 6.8013717.0566421 1.6643804.8826732 2.9839963 2.4780932 3.9588477 1.2532071.803658 2.8769482 1.205487 4.8712231 1.205487h.5982825c1.7653464-.0570645 3.8445846-.3875629 6.2377146-.9914952 2.3931299-.6039323 4.3590839-1.3933242 5.8978617-2.3681756 1.3098493-.803658 2.3919499-1.5359853 3.2463021-2.1969821.8543521-.6609968 1.3959925-1.1341564 1.6249211-1.4194788l.3433929-.3459533-2.8214861-3.3596708-2.9914125 2.0650205c-.79771.4588935-1.5104568.7454047-2.1382404.8595337z"
13
+ fill="#000"
14
+ fill-rule="nonzero"
15
+ />
10
16
  </g>
11
- </svg>
12
- `;
17
+ </svg> `;
@@ -95,5 +95,22 @@ export default async function ActionsHandlerService(options) {
95
95
  window?.Sentry?.captureException(
96
96
  `${sentryLogs.actionsHandlerService} - Error: ${error}`
97
97
  );
98
+
99
+ /**
100
+ * Report it, don't just swallow it. A rejected fetch (offline, dropped
101
+ * connection) or a non-JSON body (a 405 returning HTML, which QA has
102
+ * been seeing) lands here, and calling neither `success` nor `error`
103
+ * leaves every caller waiting on a callback that never comes.
104
+ *
105
+ * For renew_loan that's not merely a missing modal: IABookActions
106
+ * clears its loanRenewInProgress guard from these callbacks, so a
107
+ * silent failure latched it on for good and every later renewal
108
+ * attempt became a no-op. Backgrounding a tab on a flaky mobile
109
+ * connection is exactly when this fires.
110
+ */
111
+ log(`[IABookActions] ✗ ${option.action} threw`, error);
112
+ option?.error({
113
+ error: `Could not reach the lending service. Please check your connection and try again. (${error})`,
114
+ });
98
115
  }
99
116
  }
@@ -6,7 +6,11 @@ import log from '../log.js';
6
6
 
7
7
  import ActionsHandlerService from './actions-handler-service.js';
8
8
  import LoanAnanlytics from '../loan-analytics.js';
9
- import { analyticsCategories, analyticsActions, analyticsLabels } from '../../config/analytics-event-and-category.js';
9
+ import {
10
+ analyticsCategories,
11
+ analyticsActions,
12
+ analyticsLabels,
13
+ } from '../../config/analytics-event-and-category.js';
10
14
  import * as Cookies from '../doc-cookies.js';
11
15
 
12
16
  /**
@@ -172,32 +176,53 @@ export default class ActionsHandler extends LitElement {
172
176
  action,
173
177
  identifier: this.identifier,
174
178
  success: async data => {
175
- log('RENEW_LOAN --- ', data, this.identifier);
176
- const activeLoan = data.loan ? data.loan : undefined;
177
- const isRenewal = activeLoan.renewal;
178
-
179
- if (activeLoan && isRenewal) {
180
- // Await — loanAutoRenewed listeners read this same cache key
181
- // immediately; dispatching before the write lands was a race.
182
- await this.setBrowseTimeSession();
183
-
184
- // Only record the renew analytics event once the renewal has
185
- // actually succeeded — not merely attempted.
186
- await this.loanAnanlytics?.storeLoanStatsCount(
187
- this.identifier,
188
- 'autorenew'
189
- );
190
- const analyticsLabel =
191
- renewType === 'auto'
192
- ? analyticsLabels.browseAutoRenew
193
- : analyticsLabels.browseManualRenew;
194
- this.loanAnanlytics?.sendEvent(
195
- analyticsCategories.browse,
196
- analyticsActions.browseRenew,
197
- analyticsLabel,
198
- this.identifier
199
- );
200
- } else {
179
+ // Anything thrown in here is an unhandled rejection that dispatches
180
+ // NOTHING — and IABookActions only clears loanRenewInProgress when
181
+ // one of these two events arrives, so a throw here latches it on
182
+ // permanently (dead countdown, loan stuck "active"). Always exit
183
+ // through exactly one of the two dispatches below.
184
+ try {
185
+ log('RENEW_LOAN --- ', data, this.identifier);
186
+ const activeLoan = data.loan ? data.loan : undefined;
187
+ // Optional chaining: `data.loan` is genuinely absent on some
188
+ // failures, and reading `.renewal` off undefined threw here.
189
+ const isRenewal = activeLoan?.renewal;
190
+
191
+ if (activeLoan && isRenewal) {
192
+ // Await — loanAutoRenewed listeners read this same cache key
193
+ // immediately; dispatching before the write lands was a race.
194
+ await this.setBrowseTimeSession();
195
+
196
+ // Only record the renew analytics event once the renewal has
197
+ // actually succeeded — not merely attempted.
198
+ await this.loanAnanlytics?.storeLoanStatsCount(
199
+ this.identifier,
200
+ 'autorenew'
201
+ );
202
+ const analyticsLabel =
203
+ renewType === 'auto'
204
+ ? analyticsLabels.browseAutoRenew
205
+ : analyticsLabels.browseManualRenew;
206
+ this.loanAnanlytics?.sendEvent(
207
+ analyticsCategories.browse,
208
+ analyticsActions.browseRenew,
209
+ analyticsLabel,
210
+ this.identifier
211
+ );
212
+
213
+ // Dispatch the success outcome ONLY for a confirmed renewal.
214
+ // This used to fire on the failure path too, so a
215
+ // `{loan: {renewal: false}}` response reported an error AND a
216
+ // success: the error modal went up while handleLoanAutoRenewed
217
+ // simultaneously put a fresh countdown back on the bar.
218
+ this.dispatchEvent(
219
+ new CustomEvent('loanAutoRenewed', {
220
+ detail: { action, data: { ...data, loan: activeLoan } },
221
+ })
222
+ );
223
+ return;
224
+ }
225
+
201
226
  log('RENEW_LOAN ERROR --- ', {
202
227
  action,
203
228
  isRenewal,
@@ -213,14 +238,17 @@ export default class ActionsHandler extends LitElement {
213
238
  error: true,
214
239
  message: 'Loan renewal failed: no loan active.',
215
240
  });
241
+ } catch (error) {
242
+ log('RENEW_LOAN THREW --- ', error);
243
+ window?.Sentry?.captureException(
244
+ `${sentryLogs.bookRenewFailed} - Exception: ${error}`
245
+ );
246
+ this.dispatchActionError(action, {
247
+ data,
248
+ error: true,
249
+ message: `Loan renewal failed: ${error}`,
250
+ });
216
251
  }
217
-
218
- // dispatch outcome of loan renewal
219
- this.dispatchEvent(
220
- new CustomEvent('loanAutoRenewed', {
221
- detail: { action, data: { ...data, loan: activeLoan } },
222
- })
223
- );
224
252
  },
225
253
  error: data => {
226
254
  this.dispatchActionError(action, data);
@@ -372,6 +400,12 @@ export default class ActionsHandler extends LitElement {
372
400
  new Date().getTime() + this.loanTotalTime * 1000
373
401
  );
374
402
 
403
+ log('[ActionsHandler] setBrowseTimeSession: resetting loanTime', {
404
+ identifier: this.identifier,
405
+ expireDate,
406
+ loanTotalTime: this.loanTotalTime,
407
+ });
408
+
375
409
  // set a value
376
410
  await this.localCache.set({
377
411
  key: `${this.identifier}-loanTime`,
@@ -381,12 +415,20 @@ export default class ActionsHandler extends LitElement {
381
415
 
382
416
  // delete pageChangedTime when book is auto renew at nth minute
383
417
  await this.localCache.delete(`${this.identifier}-pageChangedTime`);
418
+
419
+ log('[ActionsHandler] setBrowseTimeSession: loanTime reset complete', {
420
+ identifier: this.identifier,
421
+ });
384
422
  } catch (error) {
385
- log(error);
423
+ log('[ActionsHandler] setBrowseTimeSession failed', error);
386
424
  }
387
425
  }
388
426
 
389
427
  deleteLoanCookies() {
428
+ log('[ActionsHandler] deleteLoanCookies: expiring loan cookies', {
429
+ identifier: this.identifier,
430
+ });
431
+
390
432
  const date = new Date();
391
433
  date.setTime(date.getTime() - 24 * 60 * 60 * 1000); // one day ago
392
434
 
@@ -414,7 +456,8 @@ export default class ActionsHandler extends LitElement {
414
456
  * @returns {void}
415
457
  */
416
458
  setStickyAdminAccess(value) {
417
- const domain = window.location.hostname === 'localhost' ? 'localhost' : '.archive.org';
459
+ const domain =
460
+ window.location.hostname === 'localhost' ? 'localhost' : '.archive.org';
418
461
  const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days from now
419
462
  Cookies.setItem('sticky-admin-access', value, expires, '/', domain);
420
463
  }
@@ -159,17 +159,6 @@ export default class LoanAnanlytics {
159
159
  * @memberof LoanAnanlytics
160
160
  */
161
161
  sendEvent(eventCategory, eventAction, label, extraParams) {
162
- log(
163
- 'eventCategory:-',
164
- eventCategory,
165
- '||\teventAction:-',
166
- eventAction,
167
- '||\tlabel:-',
168
- label,
169
- '||\textraParams:-',
170
- extraParams,
171
- );
172
-
173
162
  window?.archive_analytics?.send_event_no_sampling(
174
163
  eventCategory,
175
164
  eventAction,
@@ -26,7 +26,7 @@ export class LoanRenewHelper {
26
26
  };
27
27
  }
28
28
 
29
- async handleLoanRenew() {
29
+ handleLoanRenew() {
30
30
  try {
31
31
  if (this.hasPageChanged) {
32
32
  return this.pageChanged(); // user clicked on page
@@ -99,7 +99,7 @@ export class LoanRenewHelper {
99
99
  } else if (pageChangedTime >= pageChangeTimeFrame) {
100
100
  this.result = {
101
101
  texts: '',
102
- renewNow: true, // viewed in last time frame
102
+ renewNow: true, // viewed in last time frame
103
103
  renewType: 'auto',
104
104
  };
105
105
  }
@@ -2,6 +2,7 @@
2
2
  import ActionsHandlerService from './actions-handler/actions-handler-service.js';
3
3
  import LoanAnanlytics from './loan-analytics.js';
4
4
  import { sentryLogs } from '../config/sentry-events.js';
5
+ import log from './log.js';
5
6
 
6
7
  /**
7
8
  * This class is used to create loan token for borrowed books
@@ -27,8 +28,16 @@ export class LoanTokenPoller {
27
28
  * the loan is genuinely valid. Retry a few times with backoff instead
28
29
  * of giving up on the first attempt.
29
30
  */
30
- this.maxInitialTokenRetries = 3;
31
- this.initialTokenRetryDelay = 1000; // ms, multiplied by attempt number
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;
32
41
 
33
42
  /**
34
43
  * loan analytics instance
@@ -41,6 +50,8 @@ export class LoanTokenPoller {
41
50
 
42
51
  disconnectedCallback() {
43
52
  window?.IALendingIntervals?.clearTokenPoller();
53
+ clearTimeout(this.retryTimeout);
54
+ this.retryTimeout = undefined;
44
55
  }
45
56
 
46
57
  async bookAccessed() {
@@ -77,11 +88,21 @@ export class LoanTokenPoller {
77
88
  */
78
89
  async handleLoanTokenPoller(isInitial = false, retryCount = 0) {
79
90
  const action = 'create_token';
91
+ log('[LoanTokenPoller] create_token requested', {
92
+ identifier: this.identifier,
93
+ isInitial,
94
+ retryCount,
95
+ });
80
96
  ActionsHandlerService({
81
97
  identifier: this.identifier,
82
98
  action,
83
99
  error: data => this.handleTokenError(data, isInitial, retryCount),
84
100
  success: () => {
101
+ log('[LoanTokenPoller] create_token succeeded', {
102
+ identifier: this.identifier,
103
+ isInitial,
104
+ retryCount,
105
+ });
85
106
  if (isInitial) this.successCallback();
86
107
  },
87
108
  });
@@ -104,19 +125,46 @@ export class LoanTokenPoller {
104
125
  typeof data?.error === 'string' &&
105
126
  /do not currently have this book borrowed/i.test(data.error);
106
127
 
107
- if (
108
- isInitial &&
109
- isStaleLoanReadError &&
110
- retryCount < this.maxInitialTokenRetries
111
- ) {
112
- const delay = this.initialTokenRetryDelay * (retryCount + 1);
113
- setTimeout(() => {
114
- this.handleLoanTokenPoller(true, retryCount + 1);
128
+ log('[LoanTokenPoller] create_token failed', {
129
+ identifier: this.identifier,
130
+ isInitial,
131
+ retryCount,
132
+ isStaleLoanReadError,
133
+ error: data?.error,
134
+ });
135
+
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);
115
154
  }, delay);
116
155
  return;
117
156
  }
118
157
 
119
- this.errorCallback({ detail: { action, data } });
158
+ log('[LoanTokenPoller] giving up on create_token, reporting error', {
159
+ identifier: this.identifier,
160
+ isInitial,
161
+ retryCount,
162
+ });
163
+
164
+ // isInitial rides along so the consumer can distinguish "the book won't
165
+ // open at all" from "a mid-read refresh blipped" — see
166
+ // IABookActions.handleLendingActionError.
167
+ this.errorCallback({ detail: { action, data, isInitial } });
120
168
 
121
169
  // send error to Sentry
122
170
  window?.Sentry?.captureMessage(
@@ -61,7 +61,6 @@ export default class IABookActions extends LitElement {
61
61
  modal: { Object },
62
62
  tokenDelay: { type: Number },
63
63
  timerExecutionSeconds: { type: Number },
64
- userActionGracePeriod: { type: Number },
65
64
  localCache: { type: Object },
66
65
  loanRenewTimeConfig: { type: Object },
67
66
  loanRenewResult: { type: Object },
@@ -84,14 +83,6 @@ export default class IABookActions extends LitElement {
84
83
  this.tokenDelay = 120; // in seconds
85
84
  this.timerExecutionSeconds = 30;
86
85
 
87
- /**
88
- * How long (ms) after bindLoanRenewEvents() runs to ignore
89
- * BookReader:userAction events — BookReader fires that event on its
90
- * own init-time jump to the last-read page, not just on genuine
91
- * interaction. @see bindLoanRenewEvents
92
- */
93
- this.userActionGracePeriod = 2000;
94
-
95
86
  // private props
96
87
  this.postInitComplete = false;
97
88
  this.primaryActions = [];
@@ -241,17 +232,58 @@ export default class IABookActions extends LitElement {
241
232
  }
242
233
  /** End SharedObserver resize handler */
243
234
 
235
+ /**
236
+ * Recompute the action bar (title, buttons, colors, borrowType) from the
237
+ * current lendingStatus.
238
+ *
239
+ * @returns {boolean} false when there are no actions to apply, in which
240
+ * case callers should bail out rather than continue with stale state.
241
+ */
242
+ applyLendingActions() {
243
+ this.lendingOptions = new GetLendingActions(
244
+ this.userid,
245
+ this.identifier,
246
+ this.lendingStatus,
247
+ this.bwbPurchaseUrl
248
+ );
249
+ const actions = this.lendingOptions.getCurrentLendingActions();
250
+
251
+ if (!actions) return false;
252
+
253
+ this.primaryTitle = actions.primaryTitle;
254
+ this.primaryActions = actions.primaryActions?.filter(action => {
255
+ return action != null;
256
+ });
257
+ this.primaryColor = actions.primaryColor;
258
+ this.secondaryActions = actions.secondaryActions?.filter(action => {
259
+ return action != null;
260
+ });
261
+
262
+ this.borrowType = actions.borrowType ? actions.borrowType : null;
263
+
264
+ return true;
265
+ }
266
+
244
267
  async setupLendingToolbarActions() {
245
268
  const hasExpired =
246
269
  'browsingExpired' in this.lendingStatus &&
247
270
  this.lendingStatus?.browsingExpired;
248
271
  if (hasExpired) {
249
- // Intentionally do NOT recompute primaryActions/primaryColor/borrowType
250
- // here — per the ticket, auto-return must not visibly change anything.
251
- // The action bar stays exactly as it looked while reading; only a
252
- // successful silent renewal or the "unavailable" error modal are
253
- // allowed to change what's shown next.
254
- log('[IABookActions] browsing expired — leaving action bar untouched');
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.
279
+ if (this.primaryActions?.length) {
280
+ log('[IABookActions] browsing expired — leaving action bar untouched');
281
+ } else {
282
+ log(
283
+ '[IABookActions] browsing expired on first render — populating action bar'
284
+ );
285
+ this.applyLendingActions();
286
+ }
255
287
 
256
288
  if (!this.tokenPoller) {
257
289
  this.sentryCaptureMsg(sentryLogs.bookWasExpired);
@@ -271,26 +303,7 @@ export default class IABookActions extends LitElement {
271
303
  return;
272
304
  }
273
305
 
274
- this.lendingOptions = new GetLendingActions(
275
- this.userid,
276
- this.identifier,
277
- this.lendingStatus,
278
- this.bwbPurchaseUrl
279
- );
280
- const actions = this.lendingOptions.getCurrentLendingActions();
281
-
282
- if (!actions) return;
283
-
284
- this.primaryTitle = actions.primaryTitle;
285
- this.primaryActions = actions.primaryActions?.filter(action => {
286
- return action != null;
287
- });
288
- this.primaryColor = actions.primaryColor;
289
- this.secondaryActions = actions.secondaryActions?.filter(action => {
290
- return action != null;
291
- });
292
-
293
- this.borrowType = actions.borrowType ? actions.borrowType : null;
306
+ if (!this.applyLendingActions()) return;
294
307
 
295
308
  // Don't (re)start the countdown while a renewal is in flight — the
296
309
  // optimistic browsingExpired flip in autoRenewExpiredLoan() (which
@@ -336,6 +349,32 @@ export default class IABookActions extends LitElement {
336
349
  this.requestUpdate();
337
350
  }
338
351
 
352
+ /**
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
+ *
371
+ * @param {Event} event - the BookReader:userAction event
372
+ * @returns {boolean}
373
+ */
374
+ isBookReaderInitAction(event) {
375
+ return event?.detail?.props?.init?.initComplete === false;
376
+ }
377
+
339
378
  /**
340
379
  * Bind 1 hour loan auto renew event,
341
380
  * There are two events we want to use,
@@ -343,15 +382,13 @@ export default class IABookActions extends LitElement {
343
382
  * 2. IABookActions:loanRenew - dispatched from timer-countdown component
344
383
  */
345
384
  bindLoanRenewEvents() {
346
- this.userActionReadyAt = Date.now() + this.userActionGracePeriod;
347
-
348
385
  /**
349
386
  * dispatched this event from bookreader page changed
350
387
  */
351
- window.addEventListener('BookReader:userAction', () => {
352
- if (Date.now() < this.userActionReadyAt) {
388
+ window.addEventListener('BookReader:userAction', event => {
389
+ if (this.isBookReaderInitAction(event)) {
353
390
  log(
354
- '[IABookActions] BookReader:userAction ignored — within startup grace period'
391
+ '[IABookActions] BookReader:userAction ignored — fired by BookReader init'
355
392
  );
356
393
  return;
357
394
  }
@@ -392,7 +429,10 @@ export default class IABookActions extends LitElement {
392
429
  return;
393
430
  }
394
431
 
395
- log('[IABookActions] visibilitychange: tab foregrounded', this.borrowType);
432
+ log(
433
+ '[IABookActions] visibilitychange: tab foregrounded',
434
+ this.borrowType
435
+ );
396
436
 
397
437
  try {
398
438
  // Loan already expired while tab was hidden — try to silently renew
@@ -414,9 +454,16 @@ export default class IABookActions extends LitElement {
414
454
  if (secondsLeft >= this.timerExecutionSeconds) {
415
455
  this.loanStatusCheckInterval(Number(secondsLeft));
416
456
  } else {
417
- // Loan expired while user was away — try to silently renew
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.
465
+ window?.IALendingIntervals?.clearAll();
418
466
  this.autoRenewExpiredLoan();
419
- this.disconnectedCallback();
420
467
  }
421
468
  }
422
469
  } catch (error) {
@@ -465,7 +512,18 @@ export default class IABookActions extends LitElement {
465
512
  * On error: handleLendingActionError() shows showLoanUnavailableModal().
466
513
  */
467
514
  autoRenewExpiredLoan() {
468
- if (this.loanRenewInProgress) return;
515
+ if (this.loanRenewInProgress) {
516
+ log(
517
+ '[IABookActions] autoRenewExpiredLoan: skipped, renewal already in progress',
518
+ {
519
+ identifier: this.identifier,
520
+ }
521
+ );
522
+ return;
523
+ }
524
+ log('[IABookActions] autoRenewExpiredLoan: starting silent renewal', {
525
+ identifier: this.identifier,
526
+ });
469
527
  this.loanRenewInProgress = true;
470
528
  this.recoveringFromLoanExpiry = true;
471
529
 
@@ -561,7 +619,8 @@ export default class IABookActions extends LitElement {
561
619
  >
562
620
  Okay
563
621
  </button>
564
- </div> `;
622
+ </div>
623
+ `;
565
624
 
566
625
  this.modal.setAttribute('aria-live', 'assertive');
567
626
  await this.modal?.showModal({ config, customModalContent });
@@ -578,7 +637,10 @@ export default class IABookActions extends LitElement {
578
637
  * Execute when loan is expired
579
638
  */
580
639
  async browseHasExpired() {
581
- log('[IABookActions] browseHasExpired');
640
+ log('[IABookActions] browseHasExpired', {
641
+ identifier: this.identifier,
642
+ loanRenewInProgress: this.loanRenewInProgress,
643
+ });
582
644
  window?.IALendingIntervals?.clearAll();
583
645
 
584
646
  const currStatus = {
@@ -591,6 +653,12 @@ export default class IABookActions extends LitElement {
591
653
  // remove respected key:value for loan-renew
592
654
  await this.localCache.delete(`${this.identifier}-loanTime`);
593
655
  await this.localCache.delete(`${this.identifier}-pageChangedTime`);
656
+ log(
657
+ '[IABookActions] browseHasExpired: cleared loanTime/pageChangedTime cache',
658
+ {
659
+ identifier: this.identifier,
660
+ }
661
+ );
594
662
 
595
663
  // show message after browsed book is expired.
596
664
  this.loanRenewResult.renewNow = false;
@@ -598,6 +666,10 @@ export default class IABookActions extends LitElement {
598
666
  'This book has been returned due to inactivity.';
599
667
 
600
668
  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.
672
+ this.warningModalOpen = false;
601
673
 
602
674
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
603
675
  }
@@ -712,10 +784,34 @@ export default class IABookActions extends LitElement {
712
784
  */
713
785
  async handleLoanAutoRenewed({ detail }) {
714
786
  const activeLoan = detail?.data?.loan;
715
- const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
716
- if (!activeLoan) {
787
+
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
+ */
804
+ if (!activeLoan?.renewal) {
805
+ this.loanRenewInProgress = false;
717
806
  this.recoveringFromLoanExpiry = false;
718
- this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
807
+ this.warningModalOpen = false;
808
+
809
+ // dispatchActionError() has already reported this and shown the
810
+ // relevant modal; don't stack a second one on top.
811
+ log('[IABookActions] handleLoanAutoRenewed: not a confirmed renewal', {
812
+ identifier: this.identifier,
813
+ activeLoan,
814
+ });
719
815
  return;
720
816
  }
721
817
 
@@ -817,9 +913,8 @@ export default class IABookActions extends LitElement {
817
913
  }
818
914
 
819
915
  log('[IABookActions] timer', {
820
- secondsLeft,
821
- loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
822
- pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
916
+ whatShouldLeft: resyncd.whatShouldLeft,
917
+ whatIsleft: secondsLeft,
823
918
  });
824
919
 
825
920
  /**
@@ -907,7 +1002,10 @@ export default class IABookActions extends LitElement {
907
1002
  // show warning modal with remaining time to auto returned it.
908
1003
  // once the patron has dismissed it, don't re-show it on every subsequent
909
1004
  // tick — only an actual renewal (see handleLoanAutoRenewed) re-arms it.
910
- if (this.loanRenewResult.renewNow === false && !this.warningModalDismissed) {
1005
+ if (
1006
+ this.loanRenewResult.renewNow === false &&
1007
+ !this.warningModalDismissed
1008
+ ) {
911
1009
  /**
912
1010
  * so compensate for the 50 second buffer to handle above race conditions
913
1011
  * let's reduce 1 min from warning texts and early return the book when 1 min left.
@@ -977,6 +1075,16 @@ export default class IABookActions extends LitElement {
977
1075
 
978
1076
  const action = event?.detail?.action;
979
1077
  const errorMsg = event?.detail?.data?.error;
1078
+ const isInitial = event?.detail?.isInitial === true;
1079
+
1080
+ log('[IABookActions] handleLendingActionError', {
1081
+ identifier: this.identifier,
1082
+ action,
1083
+ errorMsg,
1084
+ isInitial,
1085
+ loanRenewInProgress: this.loanRenewInProgress,
1086
+ recoveringFromLoanExpiry: this.recoveringFromLoanExpiry,
1087
+ });
980
1088
 
981
1089
  if (action === 'create_token') {
982
1090
  // A create_token hiccup only affects BookReader's page-image access
@@ -985,21 +1093,38 @@ export default class IABookActions extends LitElement {
985
1093
  // token poller so it can be retried.
986
1094
  window?.IALendingIntervals?.clearTokenPoller();
987
1095
 
988
- // The loan itself is fine — just this token refresh failed — but
989
- // the action bar must reflect that it isn't safely browsable right
990
- // now rather than silently doing nothing.
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
+ */
1107
+ if (!isInitial) {
1108
+ log(
1109
+ '[IABookActions] create_token failed on interval refresh — staying silent, poller will retry'
1110
+ );
1111
+ return;
1112
+ }
1113
+
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.
991
1117
  this.lendingStatus = {
992
1118
  ...this.lendingStatus,
993
1119
  user_has_browsed: false,
994
1120
  available_to_browse: true,
995
1121
  };
996
1122
 
997
- // By the time this handler runs, LoanTokenPoller has already
998
- // exhausted its retries for the transient datanode-propagation
999
- // race (or the error wasn't that in the first place) — this is a
1000
- // genuine failure, so let the patron know instead of doing this
1001
- // silently. showErrorModal has dedicated create_token messaging
1002
- // (refresh button + support email).
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
+ // showErrorModal has dedicated create_token messaging (refresh
1127
+ // button + support email).
1003
1128
  if (errorMsg) this.showErrorModal(errorMsg, action);
1004
1129
  } else if (action === 'renew_loan') {
1005
1130
  window?.IALendingIntervals?.clearAll();
@@ -1035,9 +1160,15 @@ export default class IABookActions extends LitElement {
1035
1160
  // update action bar state if book is not available to browse or borrow.
1036
1161
  if (errorMsg.match(/not available to borrow/gm)) {
1037
1162
  if (action === 'browse_book') {
1038
- this.lendingStatus = { ...this.lendingStatus, available_to_browse: false };
1163
+ this.lendingStatus = {
1164
+ ...this.lendingStatus,
1165
+ available_to_browse: false,
1166
+ };
1039
1167
  } else if (action === 'borrow_book') {
1040
- this.lendingStatus = { ...this.lendingStatus, available_to_borrow: false };
1168
+ this.lendingStatus = {
1169
+ ...this.lendingStatus,
1170
+ available_to_borrow: false,
1171
+ };
1041
1172
  }
1042
1173
  }
1043
1174
  }
@@ -1,4 +1,4 @@
1
- import { expect } from '@open-wc/testing';
1
+ import { expect, aTimeout } from '@open-wc/testing';
2
2
  import Sinon from 'sinon';
3
3
 
4
4
  import { LoanTokenPoller } from '../../../src/core/services/loan-token-poller.js';
@@ -89,7 +89,7 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
89
89
 
90
90
  expect(errorCallbackSpy.called).to.be.false;
91
91
 
92
- clock.tick(tokenPoller.initialTokenRetryDelay);
92
+ clock.tick(tokenPoller.tokenRetryDelay);
93
93
 
94
94
  expect(handleLoanTokenPollerSpy.calledWith(true, 1)).to.be.true;
95
95
  } finally {
@@ -104,19 +104,35 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
104
104
  tokenPoller.handleTokenError(
105
105
  staleLoanReadError,
106
106
  true,
107
- tokenPoller.maxInitialTokenRetries
107
+ tokenPoller.maxTokenRetries
108
108
  );
109
109
 
110
110
  expect(errorCallbackSpy.calledOnce).to.be.true;
111
111
  });
112
112
 
113
- it('does not retry a routine (non-initial) poll on the same error', () => {
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.
114
117
  const tokenPoller = makeTokenPoller();
118
+ const handleLoanTokenPollerSpy = Sinon.spy(
119
+ tokenPoller,
120
+ 'handleLoanTokenPoller'
121
+ );
115
122
  const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
123
+ const clock = Sinon.useFakeTimers();
116
124
 
117
- tokenPoller.handleTokenError(staleLoanReadError, false, 0);
125
+ try {
126
+ tokenPoller.handleTokenError(staleLoanReadError, false, 0);
118
127
 
119
- expect(errorCallbackSpy.calledOnce).to.be.true;
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
+ }
120
136
  });
121
137
 
122
138
  it('does not retry a genuinely different error, even on the initial call', () => {
@@ -132,3 +148,53 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
132
148
  expect(errorCallbackSpy.calledOnce).to.be.true;
133
149
  });
134
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
+
172
+ it('passes isInitial through to the error callback', () => {
173
+ const errorCallback = Sinon.spy();
174
+ const poller = new LoanTokenPoller('foo', 'browsed', () => {}, errorCallback, 120);
175
+ poller.disconnectedCallback();
176
+
177
+ poller.handleTokenError({ error: 'something else went wrong' }, true, 0);
178
+
179
+ expect(errorCallback.calledOnce).to.be.true;
180
+ expect(errorCallback.firstCall.args[0].detail.isInitial).to.be.true;
181
+ });
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
+ });
@@ -89,8 +89,6 @@ describe('<ia-book-actions>', () => {
89
89
  identifier: 'foobar',
90
90
  })
91
91
  );
92
- // Not testing the BookReader-init grace period here — bypass it.
93
- el.userActionReadyAt = 0;
94
92
 
95
93
  const spy = Sinon.spy(el, 'autoLoanRenewChecker');
96
94
  el.lendingStatus = { ...el.lendingStatus, browsingExpired: false };
@@ -113,8 +111,6 @@ describe('<ia-book-actions>', () => {
113
111
  lendingStatus: { user_has_browsed: true, browsingExpired: true },
114
112
  })
115
113
  );
116
- // Not testing the BookReader-init grace period here — bypass it.
117
- el.userActionReadyAt = 0;
118
114
 
119
115
  const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
120
116
  el.borrowType = 'browsed';
@@ -126,14 +122,17 @@ describe('<ia-book-actions>', () => {
126
122
  expect(spy.calledOnce).to.be.true;
127
123
  });
128
124
 
129
- it('ignores BookReader:userAction fired within the startup grace period (WEBDEV-8322 follow-up)', async () => {
130
- // Regression: BookReader.jumpToIndex() fires its own 'userAction'
131
- // event any time it runs — including BookReader's own init-time jump
132
- // to the reader's last-read page (updateFromParams(), called from
125
+ it('ignores BookReader:userAction fired by BookReader init (WEBDEV-8322 follow-up)', async () => {
126
+ // Regression: BookReader.updateFirstIndex() fires 'userAction'
127
+ // unconditionally — including on BookReader's own init-time jump to
128
+ // the reader's last-read page (updateFromParams(), called from
133
129
  // init()), which has nothing to do with the patron doing anything.
134
- // Without this grace period, a plain page refresh on an already-
135
- // expired loan would silently auto-renew before the patron did
136
- // anything at all.
130
+ // Auto-renewing off that would silently re-borrow a lapsed book on a
131
+ // plain page refresh, before the patron did anything at all.
132
+ //
133
+ // BookReader.trigger() passes the BookReader instance as
134
+ // detail.props, and init() holds init.initComplete === false for
135
+ // exactly the span containing that jump.
137
136
  const el = await fixture(
138
137
  container({
139
138
  userid: '@user1',
@@ -146,13 +145,60 @@ describe('<ia-book-actions>', () => {
146
145
 
147
146
  const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
148
147
 
149
- // Fired immediately after bindLoanRenewEvents() ran (in firstUpdated())
150
- // — well within the default grace period.
151
- window.dispatchEvent(new Event('BookReader:userAction'));
148
+ window.dispatchEvent(
149
+ new CustomEvent('BookReader:userAction', {
150
+ detail: { props: { init: { initComplete: false } } },
151
+ })
152
+ );
152
153
  await el.updateComplete;
153
154
 
154
155
  expect(spy.called).to.be.false;
155
156
  });
157
+
158
+ it('honours BookReader:userAction once BookReader init has completed', async () => {
159
+ const el = await fixture(
160
+ container({
161
+ userid: '@user1',
162
+ identifier: 'foobar',
163
+ lendingStatus: { user_has_browsed: true, browsingExpired: true },
164
+ })
165
+ );
166
+ el.borrowType = 'browsed';
167
+ await el.updateComplete;
168
+
169
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
170
+
171
+ window.dispatchEvent(
172
+ new CustomEvent('BookReader:userAction', {
173
+ detail: { props: { init: { initComplete: true } } },
174
+ })
175
+ );
176
+ await el.updateComplete;
177
+
178
+ expect(spy.calledOnce).to.be.true;
179
+ });
180
+
181
+ it('treats a userAction with no BookReader detail as a genuine interaction', async () => {
182
+ // A BookReader too old to expose init.initComplete, or any synthetic
183
+ // dispatch, must not be silently swallowed — only an explicit false
184
+ // suppresses the event.
185
+ const el = await fixture(
186
+ container({
187
+ userid: '@user1',
188
+ identifier: 'foobar',
189
+ lendingStatus: { user_has_browsed: true, browsingExpired: true },
190
+ })
191
+ );
192
+ el.borrowType = 'browsed';
193
+ await el.updateComplete;
194
+
195
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
196
+
197
+ window.dispatchEvent(new Event('BookReader:userAction'));
198
+ await el.updateComplete;
199
+
200
+ expect(spy.calledOnce).to.be.true;
201
+ });
156
202
  });
157
203
 
158
204
  describe('Primary Actions data', () => {
@@ -741,7 +787,7 @@ describe('autoRenewExpiredLoan', () => {
741
787
  );
742
788
  collapsibleActionGroupEl.dispatchEvent(
743
789
  new CustomEvent('loanAutoRenewed', {
744
- detail: { data: { loan: { identifier: 'foobar' } } },
790
+ detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
745
791
  })
746
792
  );
747
793
 
@@ -792,7 +838,7 @@ describe('autoRenewExpiredLoan', () => {
792
838
  );
793
839
  collapsibleActionGroupEl.dispatchEvent(
794
840
  new CustomEvent('loanAutoRenewed', {
795
- detail: { data: { loan: { identifier: 'foobar' } } },
841
+ detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
796
842
  })
797
843
  );
798
844
 
@@ -848,7 +894,7 @@ describe('autoRenewExpiredLoan', () => {
848
894
  );
849
895
  collapsibleActionGroupEl.dispatchEvent(
850
896
  new CustomEvent('loanAutoRenewed', {
851
- detail: { data: { loan: { identifier: 'foobar' } } },
897
+ detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
852
898
  })
853
899
  );
854
900
 
@@ -884,7 +930,7 @@ describe('autoRenewExpiredLoan', () => {
884
930
  );
885
931
  collapsibleActionGroupEl.dispatchEvent(
886
932
  new CustomEvent('loanAutoRenewed', {
887
- detail: { data: { loan: { identifier: 'foobar' } } },
933
+ detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
888
934
  })
889
935
  );
890
936
 
@@ -944,8 +990,6 @@ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
944
990
  );
945
991
  await el.updateComplete;
946
992
  expect(el.borrowType).to.equal('browsed');
947
- // Not testing the BookReader-init grace period here — bypass it.
948
- el.userActionReadyAt = 0;
949
993
 
950
994
  el.lendingStatus = { ...baseStatus, browsingExpired: true };
951
995
  await el.updateComplete;
@@ -987,8 +1031,6 @@ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
987
1031
  );
988
1032
  await el.updateComplete;
989
1033
  expect(el.borrowType).to.equal('browsed');
990
- // Not testing the BookReader-init grace period here — bypass it.
991
- el.userActionReadyAt = 0;
992
1034
 
993
1035
  el.loanRenewInProgress = true;
994
1036
 
@@ -1062,9 +1104,11 @@ describe('handleLendingActionError - create_token failures must not stop the cou
1062
1104
  it('shows the error modal for a create_token failure that reaches here (retries already exhausted)', async () => {
1063
1105
  // By the time handleLendingActionError runs for a create_token
1064
1106
  // failure, LoanTokenPoller has already retried and given up (see
1065
- // loan-token-poller.js's handleTokenError) — this is a genuine
1066
- // failure, so the patron must be told instead of it happening
1067
- // silently.
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.)
1068
1112
  const el = await fixture(
1069
1113
  container({
1070
1114
  userid: '@user1',
@@ -1084,6 +1128,7 @@ describe('handleLendingActionError - create_token failures must not stop the cou
1084
1128
  el.handleLendingActionError({
1085
1129
  detail: {
1086
1130
  action: 'create_token',
1131
+ isInitial: true,
1087
1132
  data: { error: errorMsg },
1088
1133
  },
1089
1134
  });
@@ -1316,3 +1361,183 @@ describe('Shared Resize Observer', () => {
1316
1361
  expect(component.sharedObserver).to.not.be.undefined;
1317
1362
  });
1318
1363
  });
1364
+
1365
+ describe('WEBDEV-8322 review fixes', () => {
1366
+ it('populates the action bar on a fresh load of an already-expired loan', async () => {
1367
+ // Regression: the hasExpired early-return was hoisted above the
1368
+ // GetLendingActions computation so auto-return wouldn't visibly change
1369
+ // the bar. That's right for the transition, but this method also runs
1370
+ // on the FIRST lendingStatus update, where there is no bar to preserve
1371
+ // — leaving the patron a blank bar and no way to re-borrow.
1372
+ const el = await fixture(
1373
+ container({
1374
+ userid: '@user1',
1375
+ identifier: 'fresh-expired',
1376
+ lendingStatus: {
1377
+ is_lendable: true,
1378
+ available_to_browse: true,
1379
+ available_to_borrow: true,
1380
+ user_has_browsed: true,
1381
+ browsingExpired: true,
1382
+ secondsLeftOnLoan: 0,
1383
+ },
1384
+ })
1385
+ );
1386
+ await el.updateComplete;
1387
+
1388
+ expect(el.primaryActions.length).to.be.greaterThan(0);
1389
+ expect(el.primaryActions.map(a => a.id)).to.include('browseBook');
1390
+ });
1391
+
1392
+ it('still leaves a rendered action bar untouched when the loan auto-returns', async () => {
1393
+ const el = await fixture(
1394
+ container({
1395
+ userid: '@user1',
1396
+ identifier: 'auto-returned',
1397
+ lendingStatus: {
1398
+ is_lendable: true,
1399
+ available_to_browse: true,
1400
+ available_to_borrow: true,
1401
+ user_has_browsed: true,
1402
+ browsingExpired: false,
1403
+ secondsLeftOnLoan: 300,
1404
+ },
1405
+ })
1406
+ );
1407
+ await el.updateComplete;
1408
+ expect(el.primaryActions[0].text).to.equal('Return now');
1409
+
1410
+ el.lendingStatus = { ...el.lendingStatus, browsingExpired: true };
1411
+ await el.updateComplete;
1412
+
1413
+ // Auto-return must not visibly change the bar (the ticket's core ask).
1414
+ expect(el.primaryActions[0].text).to.equal('Return now');
1415
+ });
1416
+
1417
+ it('does not treat a `renewal: false` response as a successful renewal', async () => {
1418
+ // Regression: handleLoanAutoRenewed saw a truthy `loan` and took the
1419
+ // success path. setBrowseTimeSession() never ran, so the loanTime read
1420
+ // was undefined -> NaN -> the fallback invented a full fresh hour,
1421
+ // putting a new countdown on screen next to the failure modal.
1422
+ const el = await fixture(
1423
+ container({
1424
+ userid: '@user1',
1425
+ identifier: 'not-renewed',
1426
+ lendingStatus: {
1427
+ is_lendable: true,
1428
+ available_to_browse: true,
1429
+ user_has_browsed: true,
1430
+ browsingExpired: true,
1431
+ secondsLeftOnLoan: 0,
1432
+ },
1433
+ })
1434
+ );
1435
+ await el.updateComplete;
1436
+ el.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
1437
+ el.loanRenewInProgress = true;
1438
+ await el.updateComplete;
1439
+
1440
+ await el.handleLoanAutoRenewed({
1441
+ detail: {
1442
+ action: 'renew_loan',
1443
+ data: { success: true, loan: { renewal: false } },
1444
+ },
1445
+ });
1446
+ await el.updateComplete;
1447
+
1448
+ expect(el.lendingStatus.secondsLeftOnLoan).to.not.equal(
1449
+ el.loanRenewTimeConfig.loanTotalTime
1450
+ );
1451
+ expect(el.loanRenewInProgress).to.be.false;
1452
+ });
1453
+
1454
+ it('clears loanRenewInProgress when the renewal response carries no loan', async () => {
1455
+ // Regression: the !activeLoan branch cleared recoveringFromLoanExpiry
1456
+ // but left loanRenewInProgress set, latching the component — every
1457
+ // later renewal attempt no-ops and neither the countdown nor the token
1458
+ // poller ever restarts.
1459
+ const el = await fixture(
1460
+ container({
1461
+ userid: '@user1',
1462
+ identifier: 'no-loan',
1463
+ lendingStatus: { user_has_browsed: true, browsingExpired: true },
1464
+ })
1465
+ );
1466
+ await el.updateComplete;
1467
+
1468
+ el.autoRenewExpiredLoan();
1469
+ expect(el.loanRenewInProgress).to.be.true;
1470
+
1471
+ await el.handleLoanAutoRenewed({
1472
+ detail: { action: 'renew_loan', data: { success: true } },
1473
+ });
1474
+
1475
+ expect(el.loanRenewInProgress).to.be.false;
1476
+ expect(el.recoveringFromLoanExpiry).to.be.false;
1477
+
1478
+ // and the component is usable again, not latched
1479
+ el.autoRenewExpiredLoan();
1480
+ expect(el.loanRenewInProgress).to.be.true;
1481
+ });
1482
+
1483
+ it('stays silent when an interval create_token refresh fails', async () => {
1484
+ const el = await fixture(
1485
+ container({
1486
+ userid: '@user1',
1487
+ identifier: 'token-blip',
1488
+ lendingStatus: {
1489
+ is_lendable: true,
1490
+ available_to_browse: true,
1491
+ user_has_browsed: true,
1492
+ browsingExpired: false,
1493
+ secondsLeftOnLoan: 300,
1494
+ },
1495
+ })
1496
+ );
1497
+ await el.updateComplete;
1498
+ const spy = Sinon.spy(el, 'showErrorModal');
1499
+
1500
+ el.handleLendingActionError({
1501
+ detail: {
1502
+ action: 'create_token',
1503
+ isInitial: false,
1504
+ data: { error: 'loan token not found. please try again later.' },
1505
+ },
1506
+ });
1507
+ await el.updateComplete;
1508
+
1509
+ expect(spy.called).to.be.false;
1510
+ // the patron is still reading — don't knock them out of the loan
1511
+ expect(el.lendingStatus.user_has_browsed).to.be.true;
1512
+ });
1513
+
1514
+ it('surfaces a failed initial create_token, which blocks opening the book', async () => {
1515
+ const el = await fixture(
1516
+ container({
1517
+ userid: '@user1',
1518
+ identifier: 'token-initial',
1519
+ lendingStatus: {
1520
+ is_lendable: true,
1521
+ available_to_browse: true,
1522
+ user_has_browsed: true,
1523
+ browsingExpired: false,
1524
+ secondsLeftOnLoan: 300,
1525
+ },
1526
+ })
1527
+ );
1528
+ await el.updateComplete;
1529
+ const spy = Sinon.spy(el, 'showErrorModal');
1530
+
1531
+ el.handleLendingActionError({
1532
+ detail: {
1533
+ action: 'create_token',
1534
+ isInitial: true,
1535
+ data: { error: 'loan token not found. please try again later.' },
1536
+ },
1537
+ });
1538
+ await el.updateComplete;
1539
+
1540
+ expect(spy.calledOnce).to.be.true;
1541
+ expect(el.lendingStatus.user_has_browsed).to.be.false;
1542
+ });
1543
+ });