@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +137 -22
- package/dist/index.es.js.map +1 -1
- package/dist/util/lazy_chunk.d.ts +30 -0
- package/package.json +1 -1
- package/src/components/ErrorBoundary.tsx +51 -12
- package/src/index.ts +1 -0
- package/src/util/lazy_chunk.ts +101 -0
|
@@ -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.
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
{
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|