@gusnips/react 0.1.0
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/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/api-client.d.ts +139 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +133 -0
- package/dist/api-client.js.map +1 -0
- package/dist/api-error.d.ts +45 -0
- package/dist/api-error.d.ts.map +1 -0
- package/dist/api-error.js +52 -0
- package/dist/api-error.js.map +1 -0
- package/dist/auth-store.d.ts +49 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +27 -0
- package/dist/auth-store.js.map +1 -0
- package/dist/chunk-reload.d.ts +61 -0
- package/dist/chunk-reload.d.ts.map +1 -0
- package/dist/chunk-reload.js +92 -0
- package/dist/chunk-reload.js.map +1 -0
- package/dist/cn.d.ts +13 -0
- package/dist/cn.d.ts.map +1 -0
- package/dist/cn.js +16 -0
- package/dist/cn.js.map +1 -0
- package/dist/describe-error.d.ts +74 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +70 -0
- package/dist/describe-error.js.map +1 -0
- package/dist/error-boundary.d.ts +61 -0
- package/dist/error-boundary.d.ts.map +1 -0
- package/dist/error-boundary.js +32 -0
- package/dist/error-boundary.js.map +1 -0
- package/dist/guards.d.ts +65 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +52 -0
- package/dist/guards.js.map +1 -0
- package/dist/hydrate.d.ts +30 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +25 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/i18n.d.ts +70 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +66 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/prerender-contract.d.ts +30 -0
- package/dist/prerender-contract.d.ts.map +1 -0
- package/dist/prerender-contract.js +30 -0
- package/dist/prerender-contract.js.map +1 -0
- package/dist/query.d.ts +28 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +51 -0
- package/dist/query.js.map +1 -0
- package/dist/sse.d.ts +64 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +105 -0
- package/dist/sse.js.map +1 -0
- package/dist/states.d.ts +69 -0
- package/dist/states.d.ts.map +1 -0
- package/dist/states.js +2 -0
- package/dist/states.js.map +1 -0
- package/dist/ui/class-name.d.ts +16 -0
- package/dist/ui/class-name.d.ts.map +1 -0
- package/dist/ui/class-name.js +13 -0
- package/dist/ui/class-name.js.map +1 -0
- package/dist/ui/combobox.d.ts +81 -0
- package/dist/ui/combobox.d.ts.map +1 -0
- package/dist/ui/combobox.js +70 -0
- package/dist/ui/combobox.js.map +1 -0
- package/dist/ui/dialog.d.ts +39 -0
- package/dist/ui/dialog.d.ts.map +1 -0
- package/dist/ui/dialog.js +32 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/ui/drawer.d.ts +63 -0
- package/dist/ui/drawer.d.ts.map +1 -0
- package/dist/ui/drawer.js +55 -0
- package/dist/ui/drawer.js.map +1 -0
- package/dist/ui/glyphs.d.ts +4 -0
- package/dist/ui/glyphs.d.ts.map +1 -0
- package/dist/ui/glyphs.js +20 -0
- package/dist/ui/glyphs.js.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +48 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/input.d.ts +45 -0
- package/dist/ui/input.d.ts.map +1 -0
- package/dist/ui/input.js +108 -0
- package/dist/ui/input.js.map +1 -0
- package/dist/ui/menu.d.ts +70 -0
- package/dist/ui/menu.d.ts.map +1 -0
- package/dist/ui/menu.js +85 -0
- package/dist/ui/menu.js.map +1 -0
- package/dist/ui/select.d.ts +69 -0
- package/dist/ui/select.d.ts.map +1 -0
- package/dist/ui/select.js +48 -0
- package/dist/ui/select.js.map +1 -0
- package/dist/ui/tabs.d.ts +52 -0
- package/dist/ui/tabs.d.ts.map +1 -0
- package/dist/ui/tabs.js +62 -0
- package/dist/ui/tabs.js.map +1 -0
- package/package.json +124 -0
- package/src/api-client.test.ts +256 -0
- package/src/api-client.ts +305 -0
- package/src/api-error.ts +60 -0
- package/src/auth-store.ts +59 -0
- package/src/chunk-reload.test.ts +46 -0
- package/src/chunk-reload.ts +94 -0
- package/src/cn.ts +16 -0
- package/src/describe-error.test.ts +102 -0
- package/src/describe-error.ts +140 -0
- package/src/error-boundary.tsx +88 -0
- package/src/guards.tsx +99 -0
- package/src/hydrate.ts +36 -0
- package/src/i18n.test.ts +68 -0
- package/src/i18n.ts +101 -0
- package/src/index.ts +63 -0
- package/src/prerender-contract.ts +31 -0
- package/src/query.test.ts +54 -0
- package/src/query.ts +79 -0
- package/src/sse.test.ts +50 -0
- package/src/sse.ts +117 -0
- package/src/states.ts +71 -0
- package/src/ui/class-name.ts +23 -0
- package/src/ui/combobox.test.ts +32 -0
- package/src/ui/combobox.tsx +242 -0
- package/src/ui/dialog.tsx +87 -0
- package/src/ui/drawer.tsx +161 -0
- package/src/ui/focus.test.ts +82 -0
- package/src/ui/glyphs.tsx +35 -0
- package/src/ui/index.ts +145 -0
- package/src/ui/input.test.ts +22 -0
- package/src/ui/input.tsx +181 -0
- package/src/ui/menu.test.ts +73 -0
- package/src/ui/menu.tsx +186 -0
- package/src/ui/select.tsx +191 -0
- package/src/ui/tabs.tsx +95 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
/**
|
|
3
|
+
* Create the store.
|
|
4
|
+
*
|
|
5
|
+
* The whole reason this is a factory and not a store: `isLoading` must start FALSE where there
|
|
6
|
+
* is no window, and only a factory can decide that at the call site rather than at import.
|
|
7
|
+
*
|
|
8
|
+
* A session bootstrap can only be in flight in a browser. The BUILD renders this app to files
|
|
9
|
+
* with no window at all, so `true` there is a wait that never ends — one donor shipped a route
|
|
10
|
+
* guard holding its loading screen forever, and prerendered a public page as 1,174 bytes of
|
|
11
|
+
* `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
|
|
12
|
+
* found. With no browser there is no session and never will be, which is exactly the state a
|
|
13
|
+
* first-time visitor arrives in, so that is what the page should render.
|
|
14
|
+
*
|
|
15
|
+
* Only one of the three donors knew this. It is invariant 7.
|
|
16
|
+
*/
|
|
17
|
+
export function createAuthStore() {
|
|
18
|
+
return create((set) => ({
|
|
19
|
+
user: null,
|
|
20
|
+
isAuthenticated: false,
|
|
21
|
+
isLoading: typeof window !== "undefined",
|
|
22
|
+
setUser: (user) => set({ user, isAuthenticated: Boolean(user), isLoading: false }),
|
|
23
|
+
setLoading: (isLoading) => set({ isLoading }),
|
|
24
|
+
clear: () => set({ user: null, isAuthenticated: false, isLoading: false }),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=auth-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-store.js","sourceRoot":"","sources":["../src/auth-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAqC,MAAM,SAAS,CAAC;AAkCpE;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,MAAM,CAAmB,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACxC,IAAI,EAAE,IAAI;QACV,eAAe,EAAE,KAAK;QACtB,SAAS,EAAE,OAAO,MAAM,KAAK,WAAW;QACxC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;QAClF,UAAU,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC;QAC7C,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;KAC3E,CAAC,CAAC,CAAC;AACN,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surviving a deploy that lands while a tab is open.
|
|
3
|
+
*
|
|
4
|
+
* Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. When a build
|
|
5
|
+
* replaces the one a tab is running, the next navigation asks for a chunk whose hashed name no
|
|
6
|
+
* longer exists — and a static host answers a missing asset with the SPA fallback, so the
|
|
7
|
+
* import gets HTML where it expected a module. React's lazy boundary throws, and the person
|
|
8
|
+
* gets a white screen on a click that worked a minute ago.
|
|
9
|
+
*
|
|
10
|
+
* One repo in the fleet had all of this. The others had none of it, and none of them knew.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* A lazy-chunk fetch failure — recoverable by reloading.
|
|
14
|
+
*
|
|
15
|
+
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
16
|
+
* here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
|
|
17
|
+
* dynamically imported module", Safari "Importing a module script failed", webpack's
|
|
18
|
+
* ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
|
|
19
|
+
* which is the one that actually fires on a static host, because the missing `.js` is answered
|
|
20
|
+
* with `index.html`.
|
|
21
|
+
*
|
|
22
|
+
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
23
|
+
* handler can classify the same failure without importing a component.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isChunkLoadError(error: unknown): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it.
|
|
28
|
+
*
|
|
29
|
+
* The guard is the part that matters. Reloading on a chunk error is obvious; reloading on a
|
|
30
|
+
* chunk error that the reload does not fix is an infinite loop with the user inside it. At most
|
|
31
|
+
* one reload per minute, so a genuinely missing chunk degrades to the error UI — which can at
|
|
32
|
+
* least say something — instead of flickering forever.
|
|
33
|
+
*
|
|
34
|
+
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament,
|
|
35
|
+
* and a stale flag in another tab must not suppress a reload this one needs. Wrapped because
|
|
36
|
+
* storage throws outright when site data is blocked, and a privacy setting must not be the
|
|
37
|
+
* reason somebody cannot recover.
|
|
38
|
+
*/
|
|
39
|
+
export declare function reloadOnceForChunkError(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
42
|
+
*
|
|
43
|
+
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
44
|
+
* When that link fails, the import itself still runs right after and usually succeeds — so this
|
|
45
|
+
* one is worth swallowing, where a failure of the import is the real thing
|
|
46
|
+
* {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
|
|
47
|
+
* for a stylesheet link; script hints never reject.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isPreloadHintFailure(error: unknown): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
52
|
+
*
|
|
53
|
+
* Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
|
|
54
|
+
* deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
55
|
+
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
56
|
+
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
57
|
+
* it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
|
|
58
|
+
* runs. A real module failure is left to throw, where the error boundary can recognise it.
|
|
59
|
+
*/
|
|
60
|
+
export declare function installPreloadErrorHandler(): void;
|
|
61
|
+
//# sourceMappingURL=chunk-reload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunk-reload.d.ts","sourceRoot":"","sources":["../src/chunk-reload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASxD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,IAAI,OAAO,CAWjD;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE5D;AAED;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,IAAI,IAAI,CAKjD"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surviving a deploy that lands while a tab is open.
|
|
3
|
+
*
|
|
4
|
+
* Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. When a build
|
|
5
|
+
* replaces the one a tab is running, the next navigation asks for a chunk whose hashed name no
|
|
6
|
+
* longer exists — and a static host answers a missing asset with the SPA fallback, so the
|
|
7
|
+
* import gets HTML where it expected a module. React's lazy boundary throws, and the person
|
|
8
|
+
* gets a white screen on a click that worked a minute ago.
|
|
9
|
+
*
|
|
10
|
+
* One repo in the fleet had all of this. The others had none of it, and none of them knew.
|
|
11
|
+
*/
|
|
12
|
+
const RELOAD_FLAG_KEY = "frontkit:chunk-reload-at";
|
|
13
|
+
const RELOAD_WINDOW_MS = 60_000;
|
|
14
|
+
/**
|
|
15
|
+
* A lazy-chunk fetch failure — recoverable by reloading.
|
|
16
|
+
*
|
|
17
|
+
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
18
|
+
* here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
|
|
19
|
+
* dynamically imported module", Safari "Importing a module script failed", webpack's
|
|
20
|
+
* ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
|
|
21
|
+
* which is the one that actually fires on a static host, because the missing `.js` is answered
|
|
22
|
+
* with `index.html`.
|
|
23
|
+
*
|
|
24
|
+
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
25
|
+
* handler can classify the same failure without importing a component.
|
|
26
|
+
*/
|
|
27
|
+
export function isChunkLoadError(error) {
|
|
28
|
+
if (error instanceof Error && error.name === "ChunkLoadError")
|
|
29
|
+
return true;
|
|
30
|
+
const message = (error instanceof Error ? error.message : String(error) || "").toLowerCase();
|
|
31
|
+
return (message.includes("dynamically imported module") ||
|
|
32
|
+
message.includes("importing a module script failed") ||
|
|
33
|
+
message.includes("mime type") ||
|
|
34
|
+
message.includes("loading chunk"));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it.
|
|
38
|
+
*
|
|
39
|
+
* The guard is the part that matters. Reloading on a chunk error is obvious; reloading on a
|
|
40
|
+
* chunk error that the reload does not fix is an infinite loop with the user inside it. At most
|
|
41
|
+
* one reload per minute, so a genuinely missing chunk degrades to the error UI — which can at
|
|
42
|
+
* least say something — instead of flickering forever.
|
|
43
|
+
*
|
|
44
|
+
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament,
|
|
45
|
+
* and a stale flag in another tab must not suppress a reload this one needs. Wrapped because
|
|
46
|
+
* storage throws outright when site data is blocked, and a privacy setting must not be the
|
|
47
|
+
* reason somebody cannot recover.
|
|
48
|
+
*/
|
|
49
|
+
export function reloadOnceForChunkError() {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
try {
|
|
52
|
+
const last = Number(sessionStorage.getItem(RELOAD_FLAG_KEY) ?? 0);
|
|
53
|
+
if (now - last < RELOAD_WINDOW_MS)
|
|
54
|
+
return false;
|
|
55
|
+
sessionStorage.setItem(RELOAD_FLAG_KEY, String(now));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Storage blocked — proceed with the reload rather than refusing to recover.
|
|
59
|
+
}
|
|
60
|
+
window.location.reload();
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
65
|
+
*
|
|
66
|
+
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
67
|
+
* When that link fails, the import itself still runs right after and usually succeeds — so this
|
|
68
|
+
* one is worth swallowing, where a failure of the import is the real thing
|
|
69
|
+
* {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
|
|
70
|
+
* for a stylesheet link; script hints never reject.
|
|
71
|
+
*/
|
|
72
|
+
export function isPreloadHintFailure(error) {
|
|
73
|
+
return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
77
|
+
*
|
|
78
|
+
* Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
|
|
79
|
+
* deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
80
|
+
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
81
|
+
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
82
|
+
* it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
|
|
83
|
+
* runs. A real module failure is left to throw, where the error boundary can recognise it.
|
|
84
|
+
*/
|
|
85
|
+
export function installPreloadErrorHandler() {
|
|
86
|
+
window.addEventListener("vite:preloadError", (event) => {
|
|
87
|
+
const payload = event.payload;
|
|
88
|
+
if (isPreloadHintFailure(payload))
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=chunk-reload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunk-reload.js","sourceRoot":"","sources":["../src/chunk-reload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,eAAe,GAAG,0BAA0B,CAAC;AACnD,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,gBAAgB;QAAE,OAAO,IAAI,CAAC;IAC3E,MAAM,OAAO,GAAG,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAC7F,OAAO,CACL,OAAO,CAAC,QAAQ,CAAC,6BAA6B,CAAC;QAC/C,OAAO,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QACpD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;QAC7B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAClC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,uBAAuB;IACrC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;QAClE,IAAI,GAAG,GAAG,IAAI,GAAG,gBAAgB;YAAE,OAAO,KAAK,CAAC;QAChD,cAAc,CAAC,OAAO,CAAC,eAAe,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACvD,CAAC;IAAC,MAAM,CAAC;QACP,6EAA6E;IAC/E,CAAC;IACD,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;IACzB,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,OAAO,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,2BAA2B,CAAC,CAAC;AACzF,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,0BAA0B;IACxC,MAAM,CAAC,gBAAgB,CAAC,mBAAmB,EAAE,CAAC,KAAK,EAAE,EAAE;QACrD,MAAM,OAAO,GAAI,KAAuC,CAAC,OAAO,CAAC;QACjE,IAAI,oBAAoB,CAAC,OAAO,CAAC;YAAE,KAAK,CAAC,cAAc,EAAE,CAAC;IAC5D,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/cn.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ClassValue } from "clsx";
|
|
2
|
+
/**
|
|
3
|
+
* Compose class names, letting later Tailwind utilities win over earlier ones
|
|
4
|
+
* (`p-2 p-4` → `p-4`).
|
|
5
|
+
*
|
|
6
|
+
* That precedence is the whole point, and it is why this is not `clsx` alone: a caller's
|
|
7
|
+
* `className` has to be able to OVERRIDE a component's defaults rather than fight them at
|
|
8
|
+
* equal specificity, where the winner would be whichever rule Tailwind happened to emit last.
|
|
9
|
+
*
|
|
10
|
+
* Ten repos wrote this function. All ten wrote it identically.
|
|
11
|
+
*/
|
|
12
|
+
export declare function cn(...inputs: ClassValue[]): string;
|
|
13
|
+
//# sourceMappingURL=cn.d.ts.map
|
package/dist/cn.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cn.d.ts","sourceRoot":"","sources":["../src/cn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAG7C;;;;;;;;;GASG;AACH,wBAAgB,EAAE,CAAC,GAAG,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,CAElD"}
|
package/dist/cn.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
/**
|
|
4
|
+
* Compose class names, letting later Tailwind utilities win over earlier ones
|
|
5
|
+
* (`p-2 p-4` → `p-4`).
|
|
6
|
+
*
|
|
7
|
+
* That precedence is the whole point, and it is why this is not `clsx` alone: a caller's
|
|
8
|
+
* `className` has to be able to OVERRIDE a component's defaults rather than fight them at
|
|
9
|
+
* equal specificity, where the winner would be whichever rule Tailwind happened to emit last.
|
|
10
|
+
*
|
|
11
|
+
* Ten repos wrote this function. All ten wrote it identically.
|
|
12
|
+
*/
|
|
13
|
+
export function cn(...inputs) {
|
|
14
|
+
return twMerge(clsx(inputs));
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=cn.js.map
|
package/dist/cn.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cn.js","sourceRoot":"","sources":["../src/cn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,MAAM,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAEzC;;;;;;;;;GASG;AACH,MAAM,UAAU,EAAE,CAAC,GAAG,MAAoB;IACxC,OAAO,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;AAC/B,CAAC"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ApiError } from "./api-error.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Turning anything thrown into copy a person can act on.
|
|
4
|
+
*
|
|
5
|
+
* The API already answers with a message written for a developer, a stable `messageKey` for the
|
|
6
|
+
* domain refusals, and `details` carrying the parts that make a refusal actionable. This is the
|
|
7
|
+
* one place that decides what the SCREEN says — which is not always the same sentence.
|
|
8
|
+
* "Monthly quota spent, upgrade or buy a pack" is right in a JSON body and wrong under a button
|
|
9
|
+
* that could just say what to do next.
|
|
10
|
+
*
|
|
11
|
+
* `cause` is what happened. `hint` is what to do about it. **A surface that shows only the first
|
|
12
|
+
* half is the dead end this exists to prevent** — which is why the type has two fields and not
|
|
13
|
+
* one, and why `hint` being absent should be a deliberate choice at each call site rather than
|
|
14
|
+
* the default nobody noticed.
|
|
15
|
+
*
|
|
16
|
+
* The per-code switch stays in the product: two donors' switches shared their SHAPE and almost
|
|
17
|
+
* no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
|
|
18
|
+
* which is the part that was written twice and got subtly different both times.
|
|
19
|
+
*/
|
|
20
|
+
export interface DescribedError {
|
|
21
|
+
/** What happened, in the reader's words. */
|
|
22
|
+
cause: string;
|
|
23
|
+
/** What to do about it. Its absence should be a decision, not an oversight. */
|
|
24
|
+
hint?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The subset of i18next's `t` this module needs — typed here so `i18next` stays optional. */
|
|
27
|
+
export type Translate = (key: string, params?: Record<string, unknown>) => string;
|
|
28
|
+
/** Seconds until a refusal clears, when the envelope carries them. */
|
|
29
|
+
export declare function retryAfterSecs(error: ApiError): number | null;
|
|
30
|
+
/**
|
|
31
|
+
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
32
|
+
*
|
|
33
|
+
* The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
|
|
34
|
+
* not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
|
|
35
|
+
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` in the catalog, each with a
|
|
36
|
+
* `count` plural.
|
|
37
|
+
*/
|
|
38
|
+
export declare function humanizeWait(t: Translate, secs: number, prefix?: string): string;
|
|
39
|
+
export interface ErrorDescriberOptions<Code extends string> {
|
|
40
|
+
t: Translate;
|
|
41
|
+
/**
|
|
42
|
+
* The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
|
|
43
|
+
* ignored — the server names a sentence, it does not get to name any key in the app.
|
|
44
|
+
*/
|
|
45
|
+
messageKeyPrefix?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The keys this build actually carries, usually `Object.keys(en.serverErrors)`.
|
|
48
|
+
*
|
|
49
|
+
* Membership is checked with `hasOwnProperty` and not `in`, which would accept `"toString"`.
|
|
50
|
+
* A key that is absent falls back to the server's English `message`, because a deploy can land
|
|
51
|
+
* ahead of the bundle a tab is still running and a missing translation must degrade to
|
|
52
|
+
* readable rather than render the key itself at somebody.
|
|
53
|
+
*/
|
|
54
|
+
knownMessageKeys?: readonly string[] | Record<string, unknown>;
|
|
55
|
+
/** Catalog namespace for this module's own copy. Defaults to `"errors."`. */
|
|
56
|
+
copyPrefix?: string;
|
|
57
|
+
/** Per-code copy. Everything not listed falls through to the default arm below. */
|
|
58
|
+
codes?: Partial<Record<Code, (ctx: ErrorContext) => DescribedError>>;
|
|
59
|
+
}
|
|
60
|
+
export interface ErrorContext {
|
|
61
|
+
error: ApiError;
|
|
62
|
+
/** The server's own localized sentence, when `messageKey` named one this build carries. */
|
|
63
|
+
says: string | null;
|
|
64
|
+
/** Seconds until it clears, already humanized. Null when the envelope did not say. */
|
|
65
|
+
wait: string | null;
|
|
66
|
+
waitSecs: number | null;
|
|
67
|
+
t: Translate;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the describer. Needs `network`, `networkHint`, `unexpected`, `retrySoon`,
|
|
71
|
+
* `waitSeconds`, `waitMinutes` and `waitHours` under `copyPrefix` in every locale.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createErrorDescriber<Code extends string>({ t, messageKeyPrefix, knownMessageKeys, copyPrefix, codes, }: ErrorDescriberOptions<Code>): (error: unknown) => DescribedError;
|
|
74
|
+
//# sourceMappingURL=describe-error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,8FAA8F;AAC9F,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC;AAElF,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAK7D;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,SAAK,GAAG,MAAM,CAK5E;AAED,MAAM,WAAW,qBAAqB,CAAC,IAAI,SAAS,MAAM;IACxD,CAAC,EAAE,SAAS,CAAC;IACb;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/D,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mFAAmF;IACnF,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC,CAAC,CAAC;CACtE;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,CAAC,EAAE,SAAS,CAAC;CACd;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,SAAS,MAAM,EAAE,EACxD,CAAC,EACD,gBAAkC,EAClC,gBAAgB,EAChB,UAAsB,EACtB,KAAU,GACX,EAAE,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CA2ClE"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ApiError } from "./api-error.js";
|
|
2
|
+
/** Seconds until a refusal clears, when the envelope carries them. */
|
|
3
|
+
export function retryAfterSecs(error) {
|
|
4
|
+
const details = error.details;
|
|
5
|
+
if (typeof details !== "object" || details === null)
|
|
6
|
+
return null;
|
|
7
|
+
const value = details.retryAfterSecs;
|
|
8
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
12
|
+
*
|
|
13
|
+
* The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
|
|
14
|
+
* not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
|
|
15
|
+
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` in the catalog, each with a
|
|
16
|
+
* `count` plural.
|
|
17
|
+
*/
|
|
18
|
+
export function humanizeWait(t, secs, prefix = "") {
|
|
19
|
+
const key = (name) => `${prefix}${name}`;
|
|
20
|
+
if (secs < 90)
|
|
21
|
+
return t(key("waitSeconds"), { count: Math.max(1, Math.round(secs)) });
|
|
22
|
+
if (secs < 90 * 60)
|
|
23
|
+
return t(key("waitMinutes"), { count: Math.round(secs / 60) });
|
|
24
|
+
return t(key("waitHours"), { count: Math.round(secs / 3600) });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the describer. Needs `network`, `networkHint`, `unexpected`, `retrySoon`,
|
|
28
|
+
* `waitSeconds`, `waitMinutes` and `waitHours` under `copyPrefix` in every locale.
|
|
29
|
+
*/
|
|
30
|
+
export function createErrorDescriber({ t, messageKeyPrefix = "serverErrors.", knownMessageKeys, copyPrefix = "errors.", codes = {}, }) {
|
|
31
|
+
const known = Array.isArray(knownMessageKeys)
|
|
32
|
+
? new Set(knownMessageKeys)
|
|
33
|
+
: new Set(Object.keys(knownMessageKeys ?? {}));
|
|
34
|
+
const has = (name) => known.size === 0 || known.has(name);
|
|
35
|
+
const key = (name) => `${copyPrefix}${name}`;
|
|
36
|
+
function serverSentence(error) {
|
|
37
|
+
const messageKey = error.messageKey;
|
|
38
|
+
if (messageKey === undefined || !messageKey.startsWith(messageKeyPrefix))
|
|
39
|
+
return null;
|
|
40
|
+
const name = messageKey.slice(messageKeyPrefix.length);
|
|
41
|
+
return has(name) ? t(messageKey, error.params) : null;
|
|
42
|
+
}
|
|
43
|
+
return function describeError(error) {
|
|
44
|
+
if (!(error instanceof ApiError)) {
|
|
45
|
+
// Not a response at all — the request never landed. Almost always the network, and almost
|
|
46
|
+
// never worth showing a stack trace for.
|
|
47
|
+
return { cause: t(key("network")), hint: t(key("networkHint")) };
|
|
48
|
+
}
|
|
49
|
+
const waitSecs = retryAfterSecs(error);
|
|
50
|
+
const ctx = {
|
|
51
|
+
error,
|
|
52
|
+
says: serverSentence(error),
|
|
53
|
+
wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
|
|
54
|
+
waitSecs,
|
|
55
|
+
t,
|
|
56
|
+
};
|
|
57
|
+
const arm = error.code === undefined ? undefined : codes[error.code];
|
|
58
|
+
if (arm)
|
|
59
|
+
return arm(ctx);
|
|
60
|
+
// Anything unmapped: the server's own sentence is still the most specific thing we have,
|
|
61
|
+
// and support can act on it. A 5xx additionally gets "try again shortly", because that one
|
|
62
|
+
// genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
63
|
+
// the reader another attempt.
|
|
64
|
+
return {
|
|
65
|
+
cause: ctx.says ?? (error.message || t(key("unexpected"))),
|
|
66
|
+
hint: error.status >= 500 ? t(key("retrySoon")) : undefined,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=describe-error.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The last stop before a white screen.
|
|
4
|
+
*
|
|
5
|
+
* React unmounts the whole tree when a render throws, and an app with no boundary anywhere
|
|
6
|
+
* answers that with a blank page: no words, no navigation, no reload — the most complete dead
|
|
7
|
+
* end a product can produce, and the one the never-dead-end rule cannot reach, because by then
|
|
8
|
+
* there is no component left to render a state from.
|
|
9
|
+
*
|
|
10
|
+
* It is deliberately dumb. No retry loop, no error reporting, no reset on a timer: it catches,
|
|
11
|
+
* it hands the error to a `fallback` the app draws in its own words, and it offers `reset`.
|
|
12
|
+
* Every donor that baked its own UI in here had to keep a second copy for its second surface;
|
|
13
|
+
* a render prop has no such problem, and the brand stays in the product where it belongs.
|
|
14
|
+
*
|
|
15
|
+
* A class, because `getDerivedStateFromError` has no hook equivalent — this is the one thing in
|
|
16
|
+
* the kit React still has no other way to express.
|
|
17
|
+
*/
|
|
18
|
+
export interface ErrorBoundaryFallbackProps {
|
|
19
|
+
error: Error;
|
|
20
|
+
/** Put the children back. */
|
|
21
|
+
reset: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* True when the throw was a failed lazy-chunk import — a deploy landed while this tab was
|
|
24
|
+
* open, so the fix is a reload and NOT a retry (a rejected dynamic import rethrows on
|
|
25
|
+
* re-render forever). Draw the "updating" screen, not the crash screen.
|
|
26
|
+
*/
|
|
27
|
+
isChunkError: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface ErrorBoundaryProps {
|
|
30
|
+
/** Drawn instead of the children once something has thrown. */
|
|
31
|
+
fallback: (props: ErrorBoundaryFallbackProps) => ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Changing this value clears the error and remounts the children — pass the route, so
|
|
34
|
+
* navigating away from a screen that broke actually leaves it rather than carrying its
|
|
35
|
+
* wreckage to every page after it.
|
|
36
|
+
*/
|
|
37
|
+
resetKey?: string;
|
|
38
|
+
/** Called once per catch. Report to whatever the app reports to. */
|
|
39
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Classify a chunk-load failure. Pass `isChunkLoadError` from this package; it is a
|
|
42
|
+
* parameter rather than a hard import so an app that does not lazy-load pays nothing.
|
|
43
|
+
*/
|
|
44
|
+
isChunkError?: (error: unknown) => boolean;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
interface State {
|
|
48
|
+
error: Error | null;
|
|
49
|
+
/** Which `resetKey` the current error belongs to. */
|
|
50
|
+
key: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
export declare class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
|
|
53
|
+
state: State;
|
|
54
|
+
static getDerivedStateFromError(error: Error): Pick<State, "error">;
|
|
55
|
+
static getDerivedStateFromProps(props: ErrorBoundaryProps, state: State): State | null;
|
|
56
|
+
componentDidCatch(error: Error, info: ErrorInfo): void;
|
|
57
|
+
private readonly reset;
|
|
58
|
+
render(): ReactNode;
|
|
59
|
+
}
|
|
60
|
+
export {};
|
|
61
|
+
//# sourceMappingURL=error-boundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-boundary.d.ts","sourceRoot":"","sources":["../src/error-boundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAElE;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,KAAK,CAAC;IACb,6BAA6B;IAC7B,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;;OAIG;IACH,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,QAAQ,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,SAAS,CAAC;IAC3D;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IAClD;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IAC3C,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,UAAU,KAAK;IACb,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,qDAAqD;IACrD,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACzB;AAED,qBAAa,aAAc,SAAQ,SAAS,CAAC,kBAAkB,EAAE,KAAK,CAAC;IAC5D,KAAK,EAAE,KAAK,CAAmC;IAExD,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC;IAInE,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,KAAK,GAAG,KAAK,GAAG,IAAI;IAQ7E,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI;IAK/D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA8D;IAE3E,MAAM,IAAI,SAAS;CAS7B"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Component } from "react";
|
|
2
|
+
export class ErrorBoundary extends Component {
|
|
3
|
+
state = { error: null, key: undefined };
|
|
4
|
+
static getDerivedStateFromError(error) {
|
|
5
|
+
return { error };
|
|
6
|
+
}
|
|
7
|
+
static getDerivedStateFromProps(props, state) {
|
|
8
|
+
if (state.error === null)
|
|
9
|
+
return { error: null, key: props.resetKey };
|
|
10
|
+
// The error belongs to the screen it happened on. Somewhere else is a fresh start, and
|
|
11
|
+
// staying broken there would strand somebody who already did the sensible thing and
|
|
12
|
+
// navigated away.
|
|
13
|
+
return state.key === props.resetKey ? null : { error: null, key: props.resetKey };
|
|
14
|
+
}
|
|
15
|
+
componentDidCatch(error, info) {
|
|
16
|
+
this.props.onError?.(error, info);
|
|
17
|
+
if (!this.props.onError)
|
|
18
|
+
console.error("Unhandled render error", error, info.componentStack);
|
|
19
|
+
}
|
|
20
|
+
reset = () => this.setState({ error: null, key: undefined });
|
|
21
|
+
render() {
|
|
22
|
+
const { error } = this.state;
|
|
23
|
+
if (error === null)
|
|
24
|
+
return this.props.children;
|
|
25
|
+
return this.props.fallback({
|
|
26
|
+
error,
|
|
27
|
+
reset: this.reset,
|
|
28
|
+
isChunkError: this.props.isChunkError?.(error) ?? false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=error-boundary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-boundary.js","sourceRoot":"","sources":["../src/error-boundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAkC,MAAM,OAAO,CAAC;AAwDlE,MAAM,OAAO,aAAc,SAAQ,SAAoC;IAC5D,KAAK,GAAU,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC;IAExD,MAAM,CAAC,wBAAwB,CAAC,KAAY;QAC1C,OAAO,EAAE,KAAK,EAAE,CAAC;IACnB,CAAC;IAED,MAAM,CAAC,wBAAwB,CAAC,KAAyB,EAAE,KAAY;QACrE,IAAI,KAAK,CAAC,KAAK,KAAK,IAAI;YAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC;QACtE,uFAAuF;QACvF,oFAAoF;QACpF,kBAAkB;QAClB,OAAO,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC;IACpF,CAAC;IAEQ,iBAAiB,CAAC,KAAY,EAAE,IAAe;QACtD,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QAClC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO;YAAE,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,KAAK,EAAE,IAAI,CAAC,cAAc,CAAC,CAAC;IAC/F,CAAC;IAEgB,KAAK,GAAG,GAAS,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,CAAC;IAE3E,MAAM;QACb,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC;QAC7B,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC;QAC/C,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC;YACzB,KAAK;YACL,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,YAAY,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC,KAAK,CAAC,IAAI,KAAK;SACxD,CAAC,CAAC;IACL,CAAC;CACF"}
|
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Route guards.
|
|
4
|
+
*
|
|
5
|
+
* Two donors wrote these four, gave two of them the same names, and explained them in nearly
|
|
6
|
+
* the same words. What differed was one line — and that line is invariant 4, so it is the
|
|
7
|
+
* reason this file exists rather than being copied a ninth time.
|
|
8
|
+
*
|
|
9
|
+
* These are factories because the destinations are the app's: `paths.signIn` is not something a
|
|
10
|
+
* package can know, and one donor hardcoded `/login` in four places for exactly that reason.
|
|
11
|
+
*
|
|
12
|
+
* A guard is a COURTESY, never the boundary. The API refuses what it refuses whatever any of
|
|
13
|
+
* these decide. What they buy is that somebody who types a URL lands on one screen that
|
|
14
|
+
* explains itself instead of four panels each failing separately.
|
|
15
|
+
*/
|
|
16
|
+
/** What the guard needs off the session store. */
|
|
17
|
+
export interface SessionState {
|
|
18
|
+
isAuthenticated: boolean;
|
|
19
|
+
isLoading: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The answer to a "who is this?" query, in the three states it actually has.
|
|
23
|
+
*
|
|
24
|
+
* This shape is invariant 4. A guard reading `!me?.isStaff` collapses "loading", "failed" and
|
|
25
|
+
* "no" into one branch — so an operator arriving while `/auth/me` is 500ing is told the page
|
|
26
|
+
* does not exist. Wrong cause, no retry, and no request id to quote to support. One donor hit
|
|
27
|
+
* that and fixed it; the other still has the collapsed version.
|
|
28
|
+
*/
|
|
29
|
+
export type MeQuery<TMe> = {
|
|
30
|
+
status: "loading";
|
|
31
|
+
} | {
|
|
32
|
+
status: "error";
|
|
33
|
+
error: unknown;
|
|
34
|
+
} | {
|
|
35
|
+
status: "success";
|
|
36
|
+
data: TMe;
|
|
37
|
+
};
|
|
38
|
+
export interface GuardOptions {
|
|
39
|
+
/** Drawn while the session or the profile is still resolving. */
|
|
40
|
+
loading: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Signed in, or off to sign in — remembering where they were headed, so the redirect afterwards
|
|
44
|
+
* lands on the page they actually wanted rather than the home screen.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createRequireAuth(useSession: () => SessionState, signInPath: string, { loading }: GuardOptions): ({ children }: {
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
}) => import("react").JSX.Element;
|
|
49
|
+
/** The mirror: somebody already signed in has no business on the sign-in screen. */
|
|
50
|
+
export declare function createRequireAnonymous(useSession: () => SessionState, homePath: string, { loading }: GuardOptions): ({ children }: {
|
|
51
|
+
children: ReactNode;
|
|
52
|
+
}) => import("react").JSX.Element;
|
|
53
|
+
/**
|
|
54
|
+
* A gate on something the profile says — suspended, staff, on a given plan.
|
|
55
|
+
*
|
|
56
|
+
* `allow` gets the profile and answers yes or no. `onDenied` draws the refusal; `onError` draws
|
|
57
|
+
* the failure, and they are SEPARATE arguments on purpose. That separation is the whole point
|
|
58
|
+
* of this file: "you may not see this" and "we could not find out" are different sentences, and
|
|
59
|
+
* showing the first when the second is true is how an outage becomes a support ticket about
|
|
60
|
+
* permissions.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createRequireProfile<TMe>(useMe: () => MeQuery<TMe>, { loading }: GuardOptions): (allow: (me: TMe) => boolean, onDenied: ReactNode | (() => ReactNode), onError: (error: unknown) => ReactNode) => ({ children }: {
|
|
63
|
+
children: ReactNode;
|
|
64
|
+
}) => import("react").JSX.Element;
|
|
65
|
+
//# sourceMappingURL=guards.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC;;;;;;;;;;;;;GAaG;AAEH,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,OAAO,CAAC,GAAG,IACrB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,GAAG,CAAA;CAAE,CAAC;AAEjG,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,OAAO,EAAE,SAAS,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,YAAY,EAC9B,UAAU,EAAE,MAAM,EAClB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEG,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAUlE;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,MAAM,YAAY,EAC9B,QAAQ,EAAE,MAAM,EAChB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEQ,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAMvE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,YAAY,IAE1F,OAAO,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,EAC3B,UAAU,SAAS,GAAG,CAAC,MAAM,SAAS,CAAC,EACvC,SAAS,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,MAEP,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAQvE"}
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Navigate, useLocation } from "react-router-dom";
|
|
3
|
+
/**
|
|
4
|
+
* Signed in, or off to sign in — remembering where they were headed, so the redirect afterwards
|
|
5
|
+
* lands on the page they actually wanted rather than the home screen.
|
|
6
|
+
*/
|
|
7
|
+
export function createRequireAuth(useSession, signInPath, { loading }) {
|
|
8
|
+
return function RequireAuth({ children }) {
|
|
9
|
+
const { isAuthenticated, isLoading } = useSession();
|
|
10
|
+
const location = useLocation();
|
|
11
|
+
if (isLoading)
|
|
12
|
+
return _jsx(_Fragment, { children: loading });
|
|
13
|
+
if (!isAuthenticated)
|
|
14
|
+
return (_jsx(Navigate, { to: signInPath, replace: true, state: { from: location.pathname + location.search } }));
|
|
15
|
+
return _jsx(_Fragment, { children: children });
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** The mirror: somebody already signed in has no business on the sign-in screen. */
|
|
19
|
+
export function createRequireAnonymous(useSession, homePath, { loading }) {
|
|
20
|
+
return function RequireAnonymous({ children }) {
|
|
21
|
+
const { isAuthenticated, isLoading } = useSession();
|
|
22
|
+
if (isLoading)
|
|
23
|
+
return _jsx(_Fragment, { children: loading });
|
|
24
|
+
if (isAuthenticated)
|
|
25
|
+
return _jsx(Navigate, { to: homePath, replace: true });
|
|
26
|
+
return _jsx(_Fragment, { children: children });
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A gate on something the profile says — suspended, staff, on a given plan.
|
|
31
|
+
*
|
|
32
|
+
* `allow` gets the profile and answers yes or no. `onDenied` draws the refusal; `onError` draws
|
|
33
|
+
* the failure, and they are SEPARATE arguments on purpose. That separation is the whole point
|
|
34
|
+
* of this file: "you may not see this" and "we could not find out" are different sentences, and
|
|
35
|
+
* showing the first when the second is true is how an outage becomes a support ticket about
|
|
36
|
+
* permissions.
|
|
37
|
+
*/
|
|
38
|
+
export function createRequireProfile(useMe, { loading }) {
|
|
39
|
+
return function requireProfile(allow, onDenied, onError) {
|
|
40
|
+
return function RequireProfile({ children }) {
|
|
41
|
+
const query = useMe();
|
|
42
|
+
if (query.status === "loading")
|
|
43
|
+
return _jsx(_Fragment, { children: loading });
|
|
44
|
+
if (query.status === "error")
|
|
45
|
+
return _jsx(_Fragment, { children: onError(query.error) });
|
|
46
|
+
if (!allow(query.data))
|
|
47
|
+
return _jsx(_Fragment, { children: typeof onDenied === "function" ? onDenied() : onDenied });
|
|
48
|
+
return _jsx(_Fragment, { children: children });
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=guards.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAuCzD;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC/B,UAA8B,EAC9B,UAAkB,EAClB,EAAE,OAAO,EAAgB;IAEzB,OAAO,SAAS,WAAW,CAAC,EAAE,QAAQ,EAA2B;QAC/D,MAAM,EAAE,eAAe,EAAE,SAAS,EAAE,GAAG,UAAU,EAAE,CAAC;QACpD,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAC;QAC/B,IAAI,SAAS;YAAE,OAAO,4BAAG,OAAO,GAAI,CAAC;QACrC,IAAI,CAAC,eAAe;YAClB,OAAO,CACL,KAAC,QAAQ,IAAC,EAAE,EAAE,UAAU,EAAE,OAAO,QAAC,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,CAAC,QAAQ,GAAG,QAAQ,CAAC,MAAM,EAAE,GAAI,CAC3F,CAAC;QACJ,OAAO,4BAAG,QAAQ,GAAI,CAAC;IACzB,CAAC,CAAC;AACJ,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,sBAAsB,CACpC,UAA8B,EAC9B,QAAgB,EAChB,EAAE,OAAO,EAAgB;IAEzB,OAAO,SAAS,gBAAgB,CAAC,EAAE,QAAQ,EAA2B;QACpE,MAAM,EAAE,eAAe,EAAE,SAAS,EAAE,GAAG,UAAU,EAAE,CAAC;QACpD,IAAI,SAAS;YAAE,OAAO,4BAAG,OAAO,GAAI,CAAC;QACrC,IAAI,eAAe;YAAE,OAAO,KAAC,QAAQ,IAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,SAAG,CAAC;QAC/D,OAAO,4BAAG,QAAQ,GAAI,CAAC;IACzB,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAM,KAAyB,EAAE,EAAE,OAAO,EAAgB;IAC5F,OAAO,SAAS,cAAc,CAC5B,KAA2B,EAC3B,QAAuC,EACvC,OAAsC;QAEtC,OAAO,SAAS,cAAc,CAAC,EAAE,QAAQ,EAA2B;YAClE,MAAM,KAAK,GAAG,KAAK,EAAE,CAAC;YACtB,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS;gBAAE,OAAO,4BAAG,OAAO,GAAI,CAAC;YACtD,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;gBAAE,OAAO,4BAAG,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAI,CAAC;YACjE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC;gBAAE,OAAO,4BAAG,OAAO,QAAQ,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAI,CAAC;YAC7F,OAAO,4BAAG,QAAQ,GAAI,CAAC;QACzB,CAAC,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
|