@autobusal/providers 1.37.5 → 1.37.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.37.6
4
+
5
+ ### Fixed
6
+
7
+ - **The DOM-corruption self-heal watchdog now actually recovers every navigation it's supposed to.** It had two gaps that left users stuck exactly like the bug it exists to catch: its 10-second reload cooldown was tracked globally, so a SECOND corrupted navigation to a different URL within 10s of a first one was silently swallowed - no reload, no recovery, stuck. Scoped the cooldown per-URL instead: a fresh URL always gets a fresh recovery attempt; only a repeat failure on the SAME URL is throttled (the loop-protection this cooldown exists for in the first place). It also only listened for synchronous `error` events - a promise-rejection-based failure (plausible from the lazy()/dynamic-import machinery a route transition drives) was invisible to it and got no recovery at all. Added an `unhandledrejection` listener alongside the existing one, both routed through the same recovery check.
8
+ - **`apiClient`'s 401 handler no longer redirects multiple times for one dead session.** A page boot fires several requests in parallel (settings, menu, account refresh, whatever the current page needs); on a stale/invalid session every one of them 401s within the same tick, and each independently ran its own async `/session` confirmation and its own `window.location.href` redirect - the existing pathname guard only protects against a request that 401s AFTER a redirect has already navigated away, not against several 401s racing each other before any of them has. Reproduced live: a stale session landed on `/account/logout`, and before that page's own logout mutation finished, a second queued redirect (from another request's 401, still in flight when the first one fired) reloaded the page again - repeatedly, stacking a fresh "logged out" toast for every concurrent 401 the boot had fired. Now guarded with a synchronous flag set before the async confirmation, so only the first 401 of a burst does the work; every other one concurrent with it short-circuits immediately.
9
+
3
10
  ## 1.37.5
4
11
 
5
12
  ### Fixed
package/Providers.tsx CHANGED
@@ -31,30 +31,69 @@ interface Props {
31
31
  // LoginAs.tsx carry a second, more targeted watchdog (a plain timer, not
32
32
  // error-triggered) for the two pages users actually get stuck on during
33
33
  // impersonation - this one is the general backstop for everywhere else.
34
+ //
35
+ // Edited: Ferjolt Ozuni - Date: 2026-08-11
36
+ // Two gaps closed after this was reported as still reproducing in the wild
37
+ // ("have to click twice", and separately "sometimes I have to refresh
38
+ // myself"):
39
+ //
40
+ // 1. The cooldown used to be a single global timestamp, so ANY two
41
+ // corruptions within 10s of each other - on two DIFFERENT navigations, e.g.
42
+ // a visitor clicking through Help > a category > an article - had the
43
+ // SECOND one silently swallowed: no reload, no error shown, stuck exactly
44
+ // as reported, with a manual refresh as the only way out. The cooldown's
45
+ // actual job is loop protection - stop the SAME failing reload from firing
46
+ // forever - so it only needs to compare against the last recovery done for
47
+ // THIS SAME url; a corruption on a new url is a fresh problem and should
48
+ // always self-heal immediately.
49
+ //
50
+ // 2. This only ever listened for a synchronous, thrown `error` event. React's
51
+ // commit-phase work (and the lazy()/dynamic import() machinery a route
52
+ // transition drives) also has promise-based failure paths, which surface as
53
+ // `unhandledrejection`, not `error` - a corruption landing through one of
54
+ // those was never seen by this handler at all, so no reload ever fired.
55
+ // Both event types are handled by the same recovery function now, since the
56
+ // only thing distinguishing them is which browser event carries the message.
34
57
  const useRecoverFromDomCorruption = (): void => {
35
58
  useEffect(() => {
36
- const RECOVERY_KEY = 'autobusalDomRecoveryAt';
59
+ const RECOVERY_AT_KEY = 'autobusalDomRecoveryAt';
60
+ const RECOVERY_URL_KEY = 'autobusalDomRecoveryUrl';
37
61
 
38
- const onError = (event: ErrorEvent): void => {
39
- const message = event.message || '';
62
+ const isDomCorruption = (message: string): boolean => (
63
+ message.includes('removeChild') || message.includes('insertBefore')
64
+ );
40
65
 
41
- if (!message.includes('removeChild') && !message.includes('insertBefore')) {
66
+ const recover = (message: string): void => {
67
+ if (!isDomCorruption(message)) {
42
68
  return;
43
69
  }
44
70
 
45
- const last = Number(sessionStorage.getItem(RECOVERY_KEY) || 0);
71
+ const here = window.location.href;
72
+ const lastUrl = sessionStorage.getItem(RECOVERY_URL_KEY);
73
+ const lastAt = Number(sessionStorage.getItem(RECOVERY_AT_KEY) || 0);
46
74
 
47
- if (Date.now() - last < 10000) {
75
+ if (lastUrl === here && Date.now() - lastAt < 10000) {
48
76
  return;
49
77
  }
50
78
 
51
- sessionStorage.setItem(RECOVERY_KEY, String(Date.now()));
79
+ sessionStorage.setItem(RECOVERY_AT_KEY, String(Date.now()));
80
+ sessionStorage.setItem(RECOVERY_URL_KEY, here);
52
81
  window.location.reload();
53
82
  };
54
83
 
84
+ const onError = (event: ErrorEvent): void => recover(event.message || '');
85
+
86
+ const onRejection = (event: PromiseRejectionEvent): void => (
87
+ recover(event.reason?.message || String(event.reason ?? ''))
88
+ );
89
+
55
90
  window.addEventListener('error', onError);
91
+ window.addEventListener('unhandledrejection', onRejection);
56
92
 
57
- return () => window.removeEventListener('error', onError);
93
+ return () => {
94
+ window.removeEventListener('error', onError);
95
+ window.removeEventListener('unhandledrejection', onRejection);
96
+ };
58
97
  }, []);
59
98
  };
60
99
 
@@ -56,12 +56,39 @@ apiClient.interceptors.request.use(config => {
56
56
  return config;
57
57
  });
58
58
 
59
+ // Edited: Ferjolt Ozuni - Date: 2026-08-11
60
+ // A page boot fires several requests in parallel (settings, menu, account
61
+ // refresh, whatever the current page needs) - on a stale/invalid session
62
+ // every one of them 401s within the same tick. Without this, EACH ran its
63
+ // own async `/session` confirmation and, once that resolved, its own
64
+ // `window.location.href` assignment - independently, since the pathname
65
+ // guard below only protects against a request that 401s AFTER a redirect
66
+ // has already navigated the page away, not against several 401s racing each
67
+ // other BEFORE any of them has. Reproduced live: a stale session landed on
68
+ // /account/logout, Logout.tsx ran its own fresh logout call and toast, and
69
+ // before that finished a SECOND queued redirect (from another request's
70
+ // 401, still in flight when the first one fired) reloaded the page again -
71
+ // repeatedly, each reload restarting Logout.tsx's mutation and stacking
72
+ // another "logged out" toast, for as many concurrent 401s as the boot had
73
+ // fired. Sets synchronously, before the async confirmation - JS runs the
74
+ // check-and-set with nothing else able to interleave before the next
75
+ // `await` - so only the first 401 of a burst does the confirmation work;
76
+ // every other one concurrent with it short-circuits immediately instead of
77
+ // each independently re-deciding the same thing.
78
+ let redirectingToLogout = false;
79
+
59
80
  apiClient.interceptors.response.use(response => (
60
81
  response
61
82
  ), async (error) => {
62
83
  if (error.response) {
63
84
  // if we have a 401, the session is gone, so we go to the logout page.
64
85
  if (error.response.status === 401 && window.location.pathname !== '/account/logout') {
86
+ if (redirectingToLogout) {
87
+ return Promise.reject(error);
88
+ }
89
+
90
+ redirectingToLogout = true;
91
+
65
92
  // bff mode: auth is a server-side httpOnly cookie the BFF manages, so a
66
93
  // single request's 401 does NOT reliably mean "logged out" the way a
67
94
  // bearer 401 does - a query that momentarily races the login/cookie
@@ -76,6 +103,8 @@ apiClient.interceptors.response.use(response => (
76
103
  const { data } = await apiClient.get('/session');
77
104
 
78
105
  if (data?.authenticated) {
106
+ redirectingToLogout = false;
107
+
79
108
  return Promise.reject(error);
80
109
  }
81
110
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/providers",
3
- "version": "1.37.5",
3
+ "version": "1.37.6",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"