@gusnips/react 0.2.0 → 0.4.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/README.md +10 -2
- package/dist/describe-error.d.ts +58 -31
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +26 -25
- package/dist/describe-error.js.map +1 -1
- package/dist/hydrate.d.ts +5 -0
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -7
- package/dist/index.js.map +1 -1
- package/package.json +8 -1
- package/src/chunk-reload.test.ts +69 -2
- package/src/describe-error.test.ts +39 -1
- package/src/describe-error.ts +130 -52
- package/src/hydrate.ts +5 -0
- package/src/index.ts +19 -7
package/README.md
CHANGED
|
@@ -128,14 +128,22 @@ hurry cannot skip.
|
|
|
128
128
|
|
|
129
129
|
## Also here
|
|
130
130
|
|
|
131
|
-
`
|
|
132
|
-
|
|
131
|
+
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
|
132
|
+
`i18nInitOptions`, and `cn`.
|
|
133
133
|
|
|
134
134
|
`createAuthStore` is at `@gusnips/react/store` — a zustand store whose `isLoading` starts `false`
|
|
135
135
|
where there is no window. A session bootstrap can only be in flight in a browser, and `true`
|
|
136
136
|
during a build is a wait that never ends: it once shipped a spinner as the indexable body of a
|
|
137
137
|
page whose whole job was to be found.
|
|
138
138
|
|
|
139
|
+
`hydrateOrMount` is at `@gusnips/react/hydrate`, and the guards are at `@gusnips/react/guards`.
|
|
140
|
+
|
|
141
|
+
## On a phone
|
|
142
|
+
|
|
143
|
+
The main entry works in React Native. It imports `react` and nothing else you would have to go
|
|
144
|
+
find — no `react-dom`, no router, no store — so the fetch client, the retry rule and the SSE
|
|
145
|
+
parser all come along, and the four things that need a browser stay behind the subpaths above.
|
|
146
|
+
|
|
139
147
|
## The Base UI wrappers
|
|
140
148
|
|
|
141
149
|
```ts
|
package/dist/describe-error.d.ts
CHANGED
|
@@ -23,8 +23,21 @@ export interface DescribedError {
|
|
|
23
23
|
/** What to do about it. Its absence should be a decision, not an oversight. */
|
|
24
24
|
hint?: string;
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The subset of i18next's `t` this module needs — typed here so `i18next` stays optional.
|
|
28
|
+
*
|
|
29
|
+
* `Key` is a parameter and not plain `string` for a reason the first migration found. Every app
|
|
30
|
+
* on this stack declares `CustomTypeOptions.resources`, which types `t` to accept ONLY the keys
|
|
31
|
+
* its catalog has — so a `t` is not assignable to anything asking for `(key: string) => string`,
|
|
32
|
+
* and the adopter is left with a cast. Naming the keys instead turns that around: each function
|
|
33
|
+
* below asks for exactly the keys it looks up, a typed `t` accepts a superset of them and goes
|
|
34
|
+
* in directly, and a catalog missing one is a compile error rather than a raw key on screen.
|
|
35
|
+
*/
|
|
36
|
+
export type Translate<Key extends string = string> = (key: Key, params?: Record<string, unknown>) => string;
|
|
37
|
+
/** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
|
|
38
|
+
type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
39
|
+
/** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
|
|
40
|
+
type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
|
|
28
41
|
/** Seconds until a refusal clears, when the envelope carries them. */
|
|
29
42
|
export declare function retryAfterSecs(error: ApiError): number | null;
|
|
30
43
|
/**
|
|
@@ -32,31 +45,10 @@ export declare function retryAfterSecs(error: ApiError): number | null;
|
|
|
32
45
|
*
|
|
33
46
|
* The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
|
|
34
47
|
* 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`
|
|
48
|
+
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
|
|
36
49
|
* `count` plural.
|
|
37
50
|
*/
|
|
38
|
-
export declare function humanizeWait(t: Translate
|
|
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
|
-
}
|
|
51
|
+
export declare function humanizeWait<Prefix extends string>(t: Translate<`${Prefix}${WaitName}`>, secs: number, prefix: Prefix): string;
|
|
60
52
|
export interface ErrorContext {
|
|
61
53
|
error: ApiError;
|
|
62
54
|
/** The server's own localized sentence, when `messageKey` named one this build carries. */
|
|
@@ -64,11 +56,46 @@ export interface ErrorContext {
|
|
|
64
56
|
/** Seconds until it clears, already humanized. Null when the envelope did not say. */
|
|
65
57
|
wait: string | null;
|
|
66
58
|
waitSecs: number | null;
|
|
67
|
-
t: Translate;
|
|
68
59
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
60
|
+
export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix extends string, ServerName extends string> {
|
|
61
|
+
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
62
|
+
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
63
|
+
copyPrefix: Prefix;
|
|
64
|
+
/**
|
|
65
|
+
* The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
|
|
66
|
+
* ignored — the server names a sentence, it does not get to name any key in the app.
|
|
67
|
+
*
|
|
68
|
+
* The `code` is read under this prefix too, for an API that has no `messageKey` field and
|
|
69
|
+
* lets the code name the sentence — one repo in the fleet has 98 of those, `errors.NOT_FOUND`
|
|
70
|
+
* and its siblings, against another repo's `messageKey`. Both are the same claim ("the server
|
|
71
|
+
* named a sentence this app carries"), both pass the same gate below, and an app that uses
|
|
72
|
+
* neither is unaffected because nothing matches.
|
|
73
|
+
*/
|
|
74
|
+
messageKeyPrefix: ServerPrefix;
|
|
75
|
+
/**
|
|
76
|
+
* The keys this build actually carries, usually the `serverErrors` object out of the English
|
|
77
|
+
* catalog. Passing the catalog itself is both the membership test and the proof the compiler
|
|
78
|
+
* wants: a name that is in it IS a key, so the lookup below needs no cast.
|
|
79
|
+
*
|
|
80
|
+
* It is required because leaving it out is not a smaller version of this — it is the bug the
|
|
81
|
+
* option exists to prevent. i18next answers a key it does not have with the key itself, so a
|
|
82
|
+
* deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
|
|
83
|
+
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
84
|
+
*/
|
|
85
|
+
knownMessageKeys: Record<ServerName, unknown>;
|
|
86
|
+
/**
|
|
87
|
+
* Per-code copy. Everything not listed falls through to the default arm.
|
|
88
|
+
*
|
|
89
|
+
* The arms look up the app's own keys, so they close over the app's `t` rather than being
|
|
90
|
+
* handed one — this module has no names for that copy and no business typing it.
|
|
91
|
+
*
|
|
92
|
+
* Annotate your object `Partial<Record<YourErrorCode, ErrorArm>>` where you write it, and a
|
|
93
|
+
* code that does not exist is a compile error at the arm rather than a branch that never runs.
|
|
94
|
+
*/
|
|
95
|
+
codes?: Partial<Record<string, ErrorArm>>;
|
|
96
|
+
}
|
|
97
|
+
export type ErrorArm = (ctx: ErrorContext) => DescribedError;
|
|
98
|
+
/** Build the describer. */
|
|
99
|
+
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
|
|
100
|
+
export {};
|
|
74
101
|
//# sourceMappingURL=describe-error.d.ts.map
|
|
@@ -1 +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
|
|
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;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D,iGAAiG;AACjG,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA8BlF,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAK7D;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;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;CACzB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC;AAE7D,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,GACX,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAkD9F"}
|
package/dist/describe-error.js
CHANGED
|
@@ -12,39 +12,41 @@ export function retryAfterSecs(error) {
|
|
|
12
12
|
*
|
|
13
13
|
* The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
|
|
14
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`
|
|
15
|
+
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
|
|
16
16
|
* `count` plural.
|
|
17
17
|
*/
|
|
18
|
-
export function humanizeWait(t, secs, prefix
|
|
19
|
-
const key = (name) => `${prefix}${name}`;
|
|
18
|
+
export function humanizeWait(t, secs, prefix) {
|
|
20
19
|
if (secs < 90)
|
|
21
|
-
return t(
|
|
20
|
+
return t(`${prefix}waitSeconds`, { count: Math.max(1, Math.round(secs)) });
|
|
22
21
|
if (secs < 90 * 60)
|
|
23
|
-
return t(
|
|
24
|
-
return t(
|
|
22
|
+
return t(`${prefix}waitMinutes`, { count: Math.round(secs / 60) });
|
|
23
|
+
return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
|
|
25
24
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
: new Set(Object.keys(knownMessageKeys ?? {}));
|
|
34
|
-
const has = (name) => known.size === 0 || known.has(name);
|
|
35
|
-
const key = (name) => `${copyPrefix}${name}`;
|
|
25
|
+
/** Build the describer. */
|
|
26
|
+
export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes = {}, }) {
|
|
27
|
+
const known = new Set(Object.keys(knownMessageKeys));
|
|
28
|
+
// Sound, and the reason nothing here needs a cast: the set was built from the keys of
|
|
29
|
+
// `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
|
|
30
|
+
// `in`, which would have accepted "toString".
|
|
31
|
+
const has = (name) => known.has(name);
|
|
36
32
|
function serverSentence(error) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
const lookup = (named) => {
|
|
34
|
+
if (named === undefined || !named.startsWith(messageKeyPrefix))
|
|
35
|
+
return null;
|
|
36
|
+
const name = named.slice(messageKeyPrefix.length);
|
|
37
|
+
return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
|
|
38
|
+
};
|
|
39
|
+
// `messageKey` first: an API that sends both means the key, and the code is what the UI
|
|
40
|
+
// switches on. The code is only consulted when there is no key to prefer, and it is spelled
|
|
41
|
+
// under the same prefix so the gate is identical.
|
|
42
|
+
return (lookup(error.messageKey) ??
|
|
43
|
+
(error.code === undefined ? null : lookup(`${messageKeyPrefix}${error.code}`)));
|
|
42
44
|
}
|
|
43
45
|
return function describeError(error) {
|
|
44
46
|
if (!(error instanceof ApiError)) {
|
|
45
47
|
// Not a response at all — the request never landed. Almost always the network, and almost
|
|
46
48
|
// never worth showing a stack trace for.
|
|
47
|
-
return { cause: t(
|
|
49
|
+
return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
|
|
48
50
|
}
|
|
49
51
|
const waitSecs = retryAfterSecs(error);
|
|
50
52
|
const ctx = {
|
|
@@ -52,7 +54,6 @@ export function createErrorDescriber({ t, messageKeyPrefix = "serverErrors.", kn
|
|
|
52
54
|
says: serverSentence(error),
|
|
53
55
|
wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
|
|
54
56
|
waitSecs,
|
|
55
|
-
t,
|
|
56
57
|
};
|
|
57
58
|
const arm = error.code === undefined ? undefined : codes[error.code];
|
|
58
59
|
if (arm)
|
|
@@ -62,8 +63,8 @@ export function createErrorDescriber({ t, messageKeyPrefix = "serverErrors.", kn
|
|
|
62
63
|
// genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
63
64
|
// the reader another attempt.
|
|
64
65
|
return {
|
|
65
|
-
cause: ctx.says ?? (error.message || t(
|
|
66
|
-
hint: error.status >= 500 ? t(
|
|
66
|
+
cause: ctx.says ?? (error.message || t(`${copyPrefix}unexpected`)),
|
|
67
|
+
hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
67
68
|
};
|
|
68
69
|
};
|
|
69
70
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/dist/hydrate.d.ts
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The constants themselves live in `prerender-contract.ts`, which imports nothing, because the
|
|
10
10
|
* BUILD needs them too and must not pull `react-dom/client` into a Node process to get a string.
|
|
11
|
+
*
|
|
12
|
+
* This module sits behind `@gusnips/react/hydrate` rather than in the barrel for the same reason
|
|
13
|
+
* one rung further out: it is the package's ONLY `react-dom` import, and a React Native app has
|
|
14
|
+
* no react-dom to give. Leaving it in the barrel made `react-dom` a required peer and shut the
|
|
15
|
+
* whole package out of every phone — for one function a phone would never call.
|
|
11
16
|
*/
|
|
12
17
|
import type { ReactNode } from "react";
|
|
13
18
|
export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
|
package/dist/hydrate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hydrate.d.ts","sourceRoot":"","sources":["../src/hydrate.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"hydrate.d.ts","sourceRoot":"","sources":["../src/hydrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAGpF"}
|
package/dist/hydrate.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hydrate.js","sourceRoot":"","sources":["../src/hydrate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"hydrate.js","sourceRoot":"","sources":["../src/hydrate.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAAC,MAAe,EAAE,IAAe,EAAE,KAAa;IAC5E,IAAI,MAAM,CAAC,YAAY,CAAC,sBAAsB,CAAC,KAAK,KAAK;QAAE,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;;QAChF,UAAU,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AACvC,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,9 +4,9 @@ export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshRes
|
|
|
4
4
|
export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
|
5
5
|
export { queryDefaults, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
|
|
6
6
|
export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps, } from "./error-boundary.tsx";
|
|
7
|
-
export { createErrorDescriber, humanizeWait, retryAfterSecs, type DescribedError, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
|
|
7
|
+
export { createErrorDescriber, humanizeWait, retryAfterSecs, type DescribedError, type ErrorArm, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
|
|
8
8
|
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError, } from "./chunk-reload.ts";
|
|
9
|
-
export {
|
|
9
|
+
export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
|
|
10
10
|
export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
|
|
11
11
|
export type { EmptyStateProps, ErrorStateProps } from "./states.ts";
|
|
12
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -11,13 +11,21 @@
|
|
|
11
11
|
// @gusnips/react/ui the Base UI wrappers → @base-ui/react
|
|
12
12
|
// @gusnips/react/store createAuthStore → zustand
|
|
13
13
|
// @gusnips/react/guards the route guards → react-router-dom
|
|
14
|
+
// @gusnips/react/hydrate hydrateOrMount → react-dom
|
|
14
15
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
// `react-dom` is the one the second migration added, and it is the sharpest case yet: olhary
|
|
17
|
+
// ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to give.
|
|
18
|
+
// A required peer nobody can satisfy is not a strict contract, it is a closed door — the whole
|
|
19
|
+
// package was unusable there, for one function no phone would ever call. The rule did not need
|
|
20
|
+
// changing to catch it; `react-dom` simply had to stop being required.
|
|
21
|
+
//
|
|
22
|
+
// So what is left here imports `react` and `@gusnips/http` and nothing else, and an app on
|
|
23
|
+
// TanStack Router, on Redux, or on a phone takes the fetch client without installing a router, a
|
|
24
|
+
// store or a DOM renderer it will never call. `@tanstack/react-query`, `i18next` and
|
|
25
|
+
// `react-i18next` stay optional AND stay here, because `query.ts`, `i18n.ts` and `states.ts`
|
|
26
|
+
// import only their TYPES — which erase, so the built barrel does not reference them at runtime.
|
|
27
|
+
// Check that claim against `dist/`, never against this file: a type-only re-export looks
|
|
28
|
+
// identical in source.
|
|
21
29
|
export { cn } from "./cn.js";
|
|
22
30
|
export { ApiError, isAbortError } from "./api-error.js";
|
|
23
31
|
export { createApiClient, } from "./api-client.js";
|
|
@@ -26,6 +34,9 @@ export { queryDefaults, shouldRetry } from "./query.js";
|
|
|
26
34
|
export { ErrorBoundary, } from "./error-boundary.js";
|
|
27
35
|
export { createErrorDescriber, humanizeWait, retryAfterSecs, } from "./describe-error.js";
|
|
28
36
|
export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError, } from "./chunk-reload.js";
|
|
29
|
-
|
|
37
|
+
// The two constants, but NOT `hydrateOrMount` — that one is at `@gusnips/react/hydrate`, because
|
|
38
|
+
// it is the package's only `react-dom` import. These come from the contract module, which imports
|
|
39
|
+
// nothing at all, so they are free to everyone: a build script, a browser entry, or a phone.
|
|
40
|
+
export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.js";
|
|
30
41
|
export { applyBrandVars, i18nInitOptions } from "./i18n.js";
|
|
31
42
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,EAAE;AACF,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,6FAA6F;AAC7F,+FAA+F;AAC/F,+FAA+F;AAC/F,+FAA+F;AAC/F,uEAAuE;AACvE,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,GAMf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, chunk-reload recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"types": "./dist/guards.d.ts",
|
|
27
27
|
"default": "./dist/guards.js"
|
|
28
28
|
},
|
|
29
|
+
"./hydrate": {
|
|
30
|
+
"types": "./dist/hydrate.d.ts",
|
|
31
|
+
"default": "./dist/hydrate.js"
|
|
32
|
+
},
|
|
29
33
|
"./contract": {
|
|
30
34
|
"types": "./dist/prerender-contract.d.ts",
|
|
31
35
|
"default": "./dist/prerender-contract.js"
|
|
@@ -92,6 +96,9 @@
|
|
|
92
96
|
"i18next": {
|
|
93
97
|
"optional": true
|
|
94
98
|
},
|
|
99
|
+
"react-dom": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
95
102
|
"react-i18next": {
|
|
96
103
|
"optional": true
|
|
97
104
|
},
|
package/src/chunk-reload.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { isChunkLoadError, isPreloadHintFailure } from "./chunk-reload.ts";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError } from "./chunk-reload.ts";
|
|
3
3
|
|
|
4
4
|
// No browser gives a failed dynamic import a shared type, so this is matched by message and
|
|
5
5
|
// every phrasing has to be here. A miss is a white screen after a deploy — the exact failure
|
|
@@ -30,6 +30,9 @@ describe("isChunkLoadError", () => {
|
|
|
30
30
|
it("does not match an ordinary render error", () => {
|
|
31
31
|
expect(isChunkLoadError(new TypeError("Cannot read properties of undefined"))).toBe(false);
|
|
32
32
|
expect(isChunkLoadError(null)).toBe(false);
|
|
33
|
+
// A thrown string reaches the `String(error)` branch, which is the one a non-Error throw
|
|
34
|
+
// takes — and the one that would blow up if it were written `error.message`.
|
|
35
|
+
expect(isChunkLoadError("something failed")).toBe(false);
|
|
33
36
|
});
|
|
34
37
|
});
|
|
35
38
|
|
|
@@ -44,3 +47,67 @@ describe("isPreloadHintFailure", () => {
|
|
|
44
47
|
);
|
|
45
48
|
});
|
|
46
49
|
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The guard, which is the whole reason this function exists rather than a bare
|
|
53
|
+
* `location.reload()` at the call site. Reloading on a chunk error the reload does not fix is
|
|
54
|
+
* an infinite loop with a person inside it, and it only reproduces on a real broken deploy —
|
|
55
|
+
* so this is the only place the rule gets checked.
|
|
56
|
+
*
|
|
57
|
+
* Two globals, stubbed rather than run under a DOM: `window` and `sessionStorage` are the only
|
|
58
|
+
* ones this module touches, and adding jsdom to a package whose whole point is a small
|
|
59
|
+
* dependency list would cost more than it explains.
|
|
60
|
+
*/
|
|
61
|
+
describe("reloadOnceForChunkError", () => {
|
|
62
|
+
const reload = vi.fn();
|
|
63
|
+
let store: Record<string, string> = {};
|
|
64
|
+
/** Throws from both methods, the way a browser does when site data is blocked. */
|
|
65
|
+
let blocked = false;
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
reload.mockClear();
|
|
70
|
+
store = {};
|
|
71
|
+
blocked = false;
|
|
72
|
+
vi.stubGlobal("window", { location: { reload } });
|
|
73
|
+
vi.stubGlobal("sessionStorage", {
|
|
74
|
+
getItem: (key: string): string | null => {
|
|
75
|
+
if (blocked) throw new Error("site data blocked");
|
|
76
|
+
return store[key] ?? null;
|
|
77
|
+
},
|
|
78
|
+
setItem: (key: string, value: string): void => {
|
|
79
|
+
if (blocked) throw new Error("site data blocked");
|
|
80
|
+
store[key] = value;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.unstubAllGlobals();
|
|
87
|
+
vi.useRealTimers();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reloads on the first chunk failure", () => {
|
|
91
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
92
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("refuses a second reload inside the window, so a dead chunk cannot loop", () => {
|
|
96
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
97
|
+
expect(reloadOnceForChunkError()).toBe(false);
|
|
98
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("allows another one after the window, because the next deploy is a new problem", () => {
|
|
102
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
103
|
+
vi.setSystemTime(Date.now() + 61_000);
|
|
104
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
105
|
+
expect(reload).toHaveBeenCalledTimes(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("still reloads when storage is blocked — a privacy setting is not a reason to strand", () => {
|
|
109
|
+
blocked = true;
|
|
110
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
111
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -42,7 +42,9 @@ describe("retryAfterSecs", () => {
|
|
|
42
42
|
describe("createErrorDescriber", () => {
|
|
43
43
|
const describeError = createErrorDescriber({
|
|
44
44
|
t,
|
|
45
|
-
|
|
45
|
+
copyPrefix: "errors.",
|
|
46
|
+
messageKeyPrefix: "serverErrors.",
|
|
47
|
+
knownMessageKeys: { quotaDay: "", suspended: "" },
|
|
46
48
|
codes: {
|
|
47
49
|
QUOTA_EXCEEDED: ({ says, wait }) => ({
|
|
48
50
|
cause: says ?? "errors.quota",
|
|
@@ -100,3 +102,39 @@ describe("createErrorDescriber", () => {
|
|
|
100
102
|
expect(describeError(new ApiError(403, null)).hint).toBeUndefined();
|
|
101
103
|
});
|
|
102
104
|
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The second convention in the fleet: an API with no `messageKey` field at all, whose CODE
|
|
108
|
+
* names the sentence. One repo has 98 of them — `errors.FORECAST_NOT_FOUND` and its siblings —
|
|
109
|
+
* and its client used to resolve them itself, which is how the translation ended up inside the
|
|
110
|
+
* transport.
|
|
111
|
+
*/
|
|
112
|
+
describe("createErrorDescriber, when the code names the sentence", () => {
|
|
113
|
+
const describeError = createErrorDescriber({
|
|
114
|
+
t,
|
|
115
|
+
copyPrefix: "errors.",
|
|
116
|
+
messageKeyPrefix: "errors.",
|
|
117
|
+
knownMessageKeys: { FORECAST_NOT_FOUND: "", network: "" },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("resolves a code this build has copy for", () => {
|
|
121
|
+
const error = new ApiError(404, { code: "FORECAST_NOT_FOUND", message: "Forecast not found" });
|
|
122
|
+
expect(describeError(error).cause).toBe("errors.FORECAST_NOT_FOUND");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("falls back to the server's own message for a code with no copy", () => {
|
|
126
|
+
// The server writes for a developer, so this is the worse of the two — but it is still the
|
|
127
|
+
// most specific thing anyone has, and support can act on it.
|
|
128
|
+
const error = new ApiError(429, { code: "RESEARCH_LIMIT", message: "Concurrent limit hit" });
|
|
129
|
+
expect(describeError(error).cause).toBe("Concurrent limit hit");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("prefers messageKey when the server sends both", () => {
|
|
133
|
+
const error = new ApiError(404, {
|
|
134
|
+
code: "FORECAST_NOT_FOUND",
|
|
135
|
+
message: "Forecast not found",
|
|
136
|
+
messageKey: "errors.network",
|
|
137
|
+
});
|
|
138
|
+
expect(describeError(error).cause).toBe("errors.network");
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/describe-error.ts
CHANGED
|
@@ -26,8 +26,54 @@ export interface DescribedError {
|
|
|
26
26
|
hint?: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The subset of i18next's `t` this module needs — typed here so `i18next` stays optional.
|
|
31
|
+
*
|
|
32
|
+
* `Key` is a parameter and not plain `string` for a reason the first migration found. Every app
|
|
33
|
+
* on this stack declares `CustomTypeOptions.resources`, which types `t` to accept ONLY the keys
|
|
34
|
+
* its catalog has — so a `t` is not assignable to anything asking for `(key: string) => string`,
|
|
35
|
+
* and the adopter is left with a cast. Naming the keys instead turns that around: each function
|
|
36
|
+
* below asks for exactly the keys it looks up, a typed `t` accepts a superset of them and goes
|
|
37
|
+
* in directly, and a catalog missing one is a compile error rather than a raw key on screen.
|
|
38
|
+
*/
|
|
39
|
+
export type Translate<Key extends string = string> = (
|
|
40
|
+
key: Key,
|
|
41
|
+
params?: Record<string, unknown>,
|
|
42
|
+
) => string;
|
|
43
|
+
|
|
44
|
+
/** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
|
|
45
|
+
type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
46
|
+
|
|
47
|
+
/** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
|
|
48
|
+
type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The paragraph above, pinned so it cannot quietly stop being true.
|
|
52
|
+
*
|
|
53
|
+
* `ClosedCatalog` is what an app's `t` looks like once it declares `CustomTypeOptions.resources`:
|
|
54
|
+
* a closed union, nothing else accepted. It must go straight into the options below. Widen `Key`
|
|
55
|
+
* back to `string` and this stops compiling HERE, rather than at the eleventh adopter reaching
|
|
56
|
+
* for the cast the house rules forbid.
|
|
57
|
+
*/
|
|
58
|
+
type Satisfied<T extends true> = T;
|
|
59
|
+
type ClosedCatalog = (
|
|
60
|
+
key:
|
|
61
|
+
| "errors.network"
|
|
62
|
+
| "errors.networkHint"
|
|
63
|
+
| "errors.unexpected"
|
|
64
|
+
| "errors.retrySoon"
|
|
65
|
+
| "errors.waitSeconds"
|
|
66
|
+
| "errors.waitMinutes"
|
|
67
|
+
| "errors.waitHours"
|
|
68
|
+
| "serverErrors.quotaDay"
|
|
69
|
+
| "billing.plan",
|
|
70
|
+
params?: Record<string, unknown>,
|
|
71
|
+
) => string;
|
|
72
|
+
type _TypedCatalogNeedsNoCast = Satisfied<
|
|
73
|
+
ClosedCatalog extends ErrorDescriberOptions<"errors.", "serverErrors.", "quotaDay">["t"]
|
|
74
|
+
? true
|
|
75
|
+
: false
|
|
76
|
+
>;
|
|
31
77
|
|
|
32
78
|
/** Seconds until a refusal clears, when the envelope carries them. */
|
|
33
79
|
export function retryAfterSecs(error: ApiError): number | null {
|
|
@@ -42,78 +88,111 @@ export function retryAfterSecs(error: ApiError): number | null {
|
|
|
42
88
|
*
|
|
43
89
|
* The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
|
|
44
90
|
* not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
|
|
45
|
-
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours`
|
|
91
|
+
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
|
|
46
92
|
* `count` plural.
|
|
47
93
|
*/
|
|
48
|
-
export function humanizeWait
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
94
|
+
export function humanizeWait<Prefix extends string>(
|
|
95
|
+
t: Translate<`${Prefix}${WaitName}`>,
|
|
96
|
+
secs: number,
|
|
97
|
+
prefix: Prefix,
|
|
98
|
+
): string {
|
|
99
|
+
if (secs < 90) return t(`${prefix}waitSeconds`, { count: Math.max(1, Math.round(secs)) });
|
|
100
|
+
if (secs < 90 * 60) return t(`${prefix}waitMinutes`, { count: Math.round(secs / 60) });
|
|
101
|
+
return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ErrorContext {
|
|
105
|
+
error: ApiError;
|
|
106
|
+
/** The server's own localized sentence, when `messageKey` named one this build carries. */
|
|
107
|
+
says: string | null;
|
|
108
|
+
/** Seconds until it clears, already humanized. Null when the envelope did not say. */
|
|
109
|
+
wait: string | null;
|
|
110
|
+
waitSecs: number | null;
|
|
53
111
|
}
|
|
54
112
|
|
|
55
|
-
export interface ErrorDescriberOptions<
|
|
56
|
-
|
|
113
|
+
export interface ErrorDescriberOptions<
|
|
114
|
+
Prefix extends string,
|
|
115
|
+
ServerPrefix extends string,
|
|
116
|
+
ServerName extends string,
|
|
117
|
+
> {
|
|
118
|
+
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
119
|
+
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
120
|
+
copyPrefix: Prefix;
|
|
57
121
|
/**
|
|
58
122
|
* The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
|
|
59
123
|
* ignored — the server names a sentence, it does not get to name any key in the app.
|
|
124
|
+
*
|
|
125
|
+
* The `code` is read under this prefix too, for an API that has no `messageKey` field and
|
|
126
|
+
* lets the code name the sentence — one repo in the fleet has 98 of those, `errors.NOT_FOUND`
|
|
127
|
+
* and its siblings, against another repo's `messageKey`. Both are the same claim ("the server
|
|
128
|
+
* named a sentence this app carries"), both pass the same gate below, and an app that uses
|
|
129
|
+
* neither is unaffected because nothing matches.
|
|
60
130
|
*/
|
|
61
|
-
messageKeyPrefix
|
|
131
|
+
messageKeyPrefix: ServerPrefix;
|
|
62
132
|
/**
|
|
63
|
-
* The keys this build actually carries, usually `
|
|
133
|
+
* The keys this build actually carries, usually the `serverErrors` object out of the English
|
|
134
|
+
* catalog. Passing the catalog itself is both the membership test and the proof the compiler
|
|
135
|
+
* wants: a name that is in it IS a key, so the lookup below needs no cast.
|
|
64
136
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* ahead of the bundle a tab is still running
|
|
68
|
-
*
|
|
137
|
+
* It is required because leaving it out is not a smaller version of this — it is the bug the
|
|
138
|
+
* option exists to prevent. i18next answers a key it does not have with the key itself, so a
|
|
139
|
+
* deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
|
|
140
|
+
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
69
141
|
*/
|
|
70
|
-
knownMessageKeys
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
142
|
+
knownMessageKeys: Record<ServerName, unknown>;
|
|
143
|
+
/**
|
|
144
|
+
* Per-code copy. Everything not listed falls through to the default arm.
|
|
145
|
+
*
|
|
146
|
+
* The arms look up the app's own keys, so they close over the app's `t` rather than being
|
|
147
|
+
* handed one — this module has no names for that copy and no business typing it.
|
|
148
|
+
*
|
|
149
|
+
* Annotate your object `Partial<Record<YourErrorCode, ErrorArm>>` where you write it, and a
|
|
150
|
+
* code that does not exist is a compile error at the arm rather than a branch that never runs.
|
|
151
|
+
*/
|
|
152
|
+
codes?: Partial<Record<string, ErrorArm>>;
|
|
75
153
|
}
|
|
76
154
|
|
|
77
|
-
export
|
|
78
|
-
error: ApiError;
|
|
79
|
-
/** The server's own localized sentence, when `messageKey` named one this build carries. */
|
|
80
|
-
says: string | null;
|
|
81
|
-
/** Seconds until it clears, already humanized. Null when the envelope did not say. */
|
|
82
|
-
wait: string | null;
|
|
83
|
-
waitSecs: number | null;
|
|
84
|
-
t: Translate;
|
|
85
|
-
}
|
|
155
|
+
export type ErrorArm = (ctx: ErrorContext) => DescribedError;
|
|
86
156
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
157
|
+
/** Build the describer. */
|
|
158
|
+
export function createErrorDescriber<
|
|
159
|
+
Prefix extends string,
|
|
160
|
+
ServerPrefix extends string,
|
|
161
|
+
ServerName extends string,
|
|
162
|
+
>({
|
|
92
163
|
t,
|
|
93
|
-
|
|
164
|
+
copyPrefix,
|
|
165
|
+
messageKeyPrefix,
|
|
94
166
|
knownMessageKeys,
|
|
95
|
-
copyPrefix = "errors.",
|
|
96
167
|
codes = {},
|
|
97
|
-
}: ErrorDescriberOptions<
|
|
98
|
-
const known =
|
|
99
|
-
? new Set<string>(knownMessageKeys)
|
|
100
|
-
: new Set(Object.keys(knownMessageKeys ?? {}));
|
|
168
|
+
}: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError {
|
|
169
|
+
const known = new Set<string>(Object.keys(knownMessageKeys));
|
|
101
170
|
|
|
102
|
-
|
|
103
|
-
|
|
171
|
+
// Sound, and the reason nothing here needs a cast: the set was built from the keys of
|
|
172
|
+
// `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
|
|
173
|
+
// `in`, which would have accepted "toString".
|
|
174
|
+
const has = (name: string): name is ServerName => known.has(name);
|
|
104
175
|
|
|
105
176
|
function serverSentence(error: ApiError): string | null {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
177
|
+
const lookup = (named: string | undefined): string | null => {
|
|
178
|
+
if (named === undefined || !named.startsWith(messageKeyPrefix)) return null;
|
|
179
|
+
const name = named.slice(messageKeyPrefix.length);
|
|
180
|
+
return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
|
|
181
|
+
};
|
|
182
|
+
// `messageKey` first: an API that sends both means the key, and the code is what the UI
|
|
183
|
+
// switches on. The code is only consulted when there is no key to prefer, and it is spelled
|
|
184
|
+
// under the same prefix so the gate is identical.
|
|
185
|
+
return (
|
|
186
|
+
lookup(error.messageKey) ??
|
|
187
|
+
(error.code === undefined ? null : lookup(`${messageKeyPrefix}${error.code}`))
|
|
188
|
+
);
|
|
110
189
|
}
|
|
111
190
|
|
|
112
191
|
return function describeError(error: unknown): DescribedError {
|
|
113
192
|
if (!(error instanceof ApiError)) {
|
|
114
193
|
// Not a response at all — the request never landed. Almost always the network, and almost
|
|
115
194
|
// never worth showing a stack trace for.
|
|
116
|
-
return { cause: t(
|
|
195
|
+
return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
|
|
117
196
|
}
|
|
118
197
|
|
|
119
198
|
const waitSecs = retryAfterSecs(error);
|
|
@@ -122,10 +201,9 @@ export function createErrorDescriber<Code extends string>({
|
|
|
122
201
|
says: serverSentence(error),
|
|
123
202
|
wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
|
|
124
203
|
waitSecs,
|
|
125
|
-
t,
|
|
126
204
|
};
|
|
127
205
|
|
|
128
|
-
const arm = error.code === undefined ? undefined : codes[error.code
|
|
206
|
+
const arm = error.code === undefined ? undefined : codes[error.code];
|
|
129
207
|
if (arm) return arm(ctx);
|
|
130
208
|
|
|
131
209
|
// Anything unmapped: the server's own sentence is still the most specific thing we have,
|
|
@@ -133,8 +211,8 @@ export function createErrorDescriber<Code extends string>({
|
|
|
133
211
|
// genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
134
212
|
// the reader another attempt.
|
|
135
213
|
return {
|
|
136
|
-
cause: ctx.says ?? (error.message || t(
|
|
137
|
-
hint: error.status >= 500 ? t(
|
|
214
|
+
cause: ctx.says ?? (error.message || t(`${copyPrefix}unexpected`)),
|
|
215
|
+
hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
138
216
|
};
|
|
139
217
|
};
|
|
140
218
|
}
|
package/src/hydrate.ts
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The constants themselves live in `prerender-contract.ts`, which imports nothing, because the
|
|
10
10
|
* BUILD needs them too and must not pull `react-dom/client` into a Node process to get a string.
|
|
11
|
+
*
|
|
12
|
+
* This module sits behind `@gusnips/react/hydrate` rather than in the barrel for the same reason
|
|
13
|
+
* one rung further out: it is the package's ONLY `react-dom` import, and a React Native app has
|
|
14
|
+
* no react-dom to give. Leaving it in the barrel made `react-dom` a required peer and shut the
|
|
15
|
+
* whole package out of every phone — for one function a phone would never call.
|
|
11
16
|
*/
|
|
12
17
|
import type { ReactNode } from "react";
|
|
13
18
|
import { createRoot, hydrateRoot } from "react-dom/client";
|
package/src/index.ts
CHANGED
|
@@ -11,13 +11,21 @@
|
|
|
11
11
|
// @gusnips/react/ui the Base UI wrappers → @base-ui/react
|
|
12
12
|
// @gusnips/react/store createAuthStore → zustand
|
|
13
13
|
// @gusnips/react/guards the route guards → react-router-dom
|
|
14
|
+
// @gusnips/react/hydrate hydrateOrMount → react-dom
|
|
14
15
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
// `react-dom` is the one the second migration added, and it is the sharpest case yet: olhary
|
|
17
|
+
// ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to give.
|
|
18
|
+
// A required peer nobody can satisfy is not a strict contract, it is a closed door — the whole
|
|
19
|
+
// package was unusable there, for one function no phone would ever call. The rule did not need
|
|
20
|
+
// changing to catch it; `react-dom` simply had to stop being required.
|
|
21
|
+
//
|
|
22
|
+
// So what is left here imports `react` and `@gusnips/http` and nothing else, and an app on
|
|
23
|
+
// TanStack Router, on Redux, or on a phone takes the fetch client without installing a router, a
|
|
24
|
+
// store or a DOM renderer it will never call. `@tanstack/react-query`, `i18next` and
|
|
25
|
+
// `react-i18next` stay optional AND stay here, because `query.ts`, `i18n.ts` and `states.ts`
|
|
26
|
+
// import only their TYPES — which erase, so the built barrel does not reference them at runtime.
|
|
27
|
+
// Check that claim against `dist/`, never against this file: a type-only re-export looks
|
|
28
|
+
// identical in source.
|
|
21
29
|
|
|
22
30
|
export { cn } from "./cn.ts";
|
|
23
31
|
|
|
@@ -44,6 +52,7 @@ export {
|
|
|
44
52
|
humanizeWait,
|
|
45
53
|
retryAfterSecs,
|
|
46
54
|
type DescribedError,
|
|
55
|
+
type ErrorArm,
|
|
47
56
|
type ErrorContext,
|
|
48
57
|
type ErrorDescriberOptions,
|
|
49
58
|
type Translate,
|
|
@@ -56,7 +65,10 @@ export {
|
|
|
56
65
|
reloadOnceForChunkError,
|
|
57
66
|
} from "./chunk-reload.ts";
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
// The two constants, but NOT `hydrateOrMount` — that one is at `@gusnips/react/hydrate`, because
|
|
69
|
+
// it is the package's only `react-dom` import. These come from the contract module, which imports
|
|
70
|
+
// nothing at all, so they are free to everyone: a build script, a browser entry, or a phone.
|
|
71
|
+
export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
|
|
60
72
|
|
|
61
73
|
export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
|
|
62
74
|
|