@autobusal/providers 1.6.5 → 1.6.7

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
@@ -4,6 +4,53 @@ All notable changes to `@autobusal/providers` are documented here. This project
4
4
  adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.6.7] - 2026-07-31
8
+
9
+ ### Added
10
+
11
+ - **`Providers.tsx`: self-healing reload if the DOM ever corrupts near the
12
+ root.** 1.6.6 closed the deterministic trigger for the
13
+ `GoogleReCaptchaProvider` remount corruption (see 1.6.6 below), but its
14
+ script/badge injection is still fundamentally async - a script tag's
15
+ `onload` racing React's synchronous mount/cleanup - so a rare remount
16
+ can still leave the DOM in a state that throws an uncaught
17
+ `removeChild`/`insertBefore` error on the next reconciliation anywhere
18
+ near the root. That class of error isn't render-phase, so no
19
+ `ErrorBoundary` catches it, and it silently aborts whatever navigation
20
+ was in flight: the URL updates but the page never re-renders, with no
21
+ visible error and no way to navigate out. A reload of the current URL
22
+ always recovers cleanly (the router's `history.pushState` already
23
+ applied), so `Providers` now listens for exactly this error signature
24
+ and self-heals with one reload, guarded by a 10s `sessionStorage`
25
+ cooldown so a genuinely persistent error can't loop.
26
+
27
+ ## [1.6.6] - 2026-07-31
28
+
29
+ ### Fixed
30
+
31
+ - **`Providers.tsx`: `Setup` (and its `useGetMenu()` call) now has its own
32
+ inner `<Suspense>` boundary**, instead of sharing the outer one with
33
+ `Recaptcha`/`Styles`/`Notifications`. `Setup` calls a second,
34
+ independently-resolving `useSuspenseQuery` (`get-menu`) after
35
+ `useGetSettings()`; on a cold menu cache this suspended AFTER
36
+ Recaptcha's `GoogleReCaptchaProvider` had already committed and
37
+ injected its script/badge - and a Suspense boundary that re-suspends
38
+ discards its already-committed content and remounts it once ready.
39
+ `GoogleReCaptchaProvider` isn't remount-safe (its script/badge
40
+ injection is async, outside React's own DOM bookkeeping), so the
41
+ remount left a duplicate, half-torn-down `.grecaptcha-badge` behind.
42
+ That corrupted DOM state then threw an uncaught `TypeError: Cannot
43
+ read properties of null (reading 'removeChild')` the next time React
44
+ reconciled anywhere near the root - i.e. on every subsequent route
45
+ change, since Recaptcha wraps the whole router. The exception isn't
46
+ caught by the app's `ErrorBoundary` (render-phase only), so it
47
+ silently aborted the commit: the URL would update (the router's
48
+ `history.pushState` already ran) but the page itself never
49
+ re-rendered - freezing client-side navigation app-wide, most visibly
50
+ reproduced via the account dropdown menu (Password, Telegram, and the
51
+ impersonation-aware Logout/"Return to my account" link, which is what
52
+ surfaced this).
53
+
7
54
  ## [1.6.5] - 2026-07-31
8
55
 
9
56
  ### Added
package/Providers.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Suspense } from 'react';
1
+ import { Suspense, useEffect } from 'react';
2
2
  import Recaptcha from './Recaptcha/Recaptcha';
3
3
  import Queries from './Queries/Queries';
4
4
  import Styles from './Styles/Styles';
@@ -11,34 +11,103 @@ interface Props {
11
11
  children: JSX.Element
12
12
  }
13
13
 
14
- const Providers = ({ type, loading, children }: Props): JSX.Element => (
15
- // Styles and Setup both call useGetSettings() (a useSuspenseQuery) and sit
16
- // ABOVE the inner <Suspense> below, so on a cold settings cache their
17
- // suspension had no boundary to catch it and React threw error #426
18
- // ("a component suspended while responding to synchronous input"). This
19
- // outer boundary catches those two suspensions (showing `loading` during
20
- // the initial settings fetch). The QueryClient in <Queries> is created at
21
- // module scope, so remounting this subtree while suspended keeps the same
22
- // client and cache - no refetch loop.
23
- // Edited: Ferjolt Ozuni - Date: 2026-07-25
24
- // Recaptcha moved INSIDE Queries: it now reads the label's runtime captcha
25
- // settings via useGetSettings(), which needs the QueryClientProvider (and
26
- // suspends into the outer boundary on a cold cache, like Styles/Setup).
27
- <Suspense fallback={ loading }>
28
- <Queries>
29
- <Recaptcha>
30
- <Styles>
31
- <Notifications>
32
- <Setup type={ type }>
14
+ // Edited: Ferjolt Ozuni - Date: 2026-07-31
15
+ // Defensive backstop for the removeChild corruption described below: even
16
+ // with Setup's suspension isolated, GoogleReCaptchaProvider's script/badge
17
+ // injection is inherently async (a script tag's onload race against React's
18
+ // synchronous mount/cleanup), so a rare remount can still leave the DOM in a
19
+ // state that throws on the next reconciliation - and that throw is NOT
20
+ // render-phase, so no ErrorBoundary catches it. Left alone, this strands the
21
+ // user on whatever was on screen with a URL that no longer matches it and no
22
+ // working navigation (the exact "stuck, can't log out" symptom). A hard
23
+ // reload of the current URL always recovers cleanly (proven: the router's
24
+ // history.pushState already applied), so self-heal with one, guarded against
25
+ // looping if the error turns out to be persistent rather than transient.
26
+ const useRecoverFromDomCorruption = (): void => {
27
+ useEffect(() => {
28
+ const RECOVERY_KEY = 'autobusalDomRecoveryAt';
29
+
30
+ const onError = (event: ErrorEvent): void => {
31
+ const message = event.message || '';
32
+
33
+ if (!message.includes('removeChild') && !message.includes('insertBefore')) {
34
+ return;
35
+ }
36
+
37
+ const last = Number(sessionStorage.getItem(RECOVERY_KEY) || 0);
38
+
39
+ if (Date.now() - last < 10000) {
40
+ return;
41
+ }
42
+
43
+ sessionStorage.setItem(RECOVERY_KEY, String(Date.now()));
44
+ window.location.reload();
45
+ };
46
+
47
+ window.addEventListener('error', onError);
48
+
49
+ return () => window.removeEventListener('error', onError);
50
+ }, []);
51
+ };
52
+
53
+ const Providers = ({ type, loading, children }: Props): JSX.Element => {
54
+ useRecoverFromDomCorruption();
55
+
56
+ return (
57
+ // Styles and Setup both call useGetSettings() (a useSuspenseQuery) and
58
+ // sit ABOVE the inner <Suspense> below, so on a cold settings cache their
59
+ // suspension had no boundary to catch it and React threw error #426
60
+ // ("a component suspended while responding to synchronous input"). This
61
+ // outer boundary catches those two suspensions (showing `loading` during
62
+ // the initial settings fetch). The QueryClient in <Queries> is created
63
+ // at module scope, so remounting this subtree while suspended keeps the
64
+ // same client and cache - no refetch loop.
65
+ // Edited: Ferjolt Ozuni - Date: 2026-07-25
66
+ // Recaptcha moved INSIDE Queries: it now reads the label's runtime
67
+ // captcha settings via useGetSettings(), which needs the
68
+ // QueryClientProvider (and suspends into the outer boundary on a cold
69
+ // cache, like Styles/Setup).
70
+ // Edited: Ferjolt Ozuni - Date: 2026-07-31
71
+ // Bug: Setup also calls useGetMenu() (a SEPARATE useSuspenseQuery, its
72
+ // own cache key/promise) in its own render body, still inside this same
73
+ // outer boundary. On a cold menu cache, that suspends AFTER Recaptcha/
74
+ // Styles/Notifications above it have already committed - and a Suspense
75
+ // boundary re-suspending after its content is already committed hides/
76
+ // discards that whole committed subtree and mounts it again once ready.
77
+ // Recaptcha's GoogleReCaptchaProvider isn't remount-safe: its script/
78
+ // badge injection is async and outside React's DOM bookkeeping, so
79
+ // remounting it left a duplicate, half-torn-down
80
+ // <div class="grecaptcha-badge"> behind. That corrupted DOM state then
81
+ // threw an uncaught "Cannot read properties of null (reading
82
+ // 'removeChild')" the next time React reconciled anywhere near the root
83
+ // - which is every route change, since Recaptcha wraps the whole router
84
+ // - silently aborting the commit. The URL would update (the router's
85
+ // history.pushState already ran) but the page itself would never
86
+ // re-render, freezing navigation app-wide with no console-visible error
87
+ // (uncaught DOM exceptions like this don't hit the render-phase-only
88
+ // ErrorBoundary). Giving Setup its own inner boundary means its menu
89
+ // suspension can only hide/retry ITSELF, not the already-committed
90
+ // Recaptcha/Styles/Notifications above it - this closed off the
91
+ // deterministic trigger, and useRecoverFromDomCorruption() above is the
92
+ // backstop for whatever async-timing sliver still gets through.
93
+ <Suspense fallback={ loading }>
94
+ <Queries>
95
+ <Recaptcha>
96
+ <Styles>
97
+ <Notifications>
33
98
  <Suspense fallback={ loading }>
34
- { children }
99
+ <Setup type={ type }>
100
+ <Suspense fallback={ loading }>
101
+ { children }
102
+ </Suspense>
103
+ </Setup>
35
104
  </Suspense>
36
- </Setup>
37
- </Notifications>
38
- </Styles>
39
- </Recaptcha>
40
- </Queries>
41
- </Suspense>
42
- );
105
+ </Notifications>
106
+ </Styles>
107
+ </Recaptcha>
108
+ </Queries>
109
+ </Suspense>
110
+ );
111
+ };
43
112
 
44
113
  export default Providers;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/providers",
3
- "version": "1.6.5",
3
+ "version": "1.6.7",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"