@internetarchive/ia-book-actions 1.4.4-alpha5 → 1.4.4-alpha7

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-alpha5",
10
+ "version": "1.4.4-alpha7",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -407,6 +407,16 @@ export default class IABookActions extends LitElement {
407
407
  * @param {Boolean} hasPageChanged
408
408
  */
409
409
  async autoLoanRenewChecker(hasPageChanged = false) {
410
+ // Guards against re-entrancy the same way autoRenewExpiredLoan() does.
411
+ // BookReader:userAction can fire several times in quick succession
412
+ // (e.g. a single scroll gesture), and this method has no debouncing of
413
+ // its own — without this guard, each event would spin up its own
414
+ // LoanRenewHelper concurrently, and whichever's async localCache reads
415
+ // resolve last would clobber this.loanRenewResult, potentially
416
+ // re-triggering the whole renew_loan/create_token flow while a
417
+ // renewal from an earlier event is already in flight or just landed.
418
+ if (this.loanRenewInProgress) return;
419
+
410
420
  this.loanRenewHelper = new LoanRenewHelper(
411
421
  hasPageChanged,
412
422
  this.identifier,
@@ -568,13 +578,14 @@ export default class IABookActions extends LitElement {
568
578
  * Show modal when the book can no longer be automatically renewed
569
579
  * because another patron has checked it out.
570
580
  */
571
- async showLoanUnavailableModal() {
581
+ async showLoanUnavailableModal(errorMsg) {
572
582
  const config = new ModalConfig({
573
583
  headline: '',
574
584
  showCloseButton: false,
575
585
  closeOnBackdropClick: false,
576
586
  headerColor: '#d9534f',
577
587
  message:
588
+ errorMsg ||
578
589
  'Due to inactivity, this book was returned, and someone else has now borrowed it. Please try again later.',
579
590
  });
580
591
 
@@ -934,60 +945,60 @@ export default class IABookActions extends LitElement {
934
945
  const action = event?.detail?.action;
935
946
  const errorMsg = event?.detail?.data?.error;
936
947
 
937
- // A create_token hiccup only affects BookReader's page-image access
938
- // token — it says nothing about how much time is left on the loan
939
- // itself, so it must not stop the reading countdown. Only clear the
940
- // token poller so it can be retried. Every other action failure
941
- // (renew_loan, browse_book, etc.) genuinely affects loan state, so it
942
- // still clears everything as before.
943
948
  if (action === 'create_token') {
949
+ // A create_token hiccup only affects BookReader's page-image access
950
+ // token — it says nothing about how much time is left on the loan
951
+ // itself, so it must not stop the reading countdown. Only clear the
952
+ // token poller so it can be retried.
944
953
  window?.IALendingIntervals?.clearTokenPoller();
945
- } else {
946
- window?.IALendingIntervals?.clearAll();
947
- }
948
954
 
949
- if (action === 'renew_loan') {
955
+ // The loan itself is fine — just this token refresh failed — but
956
+ // the action bar must reflect that it isn't safely browsable right
957
+ // now rather than silently doing nothing.
958
+ this.lendingStatus = {
959
+ ...this.lendingStatus,
960
+ user_has_browsed: false,
961
+ available_to_browse: true,
962
+ };
963
+ } else if (action === 'renew_loan') {
964
+ window?.IALendingIntervals?.clearAll();
950
965
  this.loanRenewInProgress = false;
951
966
  this.recoveringFromLoanExpiry = false;
952
- }
953
-
954
- // For renew_loan failures after auto-renew, show the patron-facing message
955
- if (
956
- errorMsg &&
957
- action === 'renew_loan' &&
958
- errorMsg.match(/not available/)
959
- ) {
960
- this.showLoanUnavailableModal();
961
- } else if (errorMsg && action !== 'create_token') {
962
- this.showErrorModal(errorMsg, action);
963
- }
964
967
 
965
- // if error related to loan token
966
- // - set user_has_browsed to `false`
967
- if (action === 'create_token') {
968
- const currStatus = {
968
+ // The loan was NOT actually renewed — reflect that immediately
969
+ // (show Borrow, and clear the stale timer value) instead of leaving
970
+ // the action bar showing "Return now" with a frozen countdown from
971
+ // before the failed attempt, which falsely implies the book is
972
+ // still actively being read.
973
+ this.lendingStatus = {
969
974
  ...this.lendingStatus,
970
975
  user_has_browsed: false,
971
976
  available_to_browse: true,
977
+ secondsLeftOnLoan: 0,
972
978
  };
973
- this.lendingStatus = currStatus;
974
- }
975
979
 
976
- // update action bar state if book is not available to browse or borrow.
977
- if (errorMsg && errorMsg.match(/not available to borrow/gm)) {
978
- let currStatus = this.lendingStatus;
979
- if (action === 'browse_book') {
980
- currStatus = {
981
- ...this.lendingStatus,
982
- available_to_browse: false,
983
- };
984
- } else if (action === 'borrow_book') {
985
- currStatus = {
986
- ...this.lendingStatus,
987
- available_to_borrow: false,
988
- };
980
+ // Show the real error and refresh the page on dismissal (the
981
+ // modal's Okay button) so the client picks up whatever the server
982
+ // now authoritatively considers true — matching on "not available"
983
+ // to guess the reason was fragile since the server can return any
984
+ // message (e.g. a lending limit).
985
+ this.showLoanUnavailableModal(errorMsg);
986
+ } else {
987
+ // Every other action failure genuinely affects loan state.
988
+ window?.IALendingIntervals?.clearAll();
989
+
990
+ if (!errorMsg) return;
991
+
992
+ this.showErrorModal(errorMsg, action);
993
+
994
+ // update action bar state if book is not available to browse or borrow.
995
+ if (errorMsg.match(/not available to borrow/gm)) {
996
+ if (action === 'browse_book') {
997
+ this.lendingStatus = { ...this.lendingStatus, available_to_browse: false };
998
+ } else if (action === 'borrow_book') {
999
+ this.lendingStatus = { ...this.lendingStatus, available_to_borrow: false };
1000
+ }
989
1001
  }
990
- this.lendingStatus = currStatus;
991
1002
  }
992
1003
  }
993
1004
 
@@ -900,6 +900,45 @@ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
900
900
  expect(autoLoanRenewCheckerSpy.called).to.be.false;
901
901
  expect(el.loanRenewResult.renewNow).to.be.true;
902
902
  });
903
+
904
+ it('ignores repeated BookReader:userAction events firing in quick succession (e.g. from a scroll)', async () => {
905
+ // Regression: a single scroll gesture can fire BookReader:userAction
906
+ // several times in rapid succession. autoLoanRenewChecker() had no
907
+ // guard against concurrent/repeated invocation, unlike
908
+ // autoRenewExpiredLoan() — each event spun up its own LoanRenewHelper,
909
+ // racing to overwrite this.loanRenewResult and potentially
910
+ // re-triggering renew_loan/create_token while a prior call from an
911
+ // earlier event was still in flight or had just landed.
912
+ const baseStatus = {
913
+ is_lendable: true,
914
+ user_has_browsed: true,
915
+ browsingExpired: false,
916
+ };
917
+ const el = await fixture(
918
+ container({
919
+ userid: '@user1',
920
+ identifier: 'foobar',
921
+ lendingStatus: baseStatus,
922
+ })
923
+ );
924
+ await el.updateComplete;
925
+ expect(el.borrowType).to.equal('browsed');
926
+
927
+ el.loanRenewInProgress = true;
928
+
929
+ const loanRenewHelperSpy = Sinon.spy(el, 'autoLoanRenewChecker');
930
+
931
+ // Simulate a scroll firing the event several times back to back.
932
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
933
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
934
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
935
+ await aTimeout(50);
936
+
937
+ // The method is still called each time (the guard is inside it), but
938
+ // must no-op instead of spinning up a new LoanRenewHelper each time.
939
+ expect(loanRenewHelperSpy.callCount).to.equal(3);
940
+ expect(el.loanRenewHelper).to.be.undefined;
941
+ });
903
942
  });
904
943
 
905
944
  describe('handleLendingActionError - loanRenewInProgress reset', () => {
@@ -978,6 +1017,110 @@ describe('handleLendingActionError - create_token failures must not stop the cou
978
1017
 
979
1018
  expect(window.IALendingIntervals.timerCountdown).to.equal(0);
980
1019
  });
1020
+
1021
+ it('shows the same unavailable-modal-and-refresh path for any renew_loan failure, regardless of message text', async () => {
1022
+ // Regression: branching on errorMsg.match(/not available/) to decide
1023
+ // how to recover was fragile — the server can return any message
1024
+ // (e.g. a lending limit hit), and guessing which lendingStatus fields
1025
+ // are now accurate client-side is error-prone. Every renew_loan
1026
+ // failure now shows the real error and refreshes the page on
1027
+ // dismissal (showLoanUnavailableModal's Okay button), rather than
1028
+ // trying to patch up state without reloading from the server.
1029
+ const el = await fixture(
1030
+ container({
1031
+ userid: '@user1',
1032
+ identifier: 'foobar',
1033
+ lendingStatus: {
1034
+ is_lendable: true,
1035
+ user_has_browsed: true,
1036
+ browsingExpired: false,
1037
+ available_to_browse: false,
1038
+ secondsLeftOnLoan: 100,
1039
+ },
1040
+ })
1041
+ );
1042
+ await el.updateComplete;
1043
+
1044
+ const showUnavailableSpy = Sinon.spy(el, 'showLoanUnavailableModal');
1045
+ const lendingLimitMsg =
1046
+ 'Your account has hit a lending limit. Please try again later or contact info@archive.org.';
1047
+
1048
+ el.handleLendingActionError({
1049
+ detail: {
1050
+ action: 'renew_loan',
1051
+ data: { error: lendingLimitMsg },
1052
+ },
1053
+ });
1054
+ await el.updateComplete;
1055
+
1056
+ expect(showUnavailableSpy.calledOnceWith(lendingLimitMsg)).to.be.true;
1057
+ });
1058
+
1059
+ it('updates lendingStatus immediately on a renew_loan failure — Borrow state, timer cleared', async () => {
1060
+ // The modal's Okay button refreshes the page, but that shouldn't be
1061
+ // the only thing correcting the UI — while the modal is still open,
1062
+ // the action bar must already show Borrow instead of "Return now"
1063
+ // with a stale countdown value from before the failed renewal.
1064
+ const el = await fixture(
1065
+ container({
1066
+ userid: '@user1',
1067
+ identifier: 'foobar',
1068
+ lendingStatus: {
1069
+ is_lendable: true,
1070
+ user_has_browsed: true,
1071
+ browsingExpired: false,
1072
+ available_to_browse: false,
1073
+ secondsLeftOnLoan: 100,
1074
+ },
1075
+ })
1076
+ );
1077
+ await el.updateComplete;
1078
+
1079
+ el.handleLendingActionError({
1080
+ detail: {
1081
+ action: 'renew_loan',
1082
+ data: {
1083
+ error:
1084
+ 'Your account has hit a lending limit. Please try again later or contact info@archive.org.',
1085
+ },
1086
+ },
1087
+ });
1088
+ await el.updateComplete;
1089
+
1090
+ expect(el.lendingStatus.user_has_browsed).to.be.false;
1091
+ expect(el.lendingStatus.available_to_browse).to.be.true;
1092
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
1093
+ expect(el.primaryActions[0].text).to.not.equal('Return now');
1094
+ });
1095
+
1096
+ it('also shows the unavailable modal for the "not available" message, with its own text', async () => {
1097
+ const el = await fixture(
1098
+ container({
1099
+ userid: '@user1',
1100
+ identifier: 'foobar',
1101
+ lendingStatus: {
1102
+ is_lendable: true,
1103
+ user_has_browsed: true,
1104
+ browsingExpired: false,
1105
+ secondsLeftOnLoan: 100,
1106
+ },
1107
+ })
1108
+ );
1109
+ await el.updateComplete;
1110
+
1111
+ const showUnavailableSpy = Sinon.spy(el, 'showLoanUnavailableModal');
1112
+ const notAvailableMsg = 'This book is not available to borrow at this time.';
1113
+
1114
+ el.handleLendingActionError({
1115
+ detail: {
1116
+ action: 'renew_loan',
1117
+ data: { error: notAvailableMsg },
1118
+ },
1119
+ });
1120
+ await el.updateComplete;
1121
+
1122
+ expect(showUnavailableSpy.calledOnceWith(notAvailableMsg)).to.be.true;
1123
+ });
981
1124
  });
982
1125
 
983
1126
  describe('showWarningModal', () => {