@autobusal/providers 1.27.0 → 1.28.1

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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.28.1
4
+
5
+ ### Fixed
6
+
7
+ - **The stale-chunk guard budgeted two reloads instead of one.** Measured: a
8
+ failure at `/operators` reloaded, the server answered with its canonical
9
+ `/operators/`, the same chunk failed again — and the raw path no longer
10
+ matched the one already spent, so it reloaded a second time. Bounded, but
11
+ twice what was intended, and on a genuinely broken deploy the user watched
12
+ the page flash twice before the error appeared. The budget key now strips a
13
+ trailing slash, so one journey gets one reload.
14
+
15
+ Only visible because the retest ran against a chunk that was *permanently*
16
+ missing rather than replaced — the ordinary stale-bundle case recovers on
17
+ the first reload and never reaches the second.
18
+
19
+ ## 1.28.0
20
+
21
+ ### Added
22
+
23
+ - **`registerStaleChunkRecovery()`** — a tab left running a bundle that no
24
+ longer exists now reloads itself onto the page it was asked for, instead of
25
+ freezing.
26
+
27
+ Every route is `lazy(() => import(...))`, the build emits content-hashed
28
+ chunks, and it **empties the output directory** — so a new build deletes the
29
+ old chunk names outright (watched `index-DFPpSljG.js` disappear across one).
30
+ A tab still holding the previous `index.html` goes on asking for those names,
31
+ so the next navigation fetches a chunk that no longer resolves, the import
32
+ rejects, and the routed component never mounts — while the URL has *already*
33
+ changed. The address bar says one page and the screen shows the last one.
34
+ From the outside: "the page does not react at all, but the url seems to
35
+ change."
36
+
37
+ One listener on Vite's `vite:preloadError` covers all 141 lazy routes, which
38
+ is why this is a small module rather than a change to 141 call sites.
39
+
40
+ **Silent, not a prompt.** Every lazy chunk in both apps is a *route* —
41
+ checked, there is no lazily-loaded in-page component — so a chunk is only
42
+ ever fetched while navigating, which means the user has already left the page
43
+ they were on. Reloading to the destination costs them nothing the click was
44
+ not already costing them, and a "new version available?" dialog would be
45
+ asking permission to do the thing they just asked for.
46
+
47
+ **It cannot loop.** A missing chunk is usually a stale bundle but might be a
48
+ broken deploy, and reloading into a repeating failure would be far worse than
49
+ the bug. Each path gets at most one reload per tab; after that the error is
50
+ left to surface. `sessionStorage`, so the budget is per tab.
51
+
52
+ Verified by reproducing it: a tab pinned to an older bundle, its chunk
53
+ orphaned by a rebuild, then a client-side click — `vite:preloadError` fired,
54
+ the document was replaced, and the tab landed on `/operators` running the new
55
+ bundle.
56
+
3
57
  ## 1.27.0
4
58
 
5
59
  ### Added
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Recover a tab left running a bundle that no longer exists.
3
+ *
4
+ * Ferjolt Ozuni - Date: 2026-08-06
5
+ *
6
+ * THE FAILURE. Every route is `lazy(() => import(...))`, the build emits
7
+ * content-hashed chunks, and it EMPTIES the output directory - so the moment
8
+ * a new build lands, `index-DFPpSljG.js` and its siblings stop existing.
9
+ * Confirmed by watching that exact file disappear across a build. Any tab
10
+ * still holding the previous index.html goes on asking for the old names, so
11
+ * the next navigation fetches a chunk that 404s, the dynamic import rejects,
12
+ * the routed component never mounts - and the URL has ALREADY changed. The
13
+ * address bar says one page, the screen shows the last one, and only a manual
14
+ * refresh escapes. That is exactly what it looked like from the outside:
15
+ * "the page does not react at all, but the url seems to change".
16
+ *
17
+ * WHY A SILENT RELOAD RATHER THAN A PROMPT. Every lazy chunk in both apps is
18
+ * a ROUTE - checked, there is not one lazily-loaded in-page component - so a
19
+ * chunk is only ever fetched while the user is navigating, which means they
20
+ * have already left the page they were on. Reloading to the destination
21
+ * therefore costs them nothing that the click was not already costing them.
22
+ * A "new version available, reload?" dialog would be asking permission to do
23
+ * the thing they just asked for.
24
+ *
25
+ * WHY IT CANNOT LOOP. A missing chunk is usually a stale bundle, but it might
26
+ * be a broken deploy or a bad cache - and reloading into a failure that
27
+ * repeats would spin forever, which is far worse than the bug being fixed.
28
+ * Each URL gets AT MOST ONE reload per tab: if the same route fails again the
29
+ * error is left to surface. sessionStorage rather than localStorage, so the
30
+ * budget is per tab and does not follow somebody around for weeks.
31
+ */
32
+
33
+ const KEY = 'chunk-reload';
34
+
35
+ /**
36
+ * The key a reload is budgeted against.
37
+ *
38
+ * Ferjolt Ozuni - Date: 2026-08-06
39
+ * TRAILING SLASH STRIPPED, and that is not tidiness. Measured: a failure at
40
+ * `/operators` reloaded, the server answered with its canonical `/operators/`,
41
+ * the same chunk failed again - and the raw path no longer matched the one
42
+ * already spent, so it reloaded a SECOND time. Bounded, but twice what was
43
+ * intended, and on a genuinely broken deploy the user watches the page flash
44
+ * twice before the error appears. One journey, one budget.
45
+ */
46
+ const budgetKey = (): string => {
47
+ const path = window.location.pathname.replace(/\/+$/, '') || '/';
48
+
49
+ return path + window.location.search;
50
+ };
51
+
52
+ /**
53
+ * Which paths this tab has already spent its reload on.
54
+ */
55
+ const attempted = (): string[] => {
56
+ try {
57
+ const raw = sessionStorage.getItem(KEY);
58
+
59
+ return raw ? JSON.parse(raw) : [];
60
+ } catch {
61
+ // Storage denied (private mode) or a hand-mangled value. Answering
62
+ // "nothing attempted" is safe here only because remember() below has to
63
+ // write before it approves anything, and that write fails for the same
64
+ // reason - so an unreadable store still ends in a refusal, not a loop.
65
+ return [];
66
+ }
67
+ };
68
+
69
+ const remember = (path: string): boolean => {
70
+ try {
71
+ const paths = attempted();
72
+
73
+ if (paths.includes(path)) {
74
+ return false;
75
+ }
76
+
77
+ sessionStorage.setItem(KEY, JSON.stringify([...paths, path]));
78
+
79
+ return true;
80
+ } catch {
81
+ // Storage unavailable means no way to remember an attempt, and therefore
82
+ // no way to stop a second one. Refuse rather than risk reloading forever.
83
+ return false;
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Listen for Vite's dynamic-import failure.
89
+ *
90
+ * `vite:preloadError` is emitted by the preload helper the build wraps every
91
+ * `import()` in, so ONE listener covers all 141 lazy routes - which is the
92
+ * only reason this is a ten-line module rather than a change to 141 call
93
+ * sites. Calling preventDefault stops Vite rethrowing, since we are handling
94
+ * it by replacing the document.
95
+ */
96
+ const registerStaleChunkRecovery = (): void => {
97
+ window.addEventListener('vite:preloadError', (event) => {
98
+ if (!remember(budgetKey())) {
99
+ // Spent already. Let it throw so the error boundary says something,
100
+ // rather than reloading into the same wall a second time.
101
+ return;
102
+ }
103
+
104
+ event.preventDefault();
105
+
106
+ // reload() and not assign(): the URL is already the destination - React
107
+ // Router changes it before rendering the route whose chunk just failed -
108
+ // so this fetches a fresh index.html and lands on the page that was asked
109
+ // for, with the current chunk names.
110
+ window.location.reload();
111
+ });
112
+ };
113
+
114
+ export default registerStaleChunkRecovery;
package/index.ts CHANGED
@@ -4,6 +4,7 @@ import ErrorBoundary from './Errors/ErrorBoundary';
4
4
  import Message from './Errors/Message';
5
5
  import useMenuStore from './stores/menu';
6
6
  import { useUserStore } from './stores/user';
7
+ import registerStaleChunkRecovery from './Setup/staleChunk';
7
8
 
8
9
  export {
9
10
  apiClient,
@@ -11,7 +12,8 @@ export {
11
12
  ErrorBoundary,
12
13
  Message,
13
14
  useMenuStore,
14
- useUserStore
15
+ useUserStore,
16
+ registerStaleChunkRecovery
15
17
  };
16
18
 
17
19
  export default Providers;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/providers",
3
- "version": "1.27.0",
3
+ "version": "1.28.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"