@autobusal/providers 1.6.6 → 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,26 @@ 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
+
7
27
  ## [1.6.6] - 2026-07-31
8
28
 
9
29
  ### Fixed
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,56 +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
- // Edited: Ferjolt Ozuni - Date: 2026-07-31
28
- // Bug: Setup also calls useGetMenu() (a SEPARATE useSuspenseQuery, its own
29
- // cache key/promise) in its own render body, still inside this same outer
30
- // boundary. On a cold menu cache, that suspends AFTER Recaptcha/Styles/
31
- // Notifications above it have already committed - and a Suspense boundary
32
- // re-suspending after its content is already committed hides/discards that
33
- // whole committed subtree and mounts it again once ready. Recaptcha's
34
- // GoogleReCaptchaProvider isn't remount-safe: its script/badge injection is
35
- // async and outside React's DOM bookkeeping, so remounting it left a
36
- // duplicate, half-torn-down <div class="grecaptcha-badge"> behind. That
37
- // corrupted DOM state then threw an uncaught "Cannot read properties of
38
- // null (reading 'removeChild')" the next time React reconciled anywhere
39
- // near the root - which is every route change, since Recaptcha wraps the
40
- // whole router - silently aborting the commit. The URL would update (the
41
- // router's history.pushState already ran) but the page itself would never
42
- // re-render, freezing navigation app-wide with no console-visible error
43
- // (uncaught DOM exceptions like this don't hit the render-phase-only
44
- // ErrorBoundary). Giving Setup its own inner boundary means its menu
45
- // suspension can only hide/retry ITSELF, not the already-committed
46
- // Recaptcha/Styles/Notifications above it.
47
- <Suspense fallback={ loading }>
48
- <Queries>
49
- <Recaptcha>
50
- <Styles>
51
- <Notifications>
52
- <Suspense fallback={ loading }>
53
- <Setup type={ type }>
54
- <Suspense fallback={ loading }>
55
- { children }
56
- </Suspense>
57
- </Setup>
58
- </Suspense>
59
- </Notifications>
60
- </Styles>
61
- </Recaptcha>
62
- </Queries>
63
- </Suspense>
64
- );
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>
98
+ <Suspense fallback={ loading }>
99
+ <Setup type={ type }>
100
+ <Suspense fallback={ loading }>
101
+ { children }
102
+ </Suspense>
103
+ </Setup>
104
+ </Suspense>
105
+ </Notifications>
106
+ </Styles>
107
+ </Recaptcha>
108
+ </Queries>
109
+ </Suspense>
110
+ );
111
+ };
65
112
 
66
113
  export default Providers;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/providers",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"