@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 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,
@@ -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":"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"}
1
+ {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
@@ -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
- const known = new Set(Object.keys(knownMessageKeys));
26
- // Sound, and the reason nothing here needs a cast: the set was built from the keys of
27
- // `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
28
- // `in`, which would have accepted "toString".
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
- // Anything unmapped: the server's own sentence is still the most specific thing we have,
86
- // and support can act on it. A 5xx additionally gets "try again shortly", because that one
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":"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"}
1
+ {"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.6.0",
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 —
@@ -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
- const known = new Set<string>(Object.keys(knownMessageKeys));
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
- // Sound, and the reason nothing here needs a cast: the set was built from the keys of
240
- // `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
241
- // `in`, which would have accepted "toString".
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
- // Anything unmapped: the server's own sentence is still the most specific thing we have,
302
- // and support can act on it. A 5xx additionally gets "try again shortly", because that one
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
+ });