@rebasepro/ui 0.14.1 → 0.14.2-canary.gca521e9

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.
@@ -0,0 +1,30 @@
1
+ import React from "react";
2
+ /**
3
+ * Does this error mean a chunk could not be loaded — rather than the chunk's
4
+ * own code throwing once it did load?
5
+ *
6
+ * Errors raised anywhere are matched, not just ones `loadChunk` produced: React
7
+ * `lazy()` calls that still import directly, and Vite's own CSS preloads, fail
8
+ * the same way and deserve the same recovery.
9
+ */
10
+ export declare function isChunkLoadError(error: unknown): boolean;
11
+ /**
12
+ * Run a dynamic import, retrying once before declaring the chunk unreachable.
13
+ *
14
+ * Errors that are not chunk load failures — the imported module throwing while
15
+ * it evaluates, say — are re-thrown untouched and never retried: running a
16
+ * module's side effects twice is worse than the original failure.
17
+ */
18
+ export declare function loadChunk<T>(loader: () => Promise<T>): Promise<T>;
19
+ /**
20
+ * `React.lazy` with the retry above. A drop-in replacement — use it for every
21
+ * lazy route or dialog, since any one of them can be the first chunk a stale
22
+ * tab reaches for.
23
+ *
24
+ * Note that React caches the rejection: once a lazy component has failed, later
25
+ * renders re-throw without calling the loader again. Resetting an error boundary
26
+ * therefore cannot recover it, which is why the boundary offers a reload.
27
+ */
28
+ export declare function lazyChunk<T extends React.ComponentType<any>>(loader: () => Promise<{
29
+ default: T;
30
+ }>): React.LazyExoticComponent<T>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/ui",
3
3
  "type": "module",
4
- "version": "0.14.1",
4
+ "version": "0.14.2-canary.gca521e9",
5
5
  "description": "Rebase design system — React components and design tokens built on Radix UI, Tailwind CSS and lucide-react",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -6,6 +6,16 @@ import { iconSize } from "../icons/Icon";
6
6
  import { Typography } from "./Typography";
7
7
  import { Button } from "./Button";
8
8
  import { cls } from "../util";
9
+ import { isChunkLoadError } from "../util/lazy_chunk";
10
+
11
+ /**
12
+ * The one error a user can fix themselves. A deploy replaces the hashed chunk
13
+ * files, so a tab opened before it cannot load any lazy view it has not already
14
+ * fetched — with the browser's raw message ("Failed to fetch dynamically
15
+ * imported module: …") this reads as a broken build rather than a stale tab.
16
+ */
17
+ const CHUNK_ERROR_DESCRIPTION =
18
+ "This app was updated while the tab was open, so part of it could not be loaded. Reload to continue.";
9
19
 
10
20
  /**
11
21
  * Checks whether the error message relates to missing permissions or
@@ -86,15 +96,29 @@ showDetails: false });
86
96
  }
87
97
 
88
98
  private renderInline() {
99
+ const isStaleChunk = isChunkLoadError(this.state.error);
89
100
  return (
90
- <div className="flex flex-col m-2">
101
+ <div className="flex flex-col m-2 items-start">
91
102
  <div className="flex items-center m-2">
92
- <AlertCircleIcon className={"text-red-500 dark:text-red-400"} size={iconSize.small}/>
93
- <div className="ml-4">Error</div>
103
+ {isStaleChunk
104
+ ? <RefreshCwIcon className={"text-text-secondary dark:text-text-secondary-dark"}
105
+ size={iconSize.small}/>
106
+ : <AlertCircleIcon className={"text-red-500 dark:text-red-400"} size={iconSize.small}/>}
107
+ <div className="ml-4">{isStaleChunk ? "New version available" : "Error"}</div>
94
108
  </div>
95
109
  <Typography variant={"caption"}>
96
- {this.state.error?.message ?? "See the error in the console"}
110
+ {isStaleChunk
111
+ ? CHUNK_ERROR_DESCRIPTION
112
+ : this.state.error?.message ?? "See the error in the console"}
97
113
  </Typography>
114
+ {isStaleChunk && <Button variant="outlined"
115
+ color="neutral"
116
+ size="small"
117
+ className="mt-3 ml-2"
118
+ onClick={this.handleReload}>
119
+ <RefreshCwIcon size={16}/>
120
+ Reload
121
+ </Button>}
98
122
  </div>
99
123
  );
100
124
  }
@@ -102,14 +126,20 @@ showDetails: false });
102
126
  private renderFullPage() {
103
127
  const { error, showDetails } = this.state;
104
128
  const isPermission = isPermissionError(error);
129
+ // Neither a fault nor a denial: the app is fine, this tab is behind it.
130
+ const isStaleChunk = !isPermission && isChunkLoadError(error);
105
131
 
106
- const Icon = isPermission ? ShieldAlertIcon : AlertCircleIcon;
132
+ const Icon = isPermission ? ShieldAlertIcon : isStaleChunk ? RefreshCwIcon : AlertCircleIcon;
107
133
  const title = isPermission
108
134
  ? "Access denied"
109
- : "Something went wrong";
135
+ : isStaleChunk
136
+ ? "New version available"
137
+ : "Something went wrong";
110
138
  const description = isPermission
111
139
  ? "You don't have permission to access this resource. Please check your account permissions or contact your administrator."
112
- : "An unexpected error occurred. You can try reloading the page or going back.";
140
+ : isStaleChunk
141
+ ? CHUNK_ERROR_DESCRIPTION
142
+ : "An unexpected error occurred. You can try reloading the page or going back.";
113
143
 
114
144
  return (
115
145
  <div className={cls(
@@ -121,13 +151,17 @@ showDetails: false });
121
151
  "flex items-center justify-center w-14 h-14 rounded-xl mb-6",
122
152
  isPermission
123
153
  ? "bg-amber-100 dark:bg-amber-900/30"
124
- : "bg-red-100 dark:bg-red-900/30"
154
+ : isStaleChunk
155
+ ? "bg-surface-100 dark:bg-surface-800"
156
+ : "bg-red-100 dark:bg-red-900/30"
125
157
  )}>
126
158
  <Icon
127
159
  size={28}
128
160
  className={isPermission
129
161
  ? "text-amber-600 dark:text-amber-400"
130
- : "text-red-500 dark:text-red-400"}
162
+ : isStaleChunk
163
+ ? "text-text-secondary dark:text-text-secondary-dark"
164
+ : "text-red-500 dark:text-red-400"}
131
165
  />
132
166
  </div>
133
167
 
@@ -142,7 +176,10 @@ showDetails: false });
142
176
  </Typography>
143
177
 
144
178
  <div className="flex gap-3">
145
- <Button
179
+ {/* React caches a `lazy()` rejection: re-mounting the
180
+ children re-throws without re-running the import, so
181
+ "Try again" cannot recover a missing chunk. */}
182
+ {!isStaleChunk && <Button
146
183
  variant="outlined"
147
184
  color="neutral"
148
185
  size="medium"
@@ -150,7 +187,7 @@ showDetails: false });
150
187
  >
151
188
  <ArrowLeftIcon size={16}/>
152
189
  Try again
153
- </Button>
190
+ </Button>}
154
191
  <Button
155
192
  variant="filled"
156
193
  color="primary"
@@ -162,7 +199,9 @@ showDetails: false });
162
199
  </Button>
163
200
  </div>
164
201
 
165
- {error?.message && (
202
+ {/* For a stale chunk the message *is* the description
203
+ above; repeating it under a disclosure adds nothing. */}
204
+ {!isStaleChunk && error?.message && (
166
205
  <div className="mt-8 w-full">
167
206
  <button
168
207
  onClick={this.toggleDetails}
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export * from "./icons";
18
18
  // =============================================================================
19
19
  export * from "./styles";
20
20
  export { cls } from "./util/cls";
21
+ export { lazyChunk, loadChunk, isChunkLoadError } from "./util/lazy_chunk";
21
22
  export { CHIP_COLORS, CHIP_HUES, CHIP_SEED_KEYS, getColorSchemeForKey, getColorSchemeForSeed } from "./util/chip_colors";
22
23
  export type { ChipHue, ChipTone } from "./util/chip_colors";
23
24
 
@@ -0,0 +1,101 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Dynamic imports that survive a redeploy.
5
+ *
6
+ * A built SPA names its chunks by content hash and a deploy replaces the whole
7
+ * `assets/` directory, so a tab opened before the deploy still holds the *old*
8
+ * entry chunk. The first lazy route that tab opens asks for a hash that no
9
+ * longer exists on the server, the import rejects, and the view is dead until
10
+ * the user thinks to reload — which nothing on screen tells them to do. The
11
+ * browser's own wording ("Failed to fetch dynamically imported module: …") reads
12
+ * like a build bug, so this is usually reported as one.
13
+ *
14
+ * `loadChunk` retries once — that covers a transient network failure — and then
15
+ * gives up with an error tagged as a chunk load failure, so `ErrorBoundary` can
16
+ * offer a reload instead of printing the browser's message.
17
+ */
18
+
19
+ /**
20
+ * Marker property set on the error thrown after a failed retry. Read by
21
+ * `isChunkLoadError`, which is what the UI matches on.
22
+ */
23
+ const CHUNK_LOAD_ERROR_FLAG = "rebaseChunkLoadError";
24
+
25
+ /**
26
+ * Each engine words this differently, and the message is the only signal — none
27
+ * of them use a distinguishable error type. The MIME variants matter as much as
28
+ * the fetch ones: a server whose SPA fallback answers a missing `/assets/x.js`
29
+ * with `index.html` returns 200 HTML, and the browser rejects it as a module.
30
+ */
31
+ const CHUNK_ERROR_PATTERNS = [
32
+ "failed to fetch dynamically imported module",
33
+ "error loading dynamically imported module",
34
+ "importing a module script failed",
35
+ "failed to load module script",
36
+ "expected a javascript",
37
+ "unable to preload css"
38
+ ];
39
+
40
+ /**
41
+ * Does this error mean a chunk could not be loaded — rather than the chunk's
42
+ * own code throwing once it did load?
43
+ *
44
+ * Errors raised anywhere are matched, not just ones `loadChunk` produced: React
45
+ * `lazy()` calls that still import directly, and Vite's own CSS preloads, fail
46
+ * the same way and deserve the same recovery.
47
+ */
48
+ export function isChunkLoadError(error: unknown): boolean {
49
+ if (!error || typeof error !== "object") return false;
50
+ if ((error as Record<string, unknown>)[CHUNK_LOAD_ERROR_FLAG] === true) return true;
51
+ const message = (error as { message?: unknown }).message;
52
+ if (typeof message !== "string") return false;
53
+ const lower = message.toLowerCase();
54
+ return CHUNK_ERROR_PATTERNS.some(pattern => lower.includes(pattern));
55
+ }
56
+
57
+ function chunkLoadError(cause: unknown): Error {
58
+ const error = new Error(
59
+ "This app was updated while the tab was open, so part of it could not be loaded. Reload to continue."
60
+ );
61
+ Object.assign(error, { [CHUNK_LOAD_ERROR_FLAG]: true,
62
+ cause });
63
+ return error;
64
+ }
65
+
66
+ /**
67
+ * Run a dynamic import, retrying once before declaring the chunk unreachable.
68
+ *
69
+ * Errors that are not chunk load failures — the imported module throwing while
70
+ * it evaluates, say — are re-thrown untouched and never retried: running a
71
+ * module's side effects twice is worse than the original failure.
72
+ */
73
+ export async function loadChunk<T>(loader: () => Promise<T>): Promise<T> {
74
+ try {
75
+ return await loader();
76
+ } catch (error) {
77
+ if (!isChunkLoadError(error)) throw error;
78
+ // A blip resolves inside a few hundred ms; a stale hash never will.
79
+ await new Promise(resolve => setTimeout(resolve, 250));
80
+ try {
81
+ return await loader();
82
+ } catch (retryError) {
83
+ throw chunkLoadError(retryError);
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * `React.lazy` with the retry above. A drop-in replacement — use it for every
90
+ * lazy route or dialog, since any one of them can be the first chunk a stale
91
+ * tab reaches for.
92
+ *
93
+ * Note that React caches the rejection: once a lazy component has failed, later
94
+ * renders re-throw without calling the loader again. Resetting an error boundary
95
+ * therefore cannot recover it, which is why the boundary offers a reload.
96
+ */
97
+ export function lazyChunk<T extends React.ComponentType<any>>(
98
+ loader: () => Promise<{ default: T }>
99
+ ): React.LazyExoticComponent<T> {
100
+ return React.lazy(() => loadChunk(loader));
101
+ }