@gusnips/react 0.5.3 → 0.6.1

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
@@ -156,15 +156,29 @@ hurry cannot skip.
156
156
  which control to offer, and the request id to print under it:
157
157
 
158
158
  ```ts
159
- const { cause, hint, recover, reference } = describeError(error);
159
+ const { cause, fix, recover, reference } = describeError(error);
160
160
  // recover: "retry" | "signin" | "wait" | "none"
161
161
  ```
162
162
 
163
+ The field is `fix`, the same word as the prop above. It was `hint` until 0.6.0, which left every
164
+ adopter writing `fix={hint}` at every error surface.
165
+
163
166
  `recover` is derived from `shouldRetry` — the same rule react-query retries on — so the button a
164
167
  reader sees and the retry that actually happens cannot disagree. Give it the same
165
168
  `durableLimitCodes` list you give `queryDefaults`, and a spent quota offers no button instead of
166
169
  one that cannot work.
167
170
 
171
+ You also say how a stated wait becomes words:
172
+
173
+ ```ts
174
+ formatWait: (secs) => humanizeWait(t, secs, "errors."); // three ICU plural keys
175
+ formatWait: (secs) => formatIn(secs, locale); // or your own Intl formatter
176
+ ```
177
+
178
+ It is required because the default hid its price: `humanizeWait` reads `waitSeconds`,
179
+ `waitMinutes` and `waitHours` out of your catalog, so an app that already formats relative time
180
+ with `Intl` had to add six entries per language before it would compile.
181
+
168
182
  ## Also here
169
183
 
170
184
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
@@ -8,11 +8,17 @@ import { ApiError } from "./api-error.ts";
8
8
  * "Monthly quota spent, upgrade or buy a pack" is right in a JSON body and wrong under a button
9
9
  * that could just say what to do next.
10
10
  *
11
- * `cause` is what happened. `hint` is what to do about it. **A surface that shows only the first
11
+ * `cause` is what happened. `fix` is what to do about it. **A surface that shows only the first
12
12
  * half is the dead end this exists to prevent** — which is why the type has two fields and not
13
- * one, and why `hint` being absent should be a deliberate choice at each call site rather than
13
+ * one, and why `fix` being absent should be a deliberate choice at each call site rather than
14
14
  * the default nobody noticed.
15
15
  *
16
+ * `fix`, and not the `hint` this returned until 0.6.0. `ErrorStateProps` — the contract this same
17
+ * package ships for the component that renders this — has always called it `fix`, so every
18
+ * adopter wrote `fix={hint}` at every error surface: a rename adapter, which is the shape of an
19
+ * API we got wrong. It also clears a collision, because in at least one adopter a form field's
20
+ * `hint` is the requirement text under the input, which is a different thing entirely.
21
+ *
16
22
  * The per-code switch stays in the product: two donors' switches shared their SHAPE and almost
17
23
  * no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
18
24
  * which is the part that was written twice and got subtly different both times.
@@ -42,7 +48,7 @@ export interface ErrorCopy {
42
48
  /** What happened, in the reader's words. */
43
49
  cause: string;
44
50
  /** What to do about it. Its absence should be a decision, not an oversight. */
45
- hint?: string;
51
+ fix?: string;
46
52
  /** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
47
53
  recover?: RecoveryKind;
48
54
  /** Override the request id. Rarely wanted; the envelope's is used by default. */
@@ -65,8 +71,16 @@ export interface DescribedError extends ErrorCopy {
65
71
  export type Translate<Key extends string = string> = (key: Key, params?: Record<string, unknown>) => string;
66
72
  /** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
67
73
  type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
68
- /** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
69
- type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
74
+ /**
75
+ * Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them.
76
+ *
77
+ * The three wait names are deliberately NOT here, which is the other half of 0.6.0. This module
78
+ * used to humanize a stated wait itself, so `waitSeconds`/`waitMinutes`/`waitHours` — each with
79
+ * an ICU plural — landed in the key union of every adopter's `t` whether or not a wait was ever
80
+ * rendered. An app that formats waits with `Intl.RelativeTimeFormat` needs none of them and was
81
+ * left adding six entries per language to satisfy a compiler. `formatWait` owns that now.
82
+ */
83
+ type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
70
84
  /**
71
85
  * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
72
86
  *
@@ -74,6 +88,11 @@ type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon
74
88
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
75
89
  * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
76
90
  * `count` plural.
91
+ *
92
+ * This is what you pass as `formatWait` when your catalog carries those three:
93
+ * `formatWait: (secs) => humanizeWait(t, secs, "errors.")`. An app that formats a wait with
94
+ * `Intl.RelativeTimeFormat` passes its own one-liner instead and needs no wait keys at all —
95
+ * which also gets it correct plurals in languages with more than two forms, for free.
77
96
  */
78
97
  export declare function humanizeWait<Prefix extends string>(t: Translate<`${Prefix}${WaitName}`>, secs: number, prefix: Prefix): string;
79
98
  export interface ErrorContext {
@@ -95,6 +114,16 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
95
114
  t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
96
115
  /** Catalog namespace for this module's own copy, e.g. `"errors."`. */
97
116
  copyPrefix: Prefix;
117
+ /**
118
+ * How a stated wait becomes words, for the `wait` an arm reads off its context.
119
+ *
120
+ * Required rather than defaulted, because there is no answer that is right for everyone and a
121
+ * default hid the cost: {@link humanizeWait} reads three ICU plural keys out of your catalog,
122
+ * and an app that already formats relative time with `Intl` would have had to add them to
123
+ * compile. Say which you want — `(secs) => humanizeWait(t, secs, "errors.")`, or your own
124
+ * `(secs) => formatIn(secs, locale)` — and the catalog you need is the one you can see.
125
+ */
126
+ formatWait: (secs: number) => string;
98
127
  /**
99
128
  * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
100
129
  * ignored — the server names a sentence, it does not get to name any key in the app.
@@ -117,6 +146,20 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
117
146
  * somebody. A name that is absent falls back to the server's English `message` instead.
118
147
  */
119
148
  knownMessageKeys: Record<ServerName, unknown>;
149
+ /**
150
+ * Transform `params` before they fill the server's sentence.
151
+ *
152
+ * The envelope carries what the SERVER knows, which is not always a word anyone should read: a
153
+ * role id (`admin`), an action verb (`approve_content`). The sentence needs whatever the app's
154
+ * own screens call those things, and the substitution happens here — inside the one lookup that
155
+ * reads `params` — so there is nowhere else an adopter could intervene. Without this seam the
156
+ * choice is to ship the raw id into `{{role}}`, or to stop using the server's sentence at all
157
+ * and re-resolve every key by hand, which is how i18n ended up inside a transport once already.
158
+ *
159
+ * Optional, and identity by default: an API whose params are already words passes nothing. It
160
+ * deliberately takes only the params — an arm that needs the error has `ctx` for that.
161
+ */
162
+ localizeParams?: (params: Record<string, string | number> | undefined) => Record<string, unknown> | undefined;
120
163
  /**
121
164
  * Per-code copy. Everything not listed falls through to the default arm.
122
165
  *
@@ -138,6 +181,6 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
138
181
  }
139
182
  export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
140
183
  /** Build the describer. */
141
- export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
184
+ export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams, codes, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
142
185
  export {};
143
186
  //# 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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"}
@@ -7,6 +7,11 @@ import { shouldRetry } from "./query.js";
7
7
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
8
8
  * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
9
9
  * `count` plural.
10
+ *
11
+ * This is what you pass as `formatWait` when your catalog carries those three:
12
+ * `formatWait: (secs) => humanizeWait(t, secs, "errors.")`. An app that formats a wait with
13
+ * `Intl.RelativeTimeFormat` passes its own one-liner instead and needs no wait keys at all —
14
+ * which also gets it correct plurals in languages with more than two forms, for free.
10
15
  */
11
16
  export function humanizeWait(t, secs, prefix) {
12
17
  if (secs < 90)
@@ -16,7 +21,7 @@ export function humanizeWait(t, secs, prefix) {
16
21
  return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
17
22
  }
18
23
  /** Build the describer. */
19
- export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
24
+ export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams = (params) => params, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
20
25
  const known = new Set(Object.keys(knownMessageKeys));
21
26
  // Sound, and the reason nothing here needs a cast: the set was built from the keys of
22
27
  // `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
@@ -27,7 +32,7 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
27
32
  if (named === undefined || !named.startsWith(messageKeyPrefix))
28
33
  return null;
29
34
  const name = named.slice(messageKeyPrefix.length);
30
- return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
35
+ return has(name) ? t(`${messageKeyPrefix}${name}`, localizeParams(error.params)) : null;
31
36
  };
32
37
  // `messageKey` first: an API that sends both means the key, and the code is what the UI
33
38
  // switches on. The code is only consulted when there is no key to prefer, and it is spelled
@@ -63,7 +68,9 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
63
68
  // so these two agree here without either being told about the other.
64
69
  return {
65
70
  cause: t(`${copyPrefix}network`),
66
- hint: t(`${copyPrefix}networkHint`),
71
+ // The catalog KEY keeps its name while the field becomes `fix`: renaming it would cost
72
+ // every adopter a JSON edit in every language, for a string no caller ever types.
73
+ fix: t(`${copyPrefix}networkHint`),
67
74
  recover: "retry",
68
75
  };
69
76
  }
@@ -71,7 +78,7 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
71
78
  const ctx = {
72
79
  error,
73
80
  says: serverSentence(error),
74
- wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
81
+ wait: waitSecs === null ? null : formatWait(waitSecs),
75
82
  waitSecs,
76
83
  recover: recoveryFor(error, waitSecs),
77
84
  };
@@ -90,7 +97,7 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
90
97
  : {
91
98
  cause: ctx.says ??
92
99
  ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
93
- hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
100
+ fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
94
101
  };
95
102
  // The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
96
103
  // error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
@@ -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;AA4GzC;;;;;;;GAOG;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;AAsED,2BAA2B;AAC3B,MAAM,UAAU,oBAAoB,CAIlC,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,GAAG,EAAE,EACV,iBAAiB,GAAG,EAAE,EACtB,gBAAgB,GACwC;IACxD,MAAM,KAAK,GAAG,IAAI,GAAG,CAAS,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC;IAE7D,sFAAsF;IACtF,2FAA2F;IAC3F,8CAA8C;IAC9C,MAAM,GAAG,GAAG,CAAC,IAAY,EAAsB,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAElE,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,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC1E,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,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,IAAI,EAAE,CAAC,CAAC,GAAG,UAAU,aAAa,CAAC;gBACnC,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,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,UAAU,CAAC;YACtE,QAAQ;YACR,OAAO,EAAE,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC;SACtC,CAAC;QAEF,yFAAyF;QACzF,2FAA2F;QAC3F,4FAA4F;QAC5F,8BAA8B;QAC9B,EAAE;QACF,0FAA0F;QAC1F,6FAA6F;QAC7F,6FAA6F;QAC7F,iEAAiE;QACjE,MAAM,GAAG,GAAG,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACrE,MAAM,IAAI,GAAc,GAAG;YACzB,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;YACV,CAAC,CAAC;gBACE,KAAK,EACH,GAAG,CAAC,IAAI;oBACR,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;gBACnF,IAAI,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,UAAU,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;aACpE,CAAC;QAEN,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"}
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.5.3",
3
+ "version": "0.6.1",
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",
@@ -23,25 +23,68 @@ describe("humanizeWait", () => {
23
23
  });
24
24
  });
25
25
 
26
+ /**
27
+ * The seam 0.6.0 added, and why it is required rather than defaulted: this module used to
28
+ * humanize the wait itself, which put `waitSeconds`/`waitMinutes`/`waitHours` into the key union
29
+ * of every adopter's `t` — six ICU entries per language — even for an app that formats relative
30
+ * time with `Intl` and would never render one of them.
31
+ */
32
+ describe("formatWait", () => {
33
+ const describeError = createErrorDescriber({
34
+ t,
35
+ copyPrefix: "errors.",
36
+ // What an adopter on `Intl.RelativeTimeFormat` passes. Its catalog has no wait keys at all,
37
+ // and it gets correct plurals in every language without writing one.
38
+ formatWait: (secs) =>
39
+ new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
40
+ Math.round(secs / 60),
41
+ "minute",
42
+ ),
43
+ messageKeyPrefix: "serverErrors.",
44
+ knownMessageKeys: {},
45
+ codes: {
46
+ RATE_LIMIT_EXCEEDED: ({ wait }) => ({
47
+ cause: "errors.rateLimit",
48
+ fix: `wait:${String(wait)}`,
49
+ }),
50
+ },
51
+ });
52
+
53
+ it("hands an arm the wait in the app's own words, not the package's", () => {
54
+ const error = new ApiError(
55
+ 429,
56
+ { code: "RATE_LIMIT_EXCEEDED", message: "slow down" },
57
+ { retryAfterSecs: 120 },
58
+ );
59
+ expect(describeError(error).fix).toBe("wait:in 2 minutes");
60
+ });
61
+
62
+ it("leaves the wait null when the refusal never stated one", () => {
63
+ const error = new ApiError(429, { code: "RATE_LIMIT_EXCEEDED", message: "slow down" });
64
+ expect(describeError(error).fix).toBe("wait:null");
65
+ });
66
+ });
67
+
26
68
  describe("createErrorDescriber", () => {
27
69
  const describeError = createErrorDescriber({
28
70
  t,
29
71
  copyPrefix: "errors.",
72
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
30
73
  messageKeyPrefix: "serverErrors.",
31
74
  knownMessageKeys: { quotaDay: "", suspended: "" },
32
75
  codes: {
33
76
  QUOTA_EXCEEDED: ({ says, wait }) => ({
34
77
  cause: says ?? "errors.quota",
35
- hint: wait ? `errors.retryIn(${wait})` : undefined,
78
+ fix: wait ? `errors.retryIn(${wait})` : undefined,
36
79
  }),
37
80
  },
38
81
  });
39
82
 
40
83
  it("reads a thrown non-response as the network, with a way forward", () => {
41
- const { cause, hint } = describeError(new TypeError("Failed to fetch"));
84
+ const { cause, fix } = describeError(new TypeError("Failed to fetch"));
42
85
  expect(cause).toBe("errors.network");
43
- // The hint is the point. A cause with no hint is the dead end this module exists to stop.
44
- expect(hint).toBe("errors.networkHint");
86
+ // The fix is the point. A cause with no fix is the dead end this module exists to stop.
87
+ expect(fix).toBe("errors.networkHint");
45
88
  });
46
89
 
47
90
  it("resolves a messageKey this build carries, with its params", () => {
@@ -82,16 +125,16 @@ describe("createErrorDescriber", () => {
82
125
  it("offers a retry on a 5xx and withholds it on a 4xx", () => {
83
126
  // A 5xx genuinely clears on its own. A 4xx does not, and saying so would cost the reader
84
127
  // another attempt for nothing.
85
- expect(describeError(new ApiError(503, null)).hint).toBe("errors.retrySoon");
86
- expect(describeError(new ApiError(403, null)).hint).toBeUndefined();
128
+ expect(describeError(new ApiError(503, null)).fix).toBe("errors.retrySoon");
129
+ expect(describeError(new ApiError(403, null)).fix).toBeUndefined();
87
130
  });
88
131
 
89
132
  it("never puts the client's own log line on screen", () => {
90
133
  // A gateway answers with HTML, so there is no envelope and `message` is the client's
91
134
  // `Request failed (502)` — English, and written for a log.
92
- const { cause, hint } = describeError(new ApiError(502, null));
135
+ const { cause, fix } = describeError(new ApiError(502, null));
93
136
  expect(cause).toBe("errors.unexpected");
94
- expect(hint).toBe("errors.retrySoon");
137
+ expect(fix).toBe("errors.retrySoon");
95
138
  });
96
139
  });
97
140
 
@@ -107,6 +150,7 @@ describe("the recovery kind", () => {
107
150
  messageKeyPrefix: "serverErrors.",
108
151
  knownMessageKeys: { quotaDay: "" },
109
152
  durableLimitCodes: ["QUOTA_EXCEEDED"],
153
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
110
154
  });
111
155
 
112
156
  it("sends a 401 to sign-in and leaves a 403 alone", () => {
@@ -159,6 +203,7 @@ describe("the recovery kind", () => {
159
203
  const describe503 = createErrorDescriber({
160
204
  t,
161
205
  copyPrefix: "errors.",
206
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
162
207
  messageKeyPrefix: "serverErrors.",
163
208
  knownMessageKeys: {},
164
209
  codes: {
@@ -175,6 +220,44 @@ describe("the recovery kind", () => {
175
220
  });
176
221
  });
177
222
 
223
+ /**
224
+ * The seventh migration's seam. The server names what IT knows — a role id, an action verb — and
225
+ * the sentence needs whatever the app's own screens call those. The substitution can only happen
226
+ * inside the lookup that reads `params`, which is why an arm cannot do it: by the time an arm
227
+ * runs, `ctx.says` is already resolved.
228
+ */
229
+ describe("localizeParams", () => {
230
+ const options = {
231
+ t,
232
+ copyPrefix: "errors." as const,
233
+ formatWait: (secs: number) => humanizeWait(t, secs, "errors."),
234
+ messageKeyPrefix: "serverErrors." as const,
235
+ knownMessageKeys: { roleRequired: "" },
236
+ };
237
+ const refusal = new ApiError(403, {
238
+ code: "FORBIDDEN",
239
+ message: "Needs admin",
240
+ messageKey: "serverErrors.roleRequired",
241
+ params: { role: "admin" },
242
+ });
243
+
244
+ it("translates a param before it fills the sentence", () => {
245
+ const describeError = createErrorDescriber({
246
+ ...options,
247
+ localizeParams: (params) => ({
248
+ ...params,
249
+ ...(typeof params?.role === "string" && { role: t(`roles.${params.role}`) }),
250
+ }),
251
+ });
252
+ expect(describeError(refusal).cause).toBe('serverErrors.roleRequired({"role":"roles.admin"})');
253
+ });
254
+
255
+ it("is identity by default, so params already in words pass straight through", () => {
256
+ const describeError = createErrorDescriber(options);
257
+ expect(describeError(refusal).cause).toBe('serverErrors.roleRequired({"role":"admin"})');
258
+ });
259
+ });
260
+
178
261
  /**
179
262
  * The second convention in the fleet: an API with no `messageKey` field at all, whose CODE
180
263
  * names the sentence. One repo has 98 of them — `errors.FORECAST_NOT_FOUND` and its siblings —
@@ -185,6 +268,7 @@ describe("createErrorDescriber, when the code names the sentence", () => {
185
268
  const describeError = createErrorDescriber({
186
269
  t,
187
270
  copyPrefix: "errors.",
271
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
188
272
  messageKeyPrefix: "errors.",
189
273
  knownMessageKeys: { FORECAST_NOT_FOUND: "", network: "" },
190
274
  });
@@ -10,11 +10,17 @@ import { shouldRetry } from "./query.ts";
10
10
  * "Monthly quota spent, upgrade or buy a pack" is right in a JSON body and wrong under a button
11
11
  * that could just say what to do next.
12
12
  *
13
- * `cause` is what happened. `hint` is what to do about it. **A surface that shows only the first
13
+ * `cause` is what happened. `fix` is what to do about it. **A surface that shows only the first
14
14
  * half is the dead end this exists to prevent** — which is why the type has two fields and not
15
- * one, and why `hint` being absent should be a deliberate choice at each call site rather than
15
+ * one, and why `fix` being absent should be a deliberate choice at each call site rather than
16
16
  * the default nobody noticed.
17
17
  *
18
+ * `fix`, and not the `hint` this returned until 0.6.0. `ErrorStateProps` — the contract this same
19
+ * package ships for the component that renders this — has always called it `fix`, so every
20
+ * adopter wrote `fix={hint}` at every error surface: a rename adapter, which is the shape of an
21
+ * API we got wrong. It also clears a collision, because in at least one adopter a form field's
22
+ * `hint` is the requirement text under the input, which is a different thing entirely.
23
+ *
18
24
  * The per-code switch stays in the product: two donors' switches shared their SHAPE and almost
19
25
  * no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
20
26
  * which is the part that was written twice and got subtly different both times.
@@ -46,7 +52,7 @@ export interface ErrorCopy {
46
52
  /** What happened, in the reader's words. */
47
53
  cause: string;
48
54
  /** What to do about it. Its absence should be a decision, not an oversight. */
49
- hint?: string;
55
+ fix?: string;
50
56
  /** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
51
57
  recover?: RecoveryKind;
52
58
  /** Override the request id. Rarely wanted; the envelope's is used by default. */
@@ -76,8 +82,16 @@ export type Translate<Key extends string = string> = (
76
82
  /** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
77
83
  type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
78
84
 
79
- /** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
80
- type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
85
+ /**
86
+ * Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them.
87
+ *
88
+ * The three wait names are deliberately NOT here, which is the other half of 0.6.0. This module
89
+ * used to humanize a stated wait itself, so `waitSeconds`/`waitMinutes`/`waitHours` — each with
90
+ * an ICU plural — landed in the key union of every adopter's `t` whether or not a wait was ever
91
+ * rendered. An app that formats waits with `Intl.RelativeTimeFormat` needs none of them and was
92
+ * left adding six entries per language to satisfy a compiler. `formatWait` owns that now.
93
+ */
94
+ type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
81
95
 
82
96
  /**
83
97
  * The paragraph above, pinned so it cannot quietly stop being true.
@@ -94,9 +108,6 @@ type ClosedCatalog = (
94
108
  | "errors.networkHint"
95
109
  | "errors.unexpected"
96
110
  | "errors.retrySoon"
97
- | "errors.waitSeconds"
98
- | "errors.waitMinutes"
99
- | "errors.waitHours"
100
111
  | "serverErrors.quotaDay"
101
112
  | "billing.plan",
102
113
  params?: Record<string, unknown>,
@@ -114,6 +125,11 @@ type _TypedCatalogNeedsNoCast = Satisfied<
114
125
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
115
126
  * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
116
127
  * `count` plural.
128
+ *
129
+ * This is what you pass as `formatWait` when your catalog carries those three:
130
+ * `formatWait: (secs) => humanizeWait(t, secs, "errors.")`. An app that formats a wait with
131
+ * `Intl.RelativeTimeFormat` passes its own one-liner instead and needs no wait keys at all —
132
+ * which also gets it correct plurals in languages with more than two forms, for free.
117
133
  */
118
134
  export function humanizeWait<Prefix extends string>(
119
135
  t: Translate<`${Prefix}${WaitName}`>,
@@ -149,6 +165,16 @@ export interface ErrorDescriberOptions<
149
165
  t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
150
166
  /** Catalog namespace for this module's own copy, e.g. `"errors."`. */
151
167
  copyPrefix: Prefix;
168
+ /**
169
+ * How a stated wait becomes words, for the `wait` an arm reads off its context.
170
+ *
171
+ * Required rather than defaulted, because there is no answer that is right for everyone and a
172
+ * default hid the cost: {@link humanizeWait} reads three ICU plural keys out of your catalog,
173
+ * and an app that already formats relative time with `Intl` would have had to add them to
174
+ * compile. Say which you want — `(secs) => humanizeWait(t, secs, "errors.")`, or your own
175
+ * `(secs) => formatIn(secs, locale)` — and the catalog you need is the one you can see.
176
+ */
177
+ formatWait: (secs: number) => string;
152
178
  /**
153
179
  * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
154
180
  * ignored — the server names a sentence, it does not get to name any key in the app.
@@ -171,6 +197,22 @@ export interface ErrorDescriberOptions<
171
197
  * somebody. A name that is absent falls back to the server's English `message` instead.
172
198
  */
173
199
  knownMessageKeys: Record<ServerName, unknown>;
200
+ /**
201
+ * Transform `params` before they fill the server's sentence.
202
+ *
203
+ * The envelope carries what the SERVER knows, which is not always a word anyone should read: a
204
+ * role id (`admin`), an action verb (`approve_content`). The sentence needs whatever the app's
205
+ * own screens call those things, and the substitution happens here — inside the one lookup that
206
+ * reads `params` — so there is nowhere else an adopter could intervene. Without this seam the
207
+ * choice is to ship the raw id into `{{role}}`, or to stop using the server's sentence at all
208
+ * and re-resolve every key by hand, which is how i18n ended up inside a transport once already.
209
+ *
210
+ * Optional, and identity by default: an API whose params are already words passes nothing. It
211
+ * deliberately takes only the params — an arm that needs the error has `ctx` for that.
212
+ */
213
+ localizeParams?: (
214
+ params: Record<string, string | number> | undefined,
215
+ ) => Record<string, unknown> | undefined;
174
216
  /**
175
217
  * Per-code copy. Everything not listed falls through to the default arm.
176
218
  *
@@ -201,8 +243,10 @@ export function createErrorDescriber<
201
243
  >({
202
244
  t,
203
245
  copyPrefix,
246
+ formatWait,
204
247
  messageKeyPrefix,
205
248
  knownMessageKeys,
249
+ localizeParams = (params) => params,
206
250
  codes = {},
207
251
  durableLimitCodes = [],
208
252
  maxRetryWaitSecs,
@@ -218,7 +262,7 @@ export function createErrorDescriber<
218
262
  const lookup = (named: string | undefined): string | null => {
219
263
  if (named === undefined || !named.startsWith(messageKeyPrefix)) return null;
220
264
  const name = named.slice(messageKeyPrefix.length);
221
- return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
265
+ return has(name) ? t(`${messageKeyPrefix}${name}`, localizeParams(error.params)) : null;
222
266
  };
223
267
  // `messageKey` first: an API that sends both means the key, and the code is what the UI
224
268
  // switches on. The code is only consulted when there is no key to prefer, and it is spelled
@@ -255,7 +299,9 @@ export function createErrorDescriber<
255
299
  // so these two agree here without either being told about the other.
256
300
  return {
257
301
  cause: t(`${copyPrefix}network`),
258
- hint: t(`${copyPrefix}networkHint`),
302
+ // The catalog KEY keeps its name while the field becomes `fix`: renaming it would cost
303
+ // every adopter a JSON edit in every language, for a string no caller ever types.
304
+ fix: t(`${copyPrefix}networkHint`),
259
305
  recover: "retry",
260
306
  };
261
307
  }
@@ -264,7 +310,7 @@ export function createErrorDescriber<
264
310
  const ctx: ErrorContext = {
265
311
  error,
266
312
  says: serverSentence(error),
267
- wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
313
+ wait: waitSecs === null ? null : formatWait(waitSecs),
268
314
  waitSecs,
269
315
  recover: recoveryFor(error, waitSecs),
270
316
  };
@@ -285,7 +331,7 @@ export function createErrorDescriber<
285
331
  cause:
286
332
  ctx.says ??
287
333
  ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
288
- hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
334
+ fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
289
335
  };
290
336
 
291
337
  // The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
@@ -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
+ });