cursedbelt 5.12.0 → 5.12.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.
@@ -43,7 +43,16 @@ export declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBo
43
43
  * rather than "the app is broken" or "the server is unreachable".
44
44
  */
45
45
  export declare function isStaleChunkError(error: unknown): boolean;
46
- /** Whether this tab has already tried reloading for this URL. Never throws. */
46
+ /**
47
+ * Whether this tab already reloaded for a stale chunk inside the last minute — and
48
+ * true when storage is unavailable, so a reload loop is impossible in the one
49
+ * environment where we cannot remember trying. Never throws.
50
+ *
51
+ * 🔴 The memory is `lazyWithReload`'s (`../lazy/staleChunk.ts`), shared on purpose.
52
+ * Until 2026-09-27 this was its own flag, once per tab per URL FOREVER: a tab left
53
+ * open across a SECOND deploy — a film playing for hours — got "could not be
54
+ * loaded" instead of a reload, and the two guards could add up to two reloads.
55
+ */
47
56
  export declare function staleChunkReloadAttempted(): boolean;
48
57
  export interface StaleDeployHealerProps {
49
58
  /** The caught error, shown as detail when the reload has already been tried. */
@@ -1 +1 @@
1
- {"version":3,"file":"ErrorBoundary.d.ts","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AAGvF,OAAO,EAAwC,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAerG;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,SAAS,CAAC;IACpB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,IAAI,KAAK,SAAS,CAAC,CAAC;IACxE,iEAAiE;IACjE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IAClD,qFAAqF;IACrF,SAAS,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;IAC/B;;;kFAG8E;IAC9E,QAAQ,CAAC,EAAE,OAAO,GAAG,iBAAiB,CAAC;CACxC;AAED,UAAU,kBAAkB;IAC1B,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAQD,qBAAa,aAAc,SAAQ,SAAS,CAAC,kBAAkB,EAAE,kBAAkB,CAAC;IAClF,KAAK,EAAE,kBAAkB,CAAmB;IAE5C,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,KAAK,GAAG,kBAAkB;IAIjE,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI;IAItD,kBAAkB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI;IAMlD,KAAK,QAAO,IAAI,CAEd;IAEF,MAAM,IAAI,SAAS;CA0BpB;AA4CD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAMzD;AAMD,+EAA+E;AAC/E,wBAAgB,yBAAyB,IAAI,OAAO,CAQnD;AAUD,MAAM,WAAW,sBAAsB;IACrC,gFAAgF;IAChF,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAAI,sBAAsB,sBAAsB,gCAsC7E,CAAC"}
1
+ {"version":3,"file":"ErrorBoundary.d.ts","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AAIvF,OAAO,EAAwC,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAerG;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,SAAS,CAAC;IACpB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,IAAI,KAAK,SAAS,CAAC,CAAC;IACxE,iEAAiE;IACjE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IAClD,qFAAqF;IACrF,SAAS,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;IAC/B;;;kFAG8E;IAC9E,QAAQ,CAAC,EAAE,OAAO,GAAG,iBAAiB,CAAC;CACxC;AAED,UAAU,kBAAkB;IAC1B,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAQD,qBAAa,aAAc,SAAQ,SAAS,CAAC,kBAAkB,EAAE,kBAAkB,CAAC;IAClF,KAAK,EAAE,kBAAkB,CAAmB;IAE5C,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,KAAK,GAAG,kBAAkB;IAIjE,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI;IAItD,kBAAkB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI;IAMlD,KAAK,QAAO,IAAI,CAEd;IAEF,MAAM,IAAI,SAAS;CA0BpB;AA4CD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAMzD;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,IAAI,OAAO,CAGnD;AAED,MAAM,WAAW,sBAAsB;IACrC,gFAAgF;IAChF,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAAI,sBAAsB,sBAAsB,gCAqC7E,CAAC"}
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Component, useEffect, useState } from 'react';
3
3
  import { Alert } from './Alert.js';
4
4
  import { Spinner } from './Spinner.js';
5
+ import { claimStaleChunkReload, staleChunkReloadedRecently } from '../lazy/staleChunk.js';
5
6
  import { ConnectionHealer, isUnreachableError } from './ConnectionHealer.js';
6
7
  const keysChanged = (a, b) => {
7
8
  if (!a || !b)
@@ -39,7 +40,7 @@ export class ErrorBoundary extends Component {
39
40
  // visualizer, the owner's report of 2026-08-22), and `isUnreachableError` matches
40
41
  // the substring `failed to fetch`, so `autoHeal` would have claimed the server was
41
42
  // unreachable while it answered every request. `StaleDeployHealer` reloads exactly
42
- // once per tab per URL and explains itself if that does not help.
43
+ // once per tab per minute and explains itself if that does not help.
43
44
  if (isStaleChunkError(error))
44
45
  return _jsx(StaleDeployHealer, { error: error });
45
46
  if (autoHeal && isUnreachableError(error))
@@ -78,7 +79,7 @@ export class ErrorBoundary extends Component {
78
79
  * hashes, and the route loads. If the chunk is genuinely missing from the
79
80
  * deployed artifact rather than merely stale, reloading would loop — so the
80
81
  * attempt is recorded in `sessionStorage` and only ever made ONCE per tab per
81
- * URL. The second failure stops and says what is actually wrong, which is a
82
+ * minute (the memory `lazyWithReload` shares). The second failure stops and says what is actually wrong, which is a
82
83
  * broken deployment and a sentence the owner can act on.
83
84
  *
84
85
  * Since 2026-08-22 the origin answers a missing build asset with a 404 rather
@@ -102,26 +103,20 @@ export function isStaleChunkError(error) {
102
103
  const message = (error instanceof Error ? `${error.name} ${error.message}` : String(error)).toLowerCase();
103
104
  return STALE_CHUNK_PATTERNS.some((p) => message.includes(p));
104
105
  }
105
- /** One key per tab per URL — a reload that did not help must not be repeated. */
106
- const attemptKey = () => `cwip.staleChunkReload:${typeof window === 'undefined' ? '' : window.location.pathname}`;
107
- /** Whether this tab has already tried reloading for this URL. Never throws. */
106
+ /**
107
+ * Whether this tab already reloaded for a stale chunk inside the last minute — and
108
+ * true when storage is unavailable, so a reload loop is impossible in the one
109
+ * environment where we cannot remember trying. Never throws.
110
+ *
111
+ * 🔴 The memory is `lazyWithReload`'s (`../lazy/staleChunk.ts`), shared on purpose.
112
+ * Until 2026-09-27 this was its own flag, once per tab per URL FOREVER: a tab left
113
+ * open across a SECOND deploy — a film playing for hours — got "could not be
114
+ * loaded" instead of a reload, and the two guards could add up to two reloads.
115
+ */
108
116
  export function staleChunkReloadAttempted() {
109
- try {
110
- return window.sessionStorage.getItem(attemptKey()) !== null;
111
- }
112
- catch {
113
- // Private mode / disabled storage: treat as "already tried" so a reload loop
114
- // is impossible in the one environment where we cannot remember trying.
117
+ if (typeof window === 'undefined')
115
118
  return true;
116
- }
117
- }
118
- function markStaleChunkReload() {
119
- try {
120
- window.sessionStorage.setItem(attemptKey(), '1');
121
- }
122
- catch {
123
- // Nothing to do — `staleChunkReloadAttempted` fails closed for the same reason.
124
- }
119
+ return staleChunkReloadedRecently();
125
120
  }
126
121
  /**
127
122
  * Drop-in fallback for a stale-chunk error: reloads once, automatically, and if
@@ -134,11 +129,10 @@ export const StaleDeployHealer = ({ error, onRecover }) => {
134
129
  useEffect(() => {
135
130
  if (typeof window === 'undefined')
136
131
  return;
137
- if (staleChunkReloadAttempted()) {
132
+ if (!claimStaleChunkReload()) {
138
133
  setExhausted(true);
139
134
  return;
140
135
  }
141
- markStaleChunkReload();
142
136
  // A tick of delay so the banner paints first — an instant reload on a slow
143
137
  // connection reads as a flicker with no explanation.
144
138
  const timer = setTimeout(() => {
@@ -1 +1 @@
1
- {"version":3,"file":"ErrorBoundary.js","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ErrorBoundary.js","sourceRoot":"","sources":["../../../src/react/components/ErrorBoundary.tsx"],"names":[],"mappings":"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"}
@@ -17,7 +17,15 @@
17
17
  * boundary, rather than reloading in a loop.
18
18
  *
19
19
  * The memory is `sessionStorage`, which is per tab AND per origin, so one generic
20
- * key serves every app without two of them ever reading each other's.
20
+ * key serves every app without two of them ever reading each other's. It is a TIME,
21
+ * not a flag: a tab left open across two deploys (a film playing for hours) heals
22
+ * both times. `ErrorBoundary`'s `StaleDeployHealer` reads and writes the same
23
+ * memory, so the two never add up to more than one reload a minute.
24
+ *
25
+ * 🔴 It fails CLOSED. Storage this tab cannot read or write (site data blocked)
26
+ * means no reload at all — the error goes to the boundary, which explains it —
27
+ * because a chunk genuinely missing from the deployed build would otherwise reload
28
+ * forever with nothing able to remember trying.
21
29
  *
22
30
  * `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
23
31
  * is refused, so the next lazy view cannot quietly reopen this.
@@ -31,6 +39,15 @@ export interface StaleChunkEnv {
31
39
  reload: () => void;
32
40
  now: () => number;
33
41
  }
42
+ export declare function browserEnv(): StaleChunkEnv;
43
+ /**
44
+ * Claim this tab's one stale-chunk reload for the window: true when a reload may
45
+ * happen now, and the claim is already recorded. False when one happened inside the
46
+ * window, or when the memory cannot be read or written — fail closed, see the header.
47
+ */
48
+ export declare function claimStaleChunkReload(env?: StaleChunkEnv): boolean;
49
+ /** Whether this tab reloaded for a stale chunk inside the window — true, too, when it cannot tell. */
50
+ export declare function staleChunkReloadedRecently(env?: StaleChunkEnv): boolean;
34
51
  /** Wrap a dynamic `import()` so a chunk a deploy removed reloads the page instead of failing. */
35
52
  export declare function reloadOnStaleChunk<T>(load: () => Promise<T>, env?: () => StaleChunkEnv): () => Promise<T>;
36
53
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"staleChunk.d.ts","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,mBAAmB,EAAQ,MAAM,OAAO,CAAC;AAE3E,eAAO,MAAM,2BAA2B,kCAAkC,CAAC;AAE3E,6EAA6E;AAC7E,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC;IACrD,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAYD,iGAAiG;AACjG,wBAAgB,kBAAkB,CAAC,CAAC,EAClC,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,GAAG,GAAE,MAAM,aAA0B,GACpC,MAAM,OAAO,CAAC,CAAC,CAAC,CAsBlB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,aAAa,CAAC,GAAG,CAAC,EACzD,IAAI,EAAE,MAAM,OAAO,CAAC;IAAE,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC,EACnC,GAAG,CAAC,EAAE,MAAM,aAAa,GACxB,mBAAmB,CAAC,CAAC,CAAC,CAExB"}
1
+ {"version":3,"file":"staleChunk.d.ts","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,mBAAmB,EAAQ,MAAM,OAAO,CAAC;AAE3E,eAAO,MAAM,2BAA2B,kCAAkC,CAAC;AAE3E,6EAA6E;AAC7E,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC;IACrD,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,wBAAgB,UAAU,IAAI,aAAa,CAQ1C;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,aAA4B,GAAG,OAAO,CAShF;AAED,sGAAsG;AACtG,wBAAgB,0BAA0B,CAAC,GAAG,GAAE,aAA4B,GAAG,OAAO,CASrF;AAED,iGAAiG;AACjG,wBAAgB,kBAAkB,CAAC,CAAC,EAClC,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,GAAG,GAAE,MAAM,aAA0B,GACpC,MAAM,OAAO,CAAC,CAAC,CAAC,CAWlB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,aAAa,CAAC,GAAG,CAAC,EACzD,IAAI,EAAE,MAAM,OAAO,CAAC;IAAE,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC,EACnC,GAAG,CAAC,EAAE,MAAM,aAAa,GACxB,mBAAmB,CAAC,CAAC,CAAC,CAExB"}
@@ -17,7 +17,15 @@
17
17
  * boundary, rather than reloading in a loop.
18
18
  *
19
19
  * The memory is `sessionStorage`, which is per tab AND per origin, so one generic
20
- * key serves every app without two of them ever reading each other's.
20
+ * key serves every app without two of them ever reading each other's. It is a TIME,
21
+ * not a flag: a tab left open across two deploys (a film playing for hours) heals
22
+ * both times. `ErrorBoundary`'s `StaleDeployHealer` reads and writes the same
23
+ * memory, so the two never add up to more than one reload a minute.
24
+ *
25
+ * 🔴 It fails CLOSED. Storage this tab cannot read or write (site data blocked)
26
+ * means no reload at all — the error goes to the boundary, which explains it —
27
+ * because a chunk genuinely missing from the deployed build would otherwise reload
28
+ * forever with nothing able to remember trying.
21
29
  *
22
30
  * `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
23
31
  * is refused, so the next lazy view cannot quietly reopen this.
@@ -26,16 +34,46 @@ import { lazy } from 'react';
26
34
  export const STALE_CHUNK_RELOADED_AT_KEY = 'cursedbelt.staleChunkReloadAt';
27
35
  /** A reload within this long of the last one is a loop, not a stale page. */
28
36
  export const STALE_CHUNK_RELOAD_WINDOW_MS = 60_000;
29
- function browserEnv() {
37
+ export function browserEnv() {
30
38
  let storage = null;
31
39
  try {
32
40
  storage = window.sessionStorage;
33
41
  }
34
42
  catch {
35
- // Storage refused (private mode, blocked site data) — reload without the guard's memory.
43
+ // Storage refused (blocked site data) — no memory, so no reload: the guard fails closed.
36
44
  }
37
45
  return { storage, reload: () => window.location.reload(), now: Date.now };
38
46
  }
47
+ /**
48
+ * Claim this tab's one stale-chunk reload for the window: true when a reload may
49
+ * happen now, and the claim is already recorded. False when one happened inside the
50
+ * window, or when the memory cannot be read or written — fail closed, see the header.
51
+ */
52
+ export function claimStaleChunkReload(env = browserEnv()) {
53
+ if (staleChunkReloadedRecently(env))
54
+ return false;
55
+ const stamp = String(env.now());
56
+ try {
57
+ env.storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, stamp);
58
+ return env.storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) === stamp;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** Whether this tab reloaded for a stale chunk inside the window — true, too, when it cannot tell. */
65
+ export function staleChunkReloadedRecently(env = browserEnv()) {
66
+ const { storage, now } = env;
67
+ if (!storage)
68
+ return true;
69
+ try {
70
+ const last = Number(storage.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
71
+ return last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS;
72
+ }
73
+ catch {
74
+ return true;
75
+ }
76
+ }
39
77
  /** Wrap a dynamic `import()` so a chunk a deploy removed reloads the page instead of failing. */
40
78
  export function reloadOnStaleChunk(load, env = browserEnv) {
41
79
  return async () => {
@@ -43,23 +81,10 @@ export function reloadOnStaleChunk(load, env = browserEnv) {
43
81
  return await load();
44
82
  }
45
83
  catch (error) {
46
- const { storage, reload, now } = env();
47
- let last = 0;
48
- try {
49
- last = Number(storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
50
- }
51
- catch {
52
- // Unreadable memory is treated as no reload yet.
53
- }
54
- if (last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS)
84
+ const current = env();
85
+ if (!claimStaleChunkReload(current))
55
86
  throw error;
56
- try {
57
- storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, String(now()));
58
- }
59
- catch {
60
- // Unwritable: the reload still happens; the guard just cannot remember it.
61
- }
62
- reload();
87
+ current.reload();
63
88
  return new Promise(() => undefined);
64
89
  }
65
90
  };
@@ -1 +1 @@
1
- {"version":3,"file":"staleChunk.js","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAgD,IAAI,EAAE,MAAM,OAAO,CAAC;AAE3E,MAAM,CAAC,MAAM,2BAA2B,GAAG,+BAA+B,CAAC;AAE3E,6EAA6E;AAC7E,MAAM,CAAC,MAAM,4BAA4B,GAAG,MAAM,CAAC;AAQnD,SAAS,UAAU;IACjB,IAAI,OAAO,GAA6B,IAAI,CAAC;IAC7C,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,CAAC,cAAc,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,yFAAyF;IAC3F,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC;AAC5E,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,kBAAkB,CAChC,IAAsB,EACtB,MAA2B,UAAU;IAErC,OAAO,KAAK,IAAI,EAAE;QAChB,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,EAAE,CAAC;QACtB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,GAAG,EAAE,CAAC;YACvC,IAAI,IAAI,GAAG,CAAC,CAAC;YACb,IAAI,CAAC;gBACH,IAAI,GAAG,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,2BAA2B,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;YACzE,CAAC;YAAC,MAAM,CAAC;gBACP,iDAAiD;YACnD,CAAC;YACD,IAAI,IAAI,GAAG,CAAC,IAAI,GAAG,EAAE,GAAG,IAAI,GAAG,4BAA4B;gBAAE,MAAM,KAAK,CAAC;YACzE,IAAI,CAAC;gBACH,OAAO,EAAE,OAAO,CAAC,2BAA2B,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;YAC/D,CAAC;YAAC,MAAM,CAAC;gBACP,2EAA2E;YAC7E,CAAC;YACD,MAAM,EAAE,CAAC;YACT,OAAO,IAAI,OAAO,CAAI,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACzC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAmC,EACnC,GAAyB;IAEzB,OAAO,IAAI,CAAC,kBAAkB,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC"}
1
+ {"version":3,"file":"staleChunk.js","sourceRoot":"","sources":["../../../src/react/lazy/staleChunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAgD,IAAI,EAAE,MAAM,OAAO,CAAC;AAE3E,MAAM,CAAC,MAAM,2BAA2B,GAAG,+BAA+B,CAAC;AAE3E,6EAA6E;AAC7E,MAAM,CAAC,MAAM,4BAA4B,GAAG,MAAM,CAAC;AAQnD,MAAM,UAAU,UAAU;IACxB,IAAI,OAAO,GAA6B,IAAI,CAAC;IAC7C,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,CAAC,cAAc,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,yFAAyF;IAC3F,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC;AAC5E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAqB,UAAU,EAAE;IACrE,IAAI,0BAA0B,CAAC,GAAG,CAAC;QAAE,OAAO,KAAK,CAAC;IAClD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;IAChC,IAAI,CAAC;QACH,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,2BAA2B,EAAE,KAAK,CAAC,CAAC;QACzD,OAAO,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,2BAA2B,CAAC,KAAK,KAAK,CAAC;IACrE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,0BAA0B,CAAC,MAAqB,UAAU,EAAE;IAC1E,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC;IAC7B,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,2BAA2B,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;QAC5E,OAAO,IAAI,GAAG,CAAC,IAAI,GAAG,EAAE,GAAG,IAAI,GAAG,4BAA4B,CAAC;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,kBAAkB,CAChC,IAAsB,EACtB,MAA2B,UAAU;IAErC,OAAO,KAAK,IAAI,EAAE;QAChB,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,EAAE,CAAC;QACtB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;YACtB,IAAI,CAAC,qBAAqB,CAAC,OAAO,CAAC;gBAAE,MAAM,KAAK,CAAC;YACjD,OAAO,CAAC,MAAM,EAAE,CAAC;YACjB,OAAO,IAAI,OAAO,CAAI,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACzC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAmC,EACnC,GAAyB;IAEzB,OAAO,IAAI,CAAC,kBAAkB,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.12.0",
3
+ "version": "5.12.1",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -1,6 +1,7 @@
1
1
  import { Component, type ErrorInfo, type ReactNode, useEffect, useState } from 'react';
2
2
  import { Alert } from './Alert.js';
3
3
  import { Spinner } from './Spinner.js';
4
+ import { claimStaleChunkReload, staleChunkReloadedRecently } from '../lazy/staleChunk.js';
4
5
  import { ConnectionHealer, isUnreachableError, type ServerHealOptions } from './ConnectionHealer.js';
5
6
 
6
7
  /* ───────────────────────────────────────────────────────────────────────────
@@ -88,7 +89,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
88
89
  // visualizer, the owner's report of 2026-08-22), and `isUnreachableError` matches
89
90
  // the substring `failed to fetch`, so `autoHeal` would have claimed the server was
90
91
  // unreachable while it answered every request. `StaleDeployHealer` reloads exactly
91
- // once per tab per URL and explains itself if that does not help.
92
+ // once per tab per minute and explains itself if that does not help.
92
93
  if (isStaleChunkError(error)) return <StaleDeployHealer error={error} />;
93
94
  if (autoHeal && isUnreachableError(error))
94
95
  return <ConnectionHealer error={error} {...(autoHeal === true ? {} : autoHeal)} />;
@@ -129,7 +130,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
129
130
  * hashes, and the route loads. If the chunk is genuinely missing from the
130
131
  * deployed artifact rather than merely stale, reloading would loop — so the
131
132
  * attempt is recorded in `sessionStorage` and only ever made ONCE per tab per
132
- * URL. The second failure stops and says what is actually wrong, which is a
133
+ * minute (the memory `lazyWithReload` shares). The second failure stops and says what is actually wrong, which is a
133
134
  * broken deployment and a sentence the owner can act on.
134
135
  *
135
136
  * Since 2026-08-22 the origin answers a missing build asset with a 404 rather
@@ -157,27 +158,19 @@ export function isStaleChunkError(error: unknown): boolean {
157
158
  return STALE_CHUNK_PATTERNS.some((p) => message.includes(p));
158
159
  }
159
160
 
160
- /** One key per tab per URL — a reload that did not help must not be repeated. */
161
- const attemptKey = (): string =>
162
- `cwip.staleChunkReload:${typeof window === 'undefined' ? '' : window.location.pathname}`;
163
-
164
- /** Whether this tab has already tried reloading for this URL. Never throws. */
161
+ /**
162
+ * Whether this tab already reloaded for a stale chunk inside the last minute — and
163
+ * true when storage is unavailable, so a reload loop is impossible in the one
164
+ * environment where we cannot remember trying. Never throws.
165
+ *
166
+ * 🔴 The memory is `lazyWithReload`'s (`../lazy/staleChunk.ts`), shared on purpose.
167
+ * Until 2026-09-27 this was its own flag, once per tab per URL FOREVER: a tab left
168
+ * open across a SECOND deploy — a film playing for hours — got "could not be
169
+ * loaded" instead of a reload, and the two guards could add up to two reloads.
170
+ */
165
171
  export function staleChunkReloadAttempted(): boolean {
166
- try {
167
- return window.sessionStorage.getItem(attemptKey()) !== null;
168
- } catch {
169
- // Private mode / disabled storage: treat as "already tried" so a reload loop
170
- // is impossible in the one environment where we cannot remember trying.
171
- return true;
172
- }
173
- }
174
-
175
- function markStaleChunkReload(): void {
176
- try {
177
- window.sessionStorage.setItem(attemptKey(), '1');
178
- } catch {
179
- // Nothing to do — `staleChunkReloadAttempted` fails closed for the same reason.
180
- }
172
+ if (typeof window === 'undefined') return true;
173
+ return staleChunkReloadedRecently();
181
174
  }
182
175
 
183
176
  export interface StaleDeployHealerProps {
@@ -198,11 +191,10 @@ export const StaleDeployHealer = ({ error, onRecover }: StaleDeployHealerProps)
198
191
 
199
192
  useEffect(() => {
200
193
  if (typeof window === 'undefined') return;
201
- if (staleChunkReloadAttempted()) {
194
+ if (!claimStaleChunkReload()) {
202
195
  setExhausted(true);
203
196
  return;
204
197
  }
205
- markStaleChunkReload();
206
198
  // A tick of delay so the banner paints first — an instant reload on a slow
207
199
  // connection reads as a flicker with no explanation.
208
200
  const timer = setTimeout(() => {
@@ -1,5 +1,6 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
2
  import { cleanup, render, screen, waitFor } from '@testing-library/react';
3
+ import { STALE_CHUNK_RELOADED_AT_KEY } from '../lazy/staleChunk.js';
3
4
  import { ErrorBoundary, isStaleChunkError, StaleDeployHealer } from './ErrorBoundary.js';
4
5
 
5
6
  afterEach(() => {
@@ -105,3 +106,33 @@ describe('StaleDeployHealer recovers exactly once', () => {
105
106
  expect(screen.getByText(/VisualizerView-9pEZSQqW\.js/)).toBeTruthy();
106
107
  });
107
108
  });
109
+
110
+ describe('StaleDeployHealer shares lazyWithReload\'s memory', () => {
111
+ /**
112
+ * 🔴 The old flag was once per tab per URL FOREVER, so a tab left open across a SECOND
113
+ * deploy — a film playing for hours — hit the dead end instead of reloading.
114
+ */
115
+ test('a tab open across two deploys heals both', async () => {
116
+ let recovered = 0;
117
+ const first = render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
118
+ await waitFor(() => expect(recovered).toBe(1), { timeout: 2000 });
119
+ first.unmount();
120
+
121
+ const twoHoursLater = Date.now() + 2 * 60 * 60_000;
122
+ const clock = spyOn(Date, 'now').mockImplementation(() => twoHoursLater);
123
+ try {
124
+ render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
125
+ await waitFor(() => expect(recovered).toBe(2), { timeout: 2000 });
126
+ } finally {
127
+ clock.mockRestore();
128
+ }
129
+ });
130
+
131
+ test('a reload lazyWithReload just made is not repeated by the boundary', async () => {
132
+ window.sessionStorage.setItem(STALE_CHUNK_RELOADED_AT_KEY, String(Date.now() - 1000));
133
+ let recovered = 0;
134
+ render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
135
+ await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeTruthy());
136
+ expect(recovered).toBe(0);
137
+ });
138
+ });
@@ -82,12 +82,31 @@ describe('a chunk a deploy removed', () => {
82
82
  expect(state.reloads).toBe(2);
83
83
  });
84
84
 
85
- test('with no storage at all it still reloads rather than failing', async () => {
85
+ /*
86
+ * 🔴 Fail CLOSED. With no memory, a chunk genuinely missing from the deployed build
87
+ * would reload forever; the error goes to the boundary instead, which explains it.
88
+ */
89
+ test('with no storage it never reloads — the error goes to the boundary', async () => {
86
90
  const { state } = fakeEnv(1_000_000);
87
91
  const env: StaleChunkEnv = { storage: null, reload: () => void state.reloads++, now: () => 5 };
88
- void reloadOnStaleChunk(gone, () => env)();
89
- await tick();
90
- expect(state.reloads).toBe(1);
92
+ await expect(reloadOnStaleChunk(gone, () => env)()).rejects.toThrow('dynamically imported');
93
+ expect(state.reloads).toBe(0);
94
+ });
95
+
96
+ test('storage that refuses a write is no memory either — no reload', async () => {
97
+ const { state } = fakeEnv(1_000_000);
98
+ const env: StaleChunkEnv = {
99
+ storage: {
100
+ getItem: () => null,
101
+ setItem: () => {
102
+ throw new DOMException('blocked', 'SecurityError');
103
+ },
104
+ },
105
+ reload: () => void state.reloads++,
106
+ now: () => 5,
107
+ };
108
+ await expect(reloadOnStaleChunk(gone, () => env)()).rejects.toThrow('dynamically imported');
109
+ expect(state.reloads).toBe(0);
91
110
  });
92
111
  });
93
112
 
@@ -17,7 +17,15 @@
17
17
  * boundary, rather than reloading in a loop.
18
18
  *
19
19
  * The memory is `sessionStorage`, which is per tab AND per origin, so one generic
20
- * key serves every app without two of them ever reading each other's.
20
+ * key serves every app without two of them ever reading each other's. It is a TIME,
21
+ * not a flag: a tab left open across two deploys (a film playing for hours) heals
22
+ * both times. `ErrorBoundary`'s `StaleDeployHealer` reads and writes the same
23
+ * memory, so the two never add up to more than one reload a minute.
24
+ *
25
+ * 🔴 It fails CLOSED. Storage this tab cannot read or write (site data blocked)
26
+ * means no reload at all — the error goes to the boundary, which explains it —
27
+ * because a chunk genuinely missing from the deployed build would otherwise reload
28
+ * forever with nothing able to remember trying.
21
29
  *
22
30
  * `guardrailsEnforce`'s `no-raw-lazy` is the WHETHER: a bare `React.lazy` in an app
23
31
  * is refused, so the next lazy view cannot quietly reopen this.
@@ -35,16 +43,44 @@ export interface StaleChunkEnv {
35
43
  now: () => number;
36
44
  }
37
45
 
38
- function browserEnv(): StaleChunkEnv {
46
+ export function browserEnv(): StaleChunkEnv {
39
47
  let storage: StaleChunkEnv['storage'] = null;
40
48
  try {
41
49
  storage = window.sessionStorage;
42
50
  } catch {
43
- // Storage refused (private mode, blocked site data) — reload without the guard's memory.
51
+ // Storage refused (blocked site data) — no memory, so no reload: the guard fails closed.
44
52
  }
45
53
  return { storage, reload: () => window.location.reload(), now: Date.now };
46
54
  }
47
55
 
56
+ /**
57
+ * Claim this tab's one stale-chunk reload for the window: true when a reload may
58
+ * happen now, and the claim is already recorded. False when one happened inside the
59
+ * window, or when the memory cannot be read or written — fail closed, see the header.
60
+ */
61
+ export function claimStaleChunkReload(env: StaleChunkEnv = browserEnv()): boolean {
62
+ if (staleChunkReloadedRecently(env)) return false;
63
+ const stamp = String(env.now());
64
+ try {
65
+ env.storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, stamp);
66
+ return env.storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) === stamp;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /** Whether this tab reloaded for a stale chunk inside the window — true, too, when it cannot tell. */
73
+ export function staleChunkReloadedRecently(env: StaleChunkEnv = browserEnv()): boolean {
74
+ const { storage, now } = env;
75
+ if (!storage) return true;
76
+ try {
77
+ const last = Number(storage.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
78
+ return last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS;
79
+ } catch {
80
+ return true;
81
+ }
82
+ }
83
+
48
84
  /** Wrap a dynamic `import()` so a chunk a deploy removed reloads the page instead of failing. */
49
85
  export function reloadOnStaleChunk<T>(
50
86
  load: () => Promise<T>,
@@ -54,20 +90,9 @@ export function reloadOnStaleChunk<T>(
54
90
  try {
55
91
  return await load();
56
92
  } catch (error) {
57
- const { storage, reload, now } = env();
58
- let last = 0;
59
- try {
60
- last = Number(storage?.getItem(STALE_CHUNK_RELOADED_AT_KEY) ?? 0) || 0;
61
- } catch {
62
- // Unreadable memory is treated as no reload yet.
63
- }
64
- if (last > 0 && now() - last < STALE_CHUNK_RELOAD_WINDOW_MS) throw error;
65
- try {
66
- storage?.setItem(STALE_CHUNK_RELOADED_AT_KEY, String(now()));
67
- } catch {
68
- // Unwritable: the reload still happens; the guard just cannot remember it.
69
- }
70
- reload();
93
+ const current = env();
94
+ if (!claimStaleChunkReload(current)) throw error;
95
+ current.reload();
71
96
  return new Promise<T>(() => undefined);
72
97
  }
73
98
  };