@gusnips/react 0.6.0 → 0.6.2
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 +14 -0
- package/dist/describe-error.d.ts +47 -2
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +33 -23
- package/dist/describe-error.js.map +1 -1
- package/package.json +1 -1
- package/src/describe-error.test.ts +122 -0
- package/src/describe-error.ts +87 -25
- package/src/guards.test.ts +129 -0
package/README.md
CHANGED
|
@@ -179,6 +179,20 @@ It is required because the default hid its price: `humanizeWait` reads `waitSeco
|
|
|
179
179
|
`waitMinutes` and `waitHours` out of your catalog, so an app that already formats relative time
|
|
180
180
|
with `Intl` had to add six entries per language before it would compile.
|
|
181
181
|
|
|
182
|
+
An error your `codes` map does not name falls back to the server's own `message`. Pass `fallback`
|
|
183
|
+
when that message is written for a log rather than for a person:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
fallback: ({ says, wait }) => ({
|
|
187
|
+
cause: says ?? t("errors.invalid"),
|
|
188
|
+
fix: wait ? t("errors.retryIn", { when: wait }) : undefined,
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Some APIs write `message` for whoever reads the screen. Some write it for whoever reads the log —
|
|
193
|
+
in English, in an app that ships three languages. Your arm also gets the stated wait, which the
|
|
194
|
+
default has nowhere to put.
|
|
195
|
+
|
|
182
196
|
## Also here
|
|
183
197
|
|
|
184
198
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
package/dist/describe-error.d.ts
CHANGED
|
@@ -81,6 +81,19 @@ type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
|
81
81
|
* left adding six entries per language to satisfy a compiler. `formatWait` owns that now.
|
|
82
82
|
*/
|
|
83
83
|
type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
|
|
84
|
+
/**
|
|
85
|
+
* A plural is stored under suffixed names and named by its base, and the gate has to know both.
|
|
86
|
+
*
|
|
87
|
+
* i18next keeps `seatLimitReached_one` and `seatLimitReached_other`; the server sends
|
|
88
|
+
* `serverErrors.seatLimitReached`, because that is the key a person resolves with a `count`. So
|
|
89
|
+
* `Object.keys` on a catalog does NOT contain the name the server actually sends, and a gate
|
|
90
|
+
* built from it alone rejects a sentence the app is carrying — falling back to the server's
|
|
91
|
+
* English for the one refusal that had a written answer. Measured in the seventh migration: two
|
|
92
|
+
* of that API's 112 keys, both of them plan limits, both of them the exact screen where a
|
|
93
|
+
* specific sentence earns its keep.
|
|
94
|
+
*/
|
|
95
|
+
type PluralSuffix = "zero" | "one" | "two" | "few" | "many" | "other";
|
|
96
|
+
type PluralBase<N extends string> = N extends `${infer Base}_${PluralSuffix}` ? Base : N;
|
|
84
97
|
/**
|
|
85
98
|
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
86
99
|
*
|
|
@@ -111,7 +124,7 @@ export interface ErrorContext {
|
|
|
111
124
|
recover: RecoveryKind;
|
|
112
125
|
}
|
|
113
126
|
export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix extends string, ServerName extends string> {
|
|
114
|
-
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
127
|
+
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName | PluralBase<ServerName>}`>;
|
|
115
128
|
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
116
129
|
copyPrefix: Prefix;
|
|
117
130
|
/**
|
|
@@ -144,8 +157,25 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
144
157
|
* option exists to prevent. i18next answers a key it does not have with the key itself, so a
|
|
145
158
|
* deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
|
|
146
159
|
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
160
|
+
*
|
|
161
|
+
* Hand it over as it is. A plural sits in the catalog under suffixed names while the server
|
|
162
|
+
* names the base, so the base is admitted too — see {@link PluralBase}.
|
|
147
163
|
*/
|
|
148
164
|
knownMessageKeys: Record<ServerName, unknown>;
|
|
165
|
+
/**
|
|
166
|
+
* Transform `params` before they fill the server's sentence.
|
|
167
|
+
*
|
|
168
|
+
* The envelope carries what the SERVER knows, which is not always a word anyone should read: a
|
|
169
|
+
* role id (`admin`), an action verb (`approve_content`). The sentence needs whatever the app's
|
|
170
|
+
* own screens call those things, and the substitution happens here — inside the one lookup that
|
|
171
|
+
* reads `params` — so there is nowhere else an adopter could intervene. Without this seam the
|
|
172
|
+
* choice is to ship the raw id into `{{role}}`, or to stop using the server's sentence at all
|
|
173
|
+
* and re-resolve every key by hand, which is how i18n ended up inside a transport once already.
|
|
174
|
+
*
|
|
175
|
+
* Optional, and identity by default: an API whose params are already words passes nothing. It
|
|
176
|
+
* deliberately takes only the params — an arm that needs the error has `ctx` for that.
|
|
177
|
+
*/
|
|
178
|
+
localizeParams?: (params: Record<string, string | number> | undefined) => Record<string, unknown> | undefined;
|
|
149
179
|
/**
|
|
150
180
|
* Per-code copy. Everything not listed falls through to the default arm.
|
|
151
181
|
*
|
|
@@ -156,6 +186,21 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
156
186
|
* code that does not exist is a compile error at the arm rather than a branch that never runs.
|
|
157
187
|
*/
|
|
158
188
|
codes?: Partial<Record<string, ErrorArm>>;
|
|
189
|
+
/**
|
|
190
|
+
* What to say when nothing in `codes` matches: a code this build has no arm for, or an answer
|
|
191
|
+
* with no envelope at all.
|
|
192
|
+
*
|
|
193
|
+
* The built-in arm hands the reader the server's own `message`, and for one API in the fleet
|
|
194
|
+
* that is right — its message IS the sentence a person should read. For another it is exactly
|
|
195
|
+
* wrong: that repo's own i18n rules say `message` is "the English fallback for logs", and it
|
|
196
|
+
* ships three languages, so the built-in would put developer English in front of a reader who
|
|
197
|
+
* does not speak it. One default cannot be right for both, and the one baked in here was
|
|
198
|
+
* simply the first adopter's convention, mistaken for a fact.
|
|
199
|
+
*
|
|
200
|
+
* An arm here is also handed the stated wait, which the built-in spends on nothing: a refusal
|
|
201
|
+
* with no arm of its own could state its expiry and have that go unsaid.
|
|
202
|
+
*/
|
|
203
|
+
fallback?: ErrorArm;
|
|
159
204
|
/**
|
|
160
205
|
* The codes that mean "this limit does not clear by waiting" — **the same list you hand
|
|
161
206
|
* `queryDefaults`**, because it answers the same question and a second copy is a second
|
|
@@ -167,6 +212,6 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
167
212
|
}
|
|
168
213
|
export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
|
|
169
214
|
/** Build the describer. */
|
|
170
|
-
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, codes, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
|
|
215
|
+
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams, codes, fallback, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
|
|
171
216
|
export {};
|
|
172
217
|
//# 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,EAAkB,MAAM,gBAAgB,CAAC;AAG1D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY;AACtB,mDAAmD;AACjD,OAAO;AACT,gEAAgE;GAC9D,QAAQ;AACV,gFAAgF;GAC9E,MAAM;AACR,iFAAiF;GAC/E,MAAM,CAAC;AAEX,+FAA+F;AAC/F,MAAM,WAAW,SAAS;IACxB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,kGAAkG;IAClG,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,mFAAmF;IACnF,OAAO,EAAE,YAAY,CAAC;CACvB;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;;;;;;;;GAQG;AACH,KAAK,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/dist/describe-error.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ApiError, retryAfterSecs } from "./api-error.js";
|
|
2
2
|
import { shouldRetry } from "./query.js";
|
|
3
|
+
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
|
|
3
4
|
/**
|
|
4
5
|
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
5
6
|
*
|
|
@@ -21,18 +22,23 @@ export function humanizeWait(t, secs, prefix) {
|
|
|
21
22
|
return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
|
|
22
23
|
}
|
|
23
24
|
/** Build the describer. */
|
|
24
|
-
export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams = (params) => params, codes = {}, fallback, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
26
|
+
// Both spellings of every name: the one the catalog stores, and — for a plural — the one the
|
|
27
|
+
// server sends. `Object.keys` and not `in`, which would have accepted "toString".
|
|
28
|
+
const known = new Set();
|
|
29
|
+
for (const name of Object.keys(knownMessageKeys)) {
|
|
30
|
+
known.add(name);
|
|
31
|
+
known.add(name.replace(PLURAL_SUFFIX, ""));
|
|
32
|
+
}
|
|
33
|
+
// Still sound, and still the reason nothing here needs a cast: the set holds exactly the raw
|
|
34
|
+
// keys and their plural bases, which is exactly what the predicate claims.
|
|
29
35
|
const has = (name) => known.has(name);
|
|
30
36
|
function serverSentence(error) {
|
|
31
37
|
const lookup = (named) => {
|
|
32
38
|
if (named === undefined || !named.startsWith(messageKeyPrefix))
|
|
33
39
|
return null;
|
|
34
40
|
const name = named.slice(messageKeyPrefix.length);
|
|
35
|
-
return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
|
|
41
|
+
return has(name) ? t(`${messageKeyPrefix}${name}`, localizeParams(error.params)) : null;
|
|
36
42
|
};
|
|
37
43
|
// `messageKey` first: an API that sends both means the key, and the code is what the UI
|
|
38
44
|
// switches on. The code is only consulted when there is no key to prefer, and it is spelled
|
|
@@ -61,6 +67,25 @@ export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPref
|
|
|
61
67
|
return "wait";
|
|
62
68
|
return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
|
|
63
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Anything `codes` does not name. The server's own sentence is still the most specific thing
|
|
72
|
+
* we have, and support can act on it. A 5xx additionally gets "try again shortly", because that
|
|
73
|
+
* one genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
74
|
+
* the reader another attempt.
|
|
75
|
+
*
|
|
76
|
+
* No `code` means no envelope, and then `message` is not the server's: it is the client's own
|
|
77
|
+
* `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
78
|
+
* restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
79
|
+
* whose three apps each guarded against that one string by hand.
|
|
80
|
+
*
|
|
81
|
+
* Replaceable, because reaching for `message` is a convention rather than a fact — see
|
|
82
|
+
* `fallback`.
|
|
83
|
+
*/
|
|
84
|
+
const unmapped = fallback ??
|
|
85
|
+
(({ error, says }) => ({
|
|
86
|
+
cause: says ?? ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
87
|
+
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
88
|
+
}));
|
|
64
89
|
return function describeError(error) {
|
|
65
90
|
if (!(error instanceof ApiError)) {
|
|
66
91
|
// Not a response at all — the request never landed. Almost always the network, and almost
|
|
@@ -82,23 +107,8 @@ export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPref
|
|
|
82
107
|
waitSecs,
|
|
83
108
|
recover: recoveryFor(error, waitSecs),
|
|
84
109
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
88
|
-
// the reader another attempt.
|
|
89
|
-
//
|
|
90
|
-
// No `code` means no envelope, and then `message` is not the server's: it is the client's
|
|
91
|
-
// own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
92
|
-
// restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
93
|
-
// whose three apps each guarded against that one string by hand.
|
|
94
|
-
const arm = error.code === undefined ? undefined : codes[error.code];
|
|
95
|
-
const copy = arm
|
|
96
|
-
? arm(ctx)
|
|
97
|
-
: {
|
|
98
|
-
cause: ctx.says ??
|
|
99
|
-
((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
100
|
-
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
101
|
-
};
|
|
110
|
+
const arm = (error.code === undefined ? undefined : codes[error.code]) ?? unmapped;
|
|
111
|
+
const copy = arm(ctx);
|
|
102
112
|
// The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
|
|
103
113
|
// error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
|
|
104
114
|
// `reference` slot for it since the contract was written.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AA2GzC,MAAM,aAAa,GAAG,iCAAiC,CAAC;AA2BxD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAC1B,CAAoC,EACpC,IAAY,EACZ,MAAc;IAEd,IAAI,IAAI,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;IAC1F,IAAI,IAAI,GAAG,EAAE,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IACvF,OAAO,CAAC,CAAC,GAAG,MAAM,WAAW,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;AACrE,CAAC;AAkHD,2BAA2B;AAC3B,MAAM,UAAU,oBAAoB,CAIlC,EACA,CAAC,EACD,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EACnC,KAAK,GAAG,EAAE,EACV,QAAQ,EACR,iBAAiB,GAAG,EAAE,EACtB,gBAAgB,GACwC;IACxD,6FAA6F;IAC7F,kFAAkF;IAClF,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACjD,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChB,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,CAAC;IAC7C,CAAC;IAED,6FAA6F;IAC7F,2EAA2E;IAC3E,MAAM,GAAG,GAAG,CAAC,IAAY,EAA+C,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAE3F,SAAS,cAAc,CAAC,KAAe;QACrC,MAAM,MAAM,GAAG,CAAC,KAAyB,EAAiB,EAAE;YAC1D,IAAI,KAAK,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,gBAAgB,CAAC;gBAAE,OAAO,IAAI,CAAC;YAC5E,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;YAClD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,gBAAgB,GAAG,IAAI,EAAE,EAAE,cAAc,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC1F,CAAC,CAAC;QACF,wFAAwF;QACxF,4FAA4F;QAC5F,kDAAkD;QAClD,OAAO,CACL,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC;YACxB,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,gBAAgB,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAC/E,CAAC;IACJ,CAAC;IAED;;;;;OAKG;IACH,SAAS,WAAW,CAAC,KAAe,EAAE,QAAuB;QAC3D,6FAA6F;QAC7F,0FAA0F;QAC1F,kFAAkF;QAClF,IAAI,KAAK,CAAC,QAAQ;YAAE,OAAO,MAAM,CAAC;QAClC,6FAA6F;QAC7F,0FAA0F;QAC1F,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG;YAAE,OAAO,QAAQ,CAAC;QAC1C,6FAA6F;QAC7F,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO,MAAM,CAAC;QACrC,OAAO,WAAW,CAAC,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IACpF,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,QAAQ,GACZ,QAAQ;QACR,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;YACrB,KAAK,EACH,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,UAAU,YAAY,CAAC,CAAC;YAC3F,GAAG,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,UAAU,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;SACnE,CAAC,CAAC,CAAC;IAEN,OAAO,SAAS,aAAa,CAAC,KAAc;QAC1C,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC,EAAE,CAAC;YACjC,0FAA0F;YAC1F,4FAA4F;YAC5F,qEAAqE;YACrE,OAAO;gBACL,KAAK,EAAE,CAAC,CAAC,GAAG,UAAU,SAAS,CAAC;gBAChC,uFAAuF;gBACvF,kFAAkF;gBAClF,GAAG,EAAE,CAAC,CAAC,GAAG,UAAU,aAAa,CAAC;gBAClC,OAAO,EAAE,OAAO;aACjB,CAAC;QACJ,CAAC;QAED,MAAM,QAAQ,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;QACvC,MAAM,GAAG,GAAiB;YACxB,KAAK;YACL,IAAI,EAAE,cAAc,CAAC,KAAK,CAAC;YAC3B,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC;YACrD,QAAQ;YACR,OAAO,EAAE,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC;SACtC,CAAC;QAEF,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,QAAQ,CAAC;QACnF,MAAM,IAAI,GAAc,GAAG,CAAC,GAAG,CAAC,CAAC;QAEjC,2FAA2F;QAC3F,sFAAsF;QACtF,0DAA0D;QAC1D,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC,SAAS,CAAC;QACpD,OAAO;YACL,GAAG,IAAI;YACP,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,GAAG,CAAC,OAAO;YACpC,GAAG,CAAC,SAAS,KAAK,SAAS,IAAI,EAAE,SAAS,EAAE,CAAC;SAC9C,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -113,6 +113,27 @@ describe("createErrorDescriber", () => {
|
|
|
113
113
|
// The server names a SENTENCE, not any key in the app. A messageKey outside the namespace
|
|
114
114
|
// is ignored rather than resolved — otherwise a compromised or careless server could point
|
|
115
115
|
// the client at arbitrary copy.
|
|
116
|
+
// i18next stores a plural suffixed and the server names the base, so `Object.keys` alone does
|
|
117
|
+
// not contain the key the server sends. Before this, the two plan limits in the seventh
|
|
118
|
+
// migration's API — a seat limit and a profile limit — read as keys the build did not have,
|
|
119
|
+
// and each fell back to English on the one screen where the written sentence earns its keep.
|
|
120
|
+
it("admits the base name of a plural the catalog stores suffixed", () => {
|
|
121
|
+
const describePlural = createErrorDescriber({
|
|
122
|
+
t,
|
|
123
|
+
copyPrefix: "errors.",
|
|
124
|
+
formatWait: (secs) => humanizeWait(t, secs, "errors."),
|
|
125
|
+
messageKeyPrefix: "serverErrors.",
|
|
126
|
+
knownMessageKeys: { seatLimitReached_one: "", seatLimitReached_other: "" },
|
|
127
|
+
});
|
|
128
|
+
const error = new ApiError(402, {
|
|
129
|
+
code: "PAYMENT_REQUIRED",
|
|
130
|
+
message: "seat limit reached",
|
|
131
|
+
messageKey: "serverErrors.seatLimitReached",
|
|
132
|
+
params: { count: 3 },
|
|
133
|
+
});
|
|
134
|
+
expect(describePlural(error).cause).toBe('serverErrors.seatLimitReached({"count":3})');
|
|
135
|
+
});
|
|
136
|
+
|
|
116
137
|
it("ignores a messageKey outside the server namespace", () => {
|
|
117
138
|
const error = new ApiError(500, {
|
|
118
139
|
code: "UNKNOWN",
|
|
@@ -220,6 +241,107 @@ describe("the recovery kind", () => {
|
|
|
220
241
|
});
|
|
221
242
|
});
|
|
222
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The seventh migration's seam. The server names what IT knows — a role id, an action verb — and
|
|
246
|
+
* the sentence needs whatever the app's own screens call those. The substitution can only happen
|
|
247
|
+
* inside the lookup that reads `params`, which is why an arm cannot do it: by the time an arm
|
|
248
|
+
* runs, `ctx.says` is already resolved.
|
|
249
|
+
*/
|
|
250
|
+
describe("localizeParams", () => {
|
|
251
|
+
const options = {
|
|
252
|
+
t,
|
|
253
|
+
copyPrefix: "errors." as const,
|
|
254
|
+
formatWait: (secs: number) => humanizeWait(t, secs, "errors."),
|
|
255
|
+
messageKeyPrefix: "serverErrors." as const,
|
|
256
|
+
knownMessageKeys: { roleRequired: "" },
|
|
257
|
+
};
|
|
258
|
+
const refusal = new ApiError(403, {
|
|
259
|
+
code: "FORBIDDEN",
|
|
260
|
+
message: "Needs admin",
|
|
261
|
+
messageKey: "serverErrors.roleRequired",
|
|
262
|
+
params: { role: "admin" },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("translates a param before it fills the sentence", () => {
|
|
266
|
+
const describeError = createErrorDescriber({
|
|
267
|
+
...options,
|
|
268
|
+
localizeParams: (params) => ({
|
|
269
|
+
...params,
|
|
270
|
+
...(typeof params?.role === "string" && { role: t(`roles.${params.role}`) }),
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
expect(describeError(refusal).cause).toBe('serverErrors.roleRequired({"role":"roles.admin"})');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("is identity by default, so params already in words pass straight through", () => {
|
|
277
|
+
const describeError = createErrorDescriber(options);
|
|
278
|
+
expect(describeError(refusal).cause).toBe('serverErrors.roleRequired({"role":"admin"})');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The seventh migration's second seam, and the same lesson as the first: a default that was one
|
|
284
|
+
* adopter's convention, shipped as if it were a fact.
|
|
285
|
+
*
|
|
286
|
+
* The built-in arm hands an unmapped code the server's `message`. That is right for the API whose
|
|
287
|
+
* message IS the reader's sentence. It is wrong for the one whose own i18n rules call `message`
|
|
288
|
+
* "the English fallback for logs" while the app ships three languages — there the built-in puts
|
|
289
|
+
* developer English on screen, which is the single thing that repo's rules forbid.
|
|
290
|
+
*/
|
|
291
|
+
describe("fallback", () => {
|
|
292
|
+
const options = {
|
|
293
|
+
t,
|
|
294
|
+
copyPrefix: "errors." as const,
|
|
295
|
+
formatWait: (secs: number) => humanizeWait(t, secs, "errors."),
|
|
296
|
+
messageKeyPrefix: "serverErrors." as const,
|
|
297
|
+
knownMessageKeys: { quotaDay: "" },
|
|
298
|
+
};
|
|
299
|
+
const describeError = createErrorDescriber({
|
|
300
|
+
...options,
|
|
301
|
+
fallback: ({ error, says, wait }) => ({
|
|
302
|
+
cause:
|
|
303
|
+
says ??
|
|
304
|
+
(error.code !== undefined && error.status < 500 ? "errors.invalid" : "errors.unexpected"),
|
|
305
|
+
fix: wait ? `errors.retryIn(${wait})` : undefined,
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("keeps the server's English log line off the screen", () => {
|
|
310
|
+
const error = new ApiError(422, {
|
|
311
|
+
code: "SLIDE_TOO_LONG",
|
|
312
|
+
message: "slide 3 exceeds 2200 chars",
|
|
313
|
+
});
|
|
314
|
+
expect(describeError(error).cause).toBe("errors.invalid");
|
|
315
|
+
// What the built-in would have said, which is the whole reason the seam exists.
|
|
316
|
+
expect(createErrorDescriber(options)(error).cause).toBe("slide 3 exceeds 2200 chars");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("still resolves a sentence this build carries", () => {
|
|
320
|
+
// The arm only runs for what `codes` left unnamed; it does not take over the key lookup.
|
|
321
|
+
const error = new ApiError(429, {
|
|
322
|
+
code: "SOMETHING_NEW",
|
|
323
|
+
message: "log line",
|
|
324
|
+
messageKey: "serverErrors.quotaDay",
|
|
325
|
+
});
|
|
326
|
+
expect(describeError(error).cause).toBe("serverErrors.quotaDay");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("hands the arm a stated wait the built-in spends on nothing", () => {
|
|
330
|
+
const error = new ApiError(
|
|
331
|
+
503,
|
|
332
|
+
{ code: "SOMETHING_NEW", message: "log line" },
|
|
333
|
+
{ retryAfterSecs: 120 },
|
|
334
|
+
);
|
|
335
|
+
expect(describeError(error).fix).toBe('errors.retryIn(errors.waitMinutes({"count":2}))');
|
|
336
|
+
expect(createErrorDescriber(options)(error).fix).toBe("errors.retrySoon");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("leaves the derived recovery kind alone unless the arm narrows it", () => {
|
|
340
|
+
const error = new ApiError(503, { code: "SOMETHING_NEW", message: "log line" });
|
|
341
|
+
expect(describeError(error).recover).toBe("retry");
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
223
345
|
/**
|
|
224
346
|
* The second convention in the fleet: an API with no `messageKey` field at all, whose CODE
|
|
225
347
|
* names the sentence. One repo has 98 of them — `errors.FORECAST_NOT_FOUND` and its siblings —
|
package/src/describe-error.ts
CHANGED
|
@@ -93,6 +93,21 @@ type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
|
93
93
|
*/
|
|
94
94
|
type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A plural is stored under suffixed names and named by its base, and the gate has to know both.
|
|
98
|
+
*
|
|
99
|
+
* i18next keeps `seatLimitReached_one` and `seatLimitReached_other`; the server sends
|
|
100
|
+
* `serverErrors.seatLimitReached`, because that is the key a person resolves with a `count`. So
|
|
101
|
+
* `Object.keys` on a catalog does NOT contain the name the server actually sends, and a gate
|
|
102
|
+
* built from it alone rejects a sentence the app is carrying — falling back to the server's
|
|
103
|
+
* English for the one refusal that had a written answer. Measured in the seventh migration: two
|
|
104
|
+
* of that API's 112 keys, both of them plan limits, both of them the exact screen where a
|
|
105
|
+
* specific sentence earns its keep.
|
|
106
|
+
*/
|
|
107
|
+
type PluralSuffix = "zero" | "one" | "two" | "few" | "many" | "other";
|
|
108
|
+
type PluralBase<N extends string> = N extends `${infer Base}_${PluralSuffix}` ? Base : N;
|
|
109
|
+
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
|
|
110
|
+
|
|
96
111
|
/**
|
|
97
112
|
* The paragraph above, pinned so it cannot quietly stop being true.
|
|
98
113
|
*
|
|
@@ -162,7 +177,7 @@ export interface ErrorDescriberOptions<
|
|
|
162
177
|
ServerPrefix extends string,
|
|
163
178
|
ServerName extends string,
|
|
164
179
|
> {
|
|
165
|
-
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
180
|
+
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName | PluralBase<ServerName>}`>;
|
|
166
181
|
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
167
182
|
copyPrefix: Prefix;
|
|
168
183
|
/**
|
|
@@ -195,8 +210,27 @@ export interface ErrorDescriberOptions<
|
|
|
195
210
|
* option exists to prevent. i18next answers a key it does not have with the key itself, so a
|
|
196
211
|
* deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
|
|
197
212
|
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
213
|
+
*
|
|
214
|
+
* Hand it over as it is. A plural sits in the catalog under suffixed names while the server
|
|
215
|
+
* names the base, so the base is admitted too — see {@link PluralBase}.
|
|
198
216
|
*/
|
|
199
217
|
knownMessageKeys: Record<ServerName, unknown>;
|
|
218
|
+
/**
|
|
219
|
+
* Transform `params` before they fill the server's sentence.
|
|
220
|
+
*
|
|
221
|
+
* The envelope carries what the SERVER knows, which is not always a word anyone should read: a
|
|
222
|
+
* role id (`admin`), an action verb (`approve_content`). The sentence needs whatever the app's
|
|
223
|
+
* own screens call those things, and the substitution happens here — inside the one lookup that
|
|
224
|
+
* reads `params` — so there is nowhere else an adopter could intervene. Without this seam the
|
|
225
|
+
* choice is to ship the raw id into `{{role}}`, or to stop using the server's sentence at all
|
|
226
|
+
* and re-resolve every key by hand, which is how i18n ended up inside a transport once already.
|
|
227
|
+
*
|
|
228
|
+
* Optional, and identity by default: an API whose params are already words passes nothing. It
|
|
229
|
+
* deliberately takes only the params — an arm that needs the error has `ctx` for that.
|
|
230
|
+
*/
|
|
231
|
+
localizeParams?: (
|
|
232
|
+
params: Record<string, string | number> | undefined,
|
|
233
|
+
) => Record<string, unknown> | undefined;
|
|
200
234
|
/**
|
|
201
235
|
* Per-code copy. Everything not listed falls through to the default arm.
|
|
202
236
|
*
|
|
@@ -207,6 +241,21 @@ export interface ErrorDescriberOptions<
|
|
|
207
241
|
* code that does not exist is a compile error at the arm rather than a branch that never runs.
|
|
208
242
|
*/
|
|
209
243
|
codes?: Partial<Record<string, ErrorArm>>;
|
|
244
|
+
/**
|
|
245
|
+
* What to say when nothing in `codes` matches: a code this build has no arm for, or an answer
|
|
246
|
+
* with no envelope at all.
|
|
247
|
+
*
|
|
248
|
+
* The built-in arm hands the reader the server's own `message`, and for one API in the fleet
|
|
249
|
+
* that is right — its message IS the sentence a person should read. For another it is exactly
|
|
250
|
+
* wrong: that repo's own i18n rules say `message` is "the English fallback for logs", and it
|
|
251
|
+
* ships three languages, so the built-in would put developer English in front of a reader who
|
|
252
|
+
* does not speak it. One default cannot be right for both, and the one baked in here was
|
|
253
|
+
* simply the first adopter's convention, mistaken for a fact.
|
|
254
|
+
*
|
|
255
|
+
* An arm here is also handed the stated wait, which the built-in spends on nothing: a refusal
|
|
256
|
+
* with no arm of its own could state its expiry and have that go unsaid.
|
|
257
|
+
*/
|
|
258
|
+
fallback?: ErrorArm;
|
|
210
259
|
/**
|
|
211
260
|
* The codes that mean "this limit does not clear by waiting" — **the same list you hand
|
|
212
261
|
* `queryDefaults`**, because it answers the same question and a second copy is a second
|
|
@@ -230,22 +279,29 @@ export function createErrorDescriber<
|
|
|
230
279
|
formatWait,
|
|
231
280
|
messageKeyPrefix,
|
|
232
281
|
knownMessageKeys,
|
|
282
|
+
localizeParams = (params) => params,
|
|
233
283
|
codes = {},
|
|
284
|
+
fallback,
|
|
234
285
|
durableLimitCodes = [],
|
|
235
286
|
maxRetryWaitSecs,
|
|
236
287
|
}: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError {
|
|
237
|
-
|
|
288
|
+
// Both spellings of every name: the one the catalog stores, and — for a plural — the one the
|
|
289
|
+
// server sends. `Object.keys` and not `in`, which would have accepted "toString".
|
|
290
|
+
const known = new Set<string>();
|
|
291
|
+
for (const name of Object.keys(knownMessageKeys)) {
|
|
292
|
+
known.add(name);
|
|
293
|
+
known.add(name.replace(PLURAL_SUFFIX, ""));
|
|
294
|
+
}
|
|
238
295
|
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
const has = (name: string): name is ServerName => known.has(name);
|
|
296
|
+
// Still sound, and still the reason nothing here needs a cast: the set holds exactly the raw
|
|
297
|
+
// keys and their plural bases, which is exactly what the predicate claims.
|
|
298
|
+
const has = (name: string): name is ServerName | PluralBase<ServerName> => known.has(name);
|
|
243
299
|
|
|
244
300
|
function serverSentence(error: ApiError): string | null {
|
|
245
301
|
const lookup = (named: string | undefined): string | null => {
|
|
246
302
|
if (named === undefined || !named.startsWith(messageKeyPrefix)) return null;
|
|
247
303
|
const name = named.slice(messageKeyPrefix.length);
|
|
248
|
-
return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
|
|
304
|
+
return has(name) ? t(`${messageKeyPrefix}${name}`, localizeParams(error.params)) : null;
|
|
249
305
|
};
|
|
250
306
|
// `messageKey` first: an API that sends both means the key, and the code is what the UI
|
|
251
307
|
// switches on. The code is only consulted when there is no key to prefer, and it is spelled
|
|
@@ -275,6 +331,28 @@ export function createErrorDescriber<
|
|
|
275
331
|
return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
|
|
276
332
|
}
|
|
277
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Anything `codes` does not name. The server's own sentence is still the most specific thing
|
|
336
|
+
* we have, and support can act on it. A 5xx additionally gets "try again shortly", because that
|
|
337
|
+
* one genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
338
|
+
* the reader another attempt.
|
|
339
|
+
*
|
|
340
|
+
* No `code` means no envelope, and then `message` is not the server's: it is the client's own
|
|
341
|
+
* `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
342
|
+
* restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
343
|
+
* whose three apps each guarded against that one string by hand.
|
|
344
|
+
*
|
|
345
|
+
* Replaceable, because reaching for `message` is a convention rather than a fact — see
|
|
346
|
+
* `fallback`.
|
|
347
|
+
*/
|
|
348
|
+
const unmapped: ErrorArm =
|
|
349
|
+
fallback ??
|
|
350
|
+
(({ error, says }) => ({
|
|
351
|
+
cause:
|
|
352
|
+
says ?? ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
353
|
+
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
354
|
+
}));
|
|
355
|
+
|
|
278
356
|
return function describeError(error: unknown): DescribedError {
|
|
279
357
|
if (!(error instanceof ApiError)) {
|
|
280
358
|
// Not a response at all — the request never landed. Almost always the network, and almost
|
|
@@ -298,24 +376,8 @@ export function createErrorDescriber<
|
|
|
298
376
|
recover: recoveryFor(error, waitSecs),
|
|
299
377
|
};
|
|
300
378
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
304
|
-
// the reader another attempt.
|
|
305
|
-
//
|
|
306
|
-
// No `code` means no envelope, and then `message` is not the server's: it is the client's
|
|
307
|
-
// own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
308
|
-
// restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
309
|
-
// whose three apps each guarded against that one string by hand.
|
|
310
|
-
const arm = error.code === undefined ? undefined : codes[error.code];
|
|
311
|
-
const copy: ErrorCopy = arm
|
|
312
|
-
? arm(ctx)
|
|
313
|
-
: {
|
|
314
|
-
cause:
|
|
315
|
-
ctx.says ??
|
|
316
|
-
((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
317
|
-
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
318
|
-
};
|
|
379
|
+
const arm = (error.code === undefined ? undefined : codes[error.code]) ?? unmapped;
|
|
380
|
+
const copy: ErrorCopy = arm(ctx);
|
|
319
381
|
|
|
320
382
|
// The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
|
|
321
383
|
// error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createRequireAnonymous,
|
|
5
|
+
createRequireAuth,
|
|
6
|
+
createRequireProfile,
|
|
7
|
+
type MeQuery,
|
|
8
|
+
} from "./guards.tsx";
|
|
9
|
+
|
|
10
|
+
// `react-router-dom` is mocked rather than wrapped in a real router. Every question here is only
|
|
11
|
+
// WHICH branch a guard picked, and a redirect is the one branch with no children to read — so
|
|
12
|
+
// `Navigate` never has to run, it only has to be identifiable as an element type. That is what
|
|
13
|
+
// lets the redirect cases assert a destination without rendering anything.
|
|
14
|
+
const router = vi.hoisted(() => ({
|
|
15
|
+
Navigate: vi.fn(),
|
|
16
|
+
useLocation: vi.fn(() => ({ pathname: "/posts", search: "?page=2" })),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("react-router-dom", () => router);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One render, as plain data.
|
|
22
|
+
*
|
|
23
|
+
* A function component IS a function, so calling it returns the element it would have rendered —
|
|
24
|
+
* no DOM, no renderer, nothing mounted, and no test-only dependency to install. The cast is this
|
|
25
|
+
* file admitting it reads two fields off that element and nothing else.
|
|
26
|
+
*/
|
|
27
|
+
type Rendered = { type: unknown; props: Record<string, unknown> };
|
|
28
|
+
const render = (Guard: (p: { children: ReactNode }) => ReactNode): Rendered =>
|
|
29
|
+
Guard({ children: "CONTENT" }) as unknown as Rendered;
|
|
30
|
+
|
|
31
|
+
/** Which branch came back: a redirect names its destination, every other branch names its child. */
|
|
32
|
+
const branch = (r: Rendered): unknown =>
|
|
33
|
+
r.type === router.Navigate ? { to: r.props.to, state: r.props.state } : r.props.children;
|
|
34
|
+
|
|
35
|
+
const session =
|
|
36
|
+
(isAuthenticated: boolean, isLoading = false) =>
|
|
37
|
+
() => ({
|
|
38
|
+
isAuthenticated,
|
|
39
|
+
isLoading,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const profile = (me: MeQuery<{ suspended: boolean }>) =>
|
|
43
|
+
createRequireProfile(() => me, { loading: "LOADING" })(
|
|
44
|
+
(m) => !m.suspended,
|
|
45
|
+
"DENIED",
|
|
46
|
+
(error) => `FAILED:${String(error)}`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
describe("createRequireProfile", () => {
|
|
50
|
+
it("waits while the profile is still being asked for", () => {
|
|
51
|
+
expect(branch(render(profile({ status: "pending" })))).toBe("LOADING");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Invariant 4, and the reason this module exists rather than being written a ninth time.
|
|
55
|
+
// `!me?.isStaff` reads an undefined profile as a real "no", so an outage on /auth/me becomes a
|
|
56
|
+
// refusal — the wrong cause, no retry, and no request id to quote. Three repos shipped that, and
|
|
57
|
+
// in two of them the sibling guard failed the other way for the same reason: `me?.suspended` read
|
|
58
|
+
// a failure as "not suspended" and let a suspended account straight through. One mistake, closed
|
|
59
|
+
// on one screen and OPEN on the next, which is why a failure must reach neither answer.
|
|
60
|
+
it("does not read a failed query as a refusal", () => {
|
|
61
|
+
const failed = branch(render(profile({ status: "error", error: "boom" })));
|
|
62
|
+
expect(failed).toBe("FAILED:boom");
|
|
63
|
+
expect(failed).not.toBe("DENIED");
|
|
64
|
+
expect(failed).not.toBe("CONTENT");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("refuses only on a real no", () => {
|
|
68
|
+
expect(branch(render(profile({ status: "success", data: { suspended: true } })))).toBe(
|
|
69
|
+
"DENIED",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("lets an allowed profile through", () => {
|
|
74
|
+
expect(branch(render(profile({ status: "success", data: { suspended: false } })))).toBe(
|
|
75
|
+
"CONTENT",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("takes a refusal as a function, for one built only when it is used", () => {
|
|
80
|
+
const Guard = createRequireProfile(() => ({ status: "success" as const, data: 1 }), {
|
|
81
|
+
loading: "LOADING",
|
|
82
|
+
})(
|
|
83
|
+
() => false,
|
|
84
|
+
() => "DENIED-LAZY",
|
|
85
|
+
() => "FAILED",
|
|
86
|
+
);
|
|
87
|
+
expect(branch(render(Guard))).toBe("DENIED-LAZY");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("createRequireAuth", () => {
|
|
92
|
+
const guard = (isAuthenticated: boolean, isLoading = false) =>
|
|
93
|
+
createRequireAuth(session(isAuthenticated, isLoading), "/login", { loading: "LOADING" });
|
|
94
|
+
|
|
95
|
+
it("waits while the session is still resolving", () => {
|
|
96
|
+
expect(branch(render(guard(false, true)))).toBe("LOADING");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// The redirect carries where they were headed, query string included: an adopter's sign-in screen
|
|
100
|
+
// reads `location.state.from` to land them on the page they actually wanted rather than the home
|
|
101
|
+
// screen. One donor hardcoded this in four places, which is what made it a factory.
|
|
102
|
+
it("remembers the address it turned away", () => {
|
|
103
|
+
expect(branch(render(guard(false)))).toEqual({
|
|
104
|
+
to: "/login",
|
|
105
|
+
state: { from: "/posts?page=2" },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("lets a signed-in visitor through", () => {
|
|
110
|
+
expect(branch(render(guard(true)))).toBe("CONTENT");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("createRequireAnonymous", () => {
|
|
115
|
+
const guard = (isAuthenticated: boolean, isLoading = false) =>
|
|
116
|
+
createRequireAnonymous(session(isAuthenticated, isLoading), "/", { loading: "LOADING" });
|
|
117
|
+
|
|
118
|
+
it("waits while the session is still resolving", () => {
|
|
119
|
+
expect(branch(render(guard(true, true)))).toBe("LOADING");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("sends a signed-in visitor home", () => {
|
|
123
|
+
expect(branch(render(guard(true)))).toEqual({ to: "/", state: undefined });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("lets an anonymous visitor through", () => {
|
|
127
|
+
expect(branch(render(guard(false)))).toBe("CONTENT");
|
|
128
|
+
});
|
|
129
|
+
});
|