@gusnips/react 0.5.1 → 0.5.3

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
@@ -152,13 +152,29 @@ the prop shape _is_ the rule: `problem`, `cause`, `fix`, and `action`. **`fix` a
152
152
  required.** A required prop is the only version of "always offer a way out" that a caller in a
153
153
  hurry cannot skip.
154
154
 
155
- `createErrorDescriber` turns a thrown `ApiError` into the `cause` and `fix` to put in it.
155
+ `createErrorDescriber` turns a thrown `ApiError` into the `cause` and `fix` to put in it — plus
156
+ which control to offer, and the request id to print under it:
157
+
158
+ ```ts
159
+ const { cause, hint, recover, reference } = describeError(error);
160
+ // recover: "retry" | "signin" | "wait" | "none"
161
+ ```
162
+
163
+ `recover` is derived from `shouldRetry` — the same rule react-query retries on — so the button a
164
+ reader sees and the retry that actually happens cannot disagree. Give it the same
165
+ `durableLimitCodes` list you give `queryDefaults`, and a spent quota offers no button instead of
166
+ one that cannot work.
156
167
 
157
168
  ## Also here
158
169
 
159
170
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
160
171
  `i18nInitOptions`, and `cn`.
161
172
 
173
+ Pass `storageWriter: "app"` to `i18nInitOptions` when your own language control writes the stored
174
+ locale. Leave it out and the detector writes it, which is right until you offer "follow the
175
+ browser": going back to that means clearing the key and detecting again, and a detector that
176
+ writes on every change puts the language it just detected straight back in. One writer per key.
177
+
162
178
  `createAuthStore` is at `@gusnips/react/store` — a zustand store whose `isLoading` starts `false`
163
179
  where there is no window. A session bootstrap can only be in flight in a browser, and `true`
164
180
  during a build is a wait that never ends: it once shipped a spinner as the indexable body of a
@@ -17,11 +17,40 @@ import { ApiError } from "./api-error.ts";
17
17
  * no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
18
18
  * which is the part that was written twice and got subtly different both times.
19
19
  */
20
- export interface DescribedError {
20
+ /**
21
+ * Which control to offer beside the words.
22
+ *
23
+ * The fourth adopter had this and the package did not, which left every adopter re-deriving
24
+ * "can a second attempt fix this" from its own switch — beside a `shouldRetry` already deciding
25
+ * exactly that for react-query. Two answers to one question drift, and the drift is visible: a
26
+ * screen offering "try again" for a refusal the query layer refuses to retry, so the button does
27
+ * nothing and the reader presses it twice.
28
+ *
29
+ * So this is DERIVED from `shouldRetry`, not switched on separately. One rule, two consumers.
30
+ */
31
+ export type RecoveryKind =
32
+ /** A second attempt can work. Offer the button. */
33
+ "retry"
34
+ /** Auth said no. Offer a way back to sign-in, never a retry. */
35
+ | "signin"
36
+ /** It stated its own expiry. Offer the button, disabled, with the countdown. */
37
+ | "wait"
38
+ /** Waiting cannot fix it and neither can pressing anything. Offer no control. */
39
+ | "none";
40
+ /** The copy an arm writes. Everything it leaves out, {@link createErrorDescriber} fills in. */
41
+ export interface ErrorCopy {
21
42
  /** What happened, in the reader's words. */
22
43
  cause: string;
23
44
  /** What to do about it. Its absence should be a decision, not an oversight. */
24
45
  hint?: string;
46
+ /** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
47
+ recover?: RecoveryKind;
48
+ /** Override the request id. Rarely wanted; the envelope's is used by default. */
49
+ reference?: string;
50
+ }
51
+ export interface DescribedError extends ErrorCopy {
52
+ /** Always present on the way out, whether an arm set it or the rule derived it. */
53
+ recover: RecoveryKind;
25
54
  }
26
55
  /**
27
56
  * The subset of i18next's `t` this module needs — typed here so `i18next` stays optional.
@@ -54,6 +83,13 @@ export interface ErrorContext {
54
83
  /** Seconds until it clears, already humanized. Null when the envelope did not say. */
55
84
  wait: string | null;
56
85
  waitSecs: number | null;
86
+ /**
87
+ * What the rule decided before this arm ran. Read it to agree, return your own to override.
88
+ *
89
+ * Exposed so an arm can narrow rather than restate: a quota arm that already knows the limit is
90
+ * durable returns `"none"`, and every other arm can leave it alone and stay right by default.
91
+ */
92
+ recover: RecoveryKind;
57
93
  }
58
94
  export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix extends string, ServerName extends string> {
59
95
  t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
@@ -91,9 +127,17 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
91
127
  * code that does not exist is a compile error at the arm rather than a branch that never runs.
92
128
  */
93
129
  codes?: Partial<Record<string, ErrorArm>>;
130
+ /**
131
+ * The codes that mean "this limit does not clear by waiting" — **the same list you hand
132
+ * `queryDefaults`**, because it answers the same question and a second copy is a second
133
+ * chance to disagree with the retry rule. Declare it once in the app and pass it twice.
134
+ */
135
+ durableLimitCodes?: readonly string[];
136
+ /** Longest stated wait still worth a retry rather than a countdown. Matches `queryDefaults`. */
137
+ maxRetryWaitSecs?: number;
94
138
  }
95
- export type ErrorArm = (ctx: ErrorContext) => DescribedError;
139
+ export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
96
140
  /** Build the describer. */
97
- export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
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;
98
142
  export {};
99
143
  //# 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;AAE1D;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D,iGAAiG;AACjG,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA8BlF;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC;AAE7D,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,GACX,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAwD9F"}
1
+ {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { ApiError, retryAfterSecs } from "./api-error.js";
2
+ import { shouldRetry } from "./query.js";
2
3
  /**
3
4
  * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
4
5
  *
@@ -15,7 +16,7 @@ export function humanizeWait(t, secs, prefix) {
15
16
  return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
16
17
  }
17
18
  /** Build the describer. */
18
- export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes = {}, }) {
19
+ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
19
20
  const known = new Set(Object.keys(knownMessageKeys));
20
21
  // Sound, and the reason nothing here needs a cast: the set was built from the keys of
21
22
  // `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
@@ -34,11 +35,37 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
34
35
  return (lookup(error.messageKey) ??
35
36
  (error.code === undefined ? null : lookup(`${messageKeyPrefix}${error.code}`)));
36
37
  }
38
+ /**
39
+ * Which control to offer, decided once and never switched on separately.
40
+ *
41
+ * Everything below the last line defers to `shouldRetry` — the rule invariant 8 already owns —
42
+ * so the button a reader sees and the retry react-query performs cannot disagree.
43
+ */
44
+ function recoveryFor(error, waitSecs) {
45
+ // The client raised this one itself, to stop a caller while a dead session was already being
46
+ // handled. Nothing is broken and the person is already on their way to sign-in, so asking
47
+ // them to try again would be the surface contradicting the thing it is reporting.
48
+ if (error.expected)
49
+ return "none";
50
+ // 401 is "we do not know who you are", which no number of attempts answers. 403 is NOT this:
51
+ // the session is fine and it is a different account that would help, so it falls through.
52
+ if (error.status === 401)
53
+ return "signin";
54
+ // It named its own expiry, so there is a real number to count down beside a disabled button.
55
+ if (waitSecs !== null)
56
+ return "wait";
57
+ return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
58
+ }
37
59
  return function describeError(error) {
38
60
  if (!(error instanceof ApiError)) {
39
61
  // Not a response at all — the request never landed. Almost always the network, and almost
40
- // never worth showing a stack trace for.
41
- return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
62
+ // never worth showing a stack trace for. `shouldRetry` answers a non-response the same way,
63
+ // so these two agree here without either being told about the other.
64
+ return {
65
+ cause: t(`${copyPrefix}network`),
66
+ hint: t(`${copyPrefix}networkHint`),
67
+ recover: "retry",
68
+ };
42
69
  }
43
70
  const waitSecs = retryAfterSecs(error);
44
71
  const ctx = {
@@ -46,10 +73,8 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
46
73
  says: serverSentence(error),
47
74
  wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
48
75
  waitSecs,
76
+ recover: recoveryFor(error, waitSecs),
49
77
  };
50
- const arm = error.code === undefined ? undefined : codes[error.code];
51
- if (arm)
52
- return arm(ctx);
53
78
  // Anything unmapped: the server's own sentence is still the most specific thing we have,
54
79
  // and support can act on it. A 5xx additionally gets "try again shortly", because that one
55
80
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
@@ -59,10 +84,22 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
59
84
  // own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
60
85
  // restarts is exactly when that happens, so it reached screens — until the second migration,
61
86
  // whose three apps each guarded against that one string by hand.
62
- const serverWords = error.code === undefined ? "" : error.message;
87
+ const arm = error.code === undefined ? undefined : codes[error.code];
88
+ const copy = arm
89
+ ? arm(ctx)
90
+ : {
91
+ cause: ctx.says ??
92
+ ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
93
+ hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
94
+ };
95
+ // The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
96
+ // error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
97
+ // `reference` slot for it since the contract was written.
98
+ const reference = copy.reference ?? error.requestId;
63
99
  return {
64
- cause: ctx.says ?? (serverWords || t(`${copyPrefix}unexpected`)),
65
- hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
100
+ ...copy,
101
+ recover: copy.recover ?? ctx.recover,
102
+ ...(reference !== undefined && { reference }),
66
103
  };
67
104
  };
68
105
  }
@@ -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/dist/i18n.d.ts CHANGED
@@ -35,8 +35,22 @@ export interface I18nInitOptions {
35
35
  * Only one donor had this. The other has the same split-origin layout and the same problem.
36
36
  */
37
37
  queryKey?: string;
38
+ /**
39
+ * Who WRITES {@link storageKey}. The detector, by default.
40
+ *
41
+ * Pass `"app"` when your own language control writes it. That is not a preference — it is what
42
+ * a "follow the browser" option costs. Going back to following the browser means clearing the
43
+ * key and calling `changeLanguage(undefined)`, which re-runs detection; a detector that caches
44
+ * then writes the language it just detected straight back into the key it was told to clear.
45
+ * The choice re-pins itself and nothing can reach that state again.
46
+ *
47
+ * Three repos on this stack, three answers: one lets the detector write, one writes it from its
48
+ * own control, and one does both — harmless there only because it offers no "follow the
49
+ * browser" state. So the rule, rather than the default: one writer per key.
50
+ */
51
+ storageWriter?: "detector" | "app";
38
52
  }
39
- export declare function i18nInitOptions({ fallbackLng, supportedLngs, storageKey, queryKey, }: I18nInitOptions): {
53
+ export declare function i18nInitOptions({ fallbackLng, supportedLngs, storageKey, queryKey, storageWriter, }: I18nInitOptions): {
40
54
  fallbackLng: string;
41
55
  supportedLngs: string[];
42
56
  load: "currentOnly";
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC;;;;;;OAMG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,GACT,EAAE,eAAe;;;;;;;;;;;;;;;;EAuBjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAmB/E"}
1
+ {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC;;;;;;OAMG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;;OAYG;IACH,aAAa,CAAC,EAAE,UAAU,GAAG,KAAK,CAAC;CACpC;AAED,wBAAgB,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,EACR,aAA0B,GAC3B,EAAE,eAAe;;;;;;;;;;;;;;;;EAwBjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAmB/E"}
package/dist/i18n.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * product from drifting apart on the details below — each of which is a bug somebody already
12
12
  * shipped.
13
13
  */
14
- export function i18nInitOptions({ fallbackLng, supportedLngs, storageKey, queryKey, }) {
14
+ export function i18nInitOptions({ fallbackLng, supportedLngs, storageKey, queryKey, storageWriter = "detector", }) {
15
15
  return {
16
16
  fallbackLng,
17
17
  supportedLngs: [...supportedLngs],
@@ -21,7 +21,8 @@ export function i18nInitOptions({ fallbackLng, supportedLngs, storageKey, queryK
21
21
  order: queryKey
22
22
  ? ["querystring", "localStorage", "navigator"]
23
23
  : ["localStorage", "navigator"],
24
- caches: ["localStorage"],
24
+ // Read from always, written to only by whoever owns it — see `storageWriter`.
25
+ caches: storageWriter === "detector" ? ["localStorage"] : [],
25
26
  ...(queryKey ? { lookupQuerystring: queryKey } : {}),
26
27
  lookupLocalStorage: storageKey,
27
28
  },
package/dist/i18n.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA4BH,MAAM,UAAU,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,GACQ;IAChB,OAAO;QACL,WAAW;QACX,aAAa,EAAE,CAAC,GAAG,aAAa,CAAC;QACjC,IAAI,EAAE,aAAsB;QAC5B,aAAa,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE,yBAAyB;QAChE,SAAS,EAAE;YACT,KAAK,EAAE,QAAQ;gBACb,CAAC,CAAC,CAAC,aAAa,EAAE,cAAc,EAAE,WAAW,CAAC;gBAC9C,CAAC,CAAC,CAAC,cAAc,EAAE,WAAW,CAAC;YACjC,MAAM,EAAE,CAAC,cAAc,CAAC;YACxB,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACpD,kBAAkB,EAAE,UAAU;SAC/B;QACD,KAAK,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE;QAC7B,gGAAgG;QAChG,4FAA4F;QAC5F,4FAA4F;QAC5F,sFAAsF;QACtF,wFAAwF;QACxF,2FAA2F;QAC3F,8FAA8F;KAC/F,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,cAAc,CAAI,SAAY,EAAE,IAA4B;IAC1E,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACzC,MAAM,OAAO,GAAG,IAAI,MAAM,CACxB,UAAU,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,EACvF,GAAG,CACJ,CAAC;IAEF,MAAM,IAAI,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAE9F,MAAM,IAAI,GAAG,CAAC,KAAc,EAAW,EAAE;QACvC,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;QAClD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjD,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;YACpC,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACjF,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;IAEF,OAAO,IAAI,CAAC,SAAS,CAAM,CAAC;AAC9B,CAAC"}
1
+ {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA0CH,MAAM,UAAU,eAAe,CAAC,EAC9B,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,EACR,aAAa,GAAG,UAAU,GACV;IAChB,OAAO;QACL,WAAW;QACX,aAAa,EAAE,CAAC,GAAG,aAAa,CAAC;QACjC,IAAI,EAAE,aAAsB;QAC5B,aAAa,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE,yBAAyB;QAChE,SAAS,EAAE;YACT,KAAK,EAAE,QAAQ;gBACb,CAAC,CAAC,CAAC,aAAa,EAAE,cAAc,EAAE,WAAW,CAAC;gBAC9C,CAAC,CAAC,CAAC,cAAc,EAAE,WAAW,CAAC;YACjC,8EAA8E;YAC9E,MAAM,EAAE,aAAa,KAAK,UAAU,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE;YAC5D,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACpD,kBAAkB,EAAE,UAAU;SAC/B;QACD,KAAK,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE;QAC7B,gGAAgG;QAChG,4FAA4F;QAC5F,4FAA4F;QAC5F,sFAAsF;QACtF,wFAAwF;QACxF,2FAA2F;QAC3F,8FAA8F;KAC/F,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,cAAc,CAAI,SAAY,EAAE,IAA4B;IAC1E,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACzC,MAAM,OAAO,GAAG,IAAI,MAAM,CACxB,UAAU,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,EACvF,GAAG,CACJ,CAAC;IAEF,MAAM,IAAI,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAE9F,MAAM,IAAI,GAAG,CAAC,KAAc,EAAW,EAAE;QACvC,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;QAClD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjD,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;YACpC,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACjF,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;IAEF,OAAO,IAAI,CAAC,SAAS,CAAM,CAAC;AAC9B,CAAC"}
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshRes
4
4
  export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
5
5
  export { queryDefaults, retryDelayMs, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
6
6
  export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps, } from "./error-boundary.tsx";
7
- export { createErrorDescriber, humanizeWait, type DescribedError, type ErrorArm, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
7
+ export { createErrorDescriber, humanizeWait, type DescribedError, type ErrorArm, type ErrorContext, type ErrorCopy, type ErrorDescriberOptions, type RecoveryKind, type Translate, } from "./describe-error.ts";
8
8
  export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, isStaleBuild, RELOAD_GUARD_KEY, reloadOnce, } from "./deploy-recovery.ts";
9
9
  export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
10
10
  export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAMb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { ApiError } from "./api-error.ts";
3
3
  import { createErrorDescriber, humanizeWait } from "./describe-error.ts";
4
+ import { shouldRetry } from "./query.ts";
4
5
 
5
6
  /** A translator that echoes the key, so a test can see exactly which one was asked for. */
6
7
  const t = (key: string, params?: Record<string, unknown>) =>
@@ -94,6 +95,86 @@ describe("createErrorDescriber", () => {
94
95
  });
95
96
  });
96
97
 
98
+ /**
99
+ * The control to offer beside the words. The fourth adopter had this and the package did not, so
100
+ * every adopter re-derived "can a second attempt fix this" from its own switch — beside a
101
+ * `shouldRetry` already answering exactly that for react-query.
102
+ */
103
+ describe("the recovery kind", () => {
104
+ const describeError = createErrorDescriber({
105
+ t,
106
+ copyPrefix: "errors.",
107
+ messageKeyPrefix: "serverErrors.",
108
+ knownMessageKeys: { quotaDay: "" },
109
+ durableLimitCodes: ["QUOTA_EXCEEDED"],
110
+ });
111
+
112
+ it("sends a 401 to sign-in and leaves a 403 alone", () => {
113
+ expect(describeError(new ApiError(401, null)).recover).toBe("signin");
114
+ // 403 is not a dead session: the session is fine and a different account is the fix, so
115
+ // signing someone out would be the wrong cause AND the wrong remedy.
116
+ expect(describeError(new ApiError(403, null)).recover).toBe("none");
117
+ });
118
+
119
+ it("offers a retry for a request that never landed", () => {
120
+ expect(describeError(new TypeError("Failed to fetch")).recover).toBe("retry");
121
+ });
122
+
123
+ it("counts down when the refusal stated its own expiry", () => {
124
+ const error = new ApiError(
125
+ 429,
126
+ { code: "RATE_LIMIT_EXCEEDED", message: "slow down" },
127
+ { retryAfterSecs: 30 },
128
+ );
129
+ expect(describeError(error).recover).toBe("wait");
130
+ });
131
+
132
+ it("offers no control for a limit that buying clears rather than waiting", () => {
133
+ // The same 429 as above. Only the CODE separates a burst limit from a spent quota, which is
134
+ // why the caller names them — from the same list it hands `queryDefaults`.
135
+ const spent = new ApiError(429, { code: "QUOTA_EXCEEDED", message: "spent" });
136
+ expect(describeError(spent).recover).toBe("none");
137
+ });
138
+
139
+ it("never asks anyone to retry the 401 the client raised itself", () => {
140
+ // `expected` is the sign-out path: nothing is broken and the person is already on their way
141
+ // to the door. A retry button here is the surface contradicting what it reports.
142
+ expect(describeError(new ApiError(401, null, { expected: true })).recover).toBe("none");
143
+ });
144
+
145
+ it("agrees with the retry rule instead of deciding the same thing twice", () => {
146
+ // The whole reason this derives rather than switches: if the two ever disagree, a reader
147
+ // presses a button the query layer has already refused to honour.
148
+ const error = new ApiError(503, null);
149
+ expect(shouldRetry(error)).toBe(true);
150
+ expect(describeError(error).recover).toBe("retry");
151
+ });
152
+
153
+ it("carries the request id the error contract reserves a slot for", () => {
154
+ const error = new ApiError(500, null, { requestId: "req_123" });
155
+ expect(describeError(error).reference).toBe("req_123");
156
+ });
157
+
158
+ it("lets an arm narrow the derived kind without restating it", () => {
159
+ const describe503 = createErrorDescriber({
160
+ t,
161
+ copyPrefix: "errors.",
162
+ messageKeyPrefix: "serverErrors.",
163
+ knownMessageKeys: {},
164
+ codes: {
165
+ MAINTENANCE: (ctx) => {
166
+ // The arm reads what the rule decided, so an arm that agrees can stay silent.
167
+ expect(ctx.recover).toBe("retry");
168
+ return { cause: "errors.maintenance", recover: "none" };
169
+ },
170
+ },
171
+ });
172
+ const out = describe503(new ApiError(503, { code: "MAINTENANCE", message: "back soon" }));
173
+ expect(out.recover).toBe("none");
174
+ expect(out.cause).toBe("errors.maintenance");
175
+ });
176
+ });
177
+
97
178
  /**
98
179
  * The second convention in the fleet: an API with no `messageKey` field at all, whose CODE
99
180
  * names the sentence. One repo has 98 of them — `errors.FORECAST_NOT_FOUND` and its siblings —
@@ -1,4 +1,5 @@
1
1
  import { ApiError, retryAfterSecs } from "./api-error.ts";
2
+ import { shouldRetry } from "./query.ts";
2
3
 
3
4
  /**
4
5
  * Turning anything thrown into copy a person can act on.
@@ -19,11 +20,42 @@ import { ApiError, retryAfterSecs } from "./api-error.ts";
19
20
  * which is the part that was written twice and got subtly different both times.
20
21
  */
21
22
 
22
- export interface DescribedError {
23
+ /**
24
+ * Which control to offer beside the words.
25
+ *
26
+ * The fourth adopter had this and the package did not, which left every adopter re-deriving
27
+ * "can a second attempt fix this" from its own switch — beside a `shouldRetry` already deciding
28
+ * exactly that for react-query. Two answers to one question drift, and the drift is visible: a
29
+ * screen offering "try again" for a refusal the query layer refuses to retry, so the button does
30
+ * nothing and the reader presses it twice.
31
+ *
32
+ * So this is DERIVED from `shouldRetry`, not switched on separately. One rule, two consumers.
33
+ */
34
+ export type RecoveryKind =
35
+ /** A second attempt can work. Offer the button. */
36
+ | "retry"
37
+ /** Auth said no. Offer a way back to sign-in, never a retry. */
38
+ | "signin"
39
+ /** It stated its own expiry. Offer the button, disabled, with the countdown. */
40
+ | "wait"
41
+ /** Waiting cannot fix it and neither can pressing anything. Offer no control. */
42
+ | "none";
43
+
44
+ /** The copy an arm writes. Everything it leaves out, {@link createErrorDescriber} fills in. */
45
+ export interface ErrorCopy {
23
46
  /** What happened, in the reader's words. */
24
47
  cause: string;
25
48
  /** What to do about it. Its absence should be a decision, not an oversight. */
26
49
  hint?: string;
50
+ /** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
51
+ recover?: RecoveryKind;
52
+ /** Override the request id. Rarely wanted; the envelope's is used by default. */
53
+ reference?: string;
54
+ }
55
+
56
+ export interface DescribedError extends ErrorCopy {
57
+ /** Always present on the way out, whether an arm set it or the rule derived it. */
58
+ recover: RecoveryKind;
27
59
  }
28
60
 
29
61
  /**
@@ -100,6 +132,13 @@ export interface ErrorContext {
100
132
  /** Seconds until it clears, already humanized. Null when the envelope did not say. */
101
133
  wait: string | null;
102
134
  waitSecs: number | null;
135
+ /**
136
+ * What the rule decided before this arm ran. Read it to agree, return your own to override.
137
+ *
138
+ * Exposed so an arm can narrow rather than restate: a quota arm that already knows the limit is
139
+ * durable returns `"none"`, and every other arm can leave it alone and stay right by default.
140
+ */
141
+ recover: RecoveryKind;
103
142
  }
104
143
 
105
144
  export interface ErrorDescriberOptions<
@@ -142,9 +181,17 @@ export interface ErrorDescriberOptions<
142
181
  * code that does not exist is a compile error at the arm rather than a branch that never runs.
143
182
  */
144
183
  codes?: Partial<Record<string, ErrorArm>>;
184
+ /**
185
+ * The codes that mean "this limit does not clear by waiting" — **the same list you hand
186
+ * `queryDefaults`**, because it answers the same question and a second copy is a second
187
+ * chance to disagree with the retry rule. Declare it once in the app and pass it twice.
188
+ */
189
+ durableLimitCodes?: readonly string[];
190
+ /** Longest stated wait still worth a retry rather than a countdown. Matches `queryDefaults`. */
191
+ maxRetryWaitSecs?: number;
145
192
  }
146
193
 
147
- export type ErrorArm = (ctx: ErrorContext) => DescribedError;
194
+ export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
148
195
 
149
196
  /** Build the describer. */
150
197
  export function createErrorDescriber<
@@ -157,6 +204,8 @@ export function createErrorDescriber<
157
204
  messageKeyPrefix,
158
205
  knownMessageKeys,
159
206
  codes = {},
207
+ durableLimitCodes = [],
208
+ maxRetryWaitSecs,
160
209
  }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError {
161
210
  const known = new Set<string>(Object.keys(knownMessageKeys));
162
211
 
@@ -180,11 +229,35 @@ export function createErrorDescriber<
180
229
  );
181
230
  }
182
231
 
232
+ /**
233
+ * Which control to offer, decided once and never switched on separately.
234
+ *
235
+ * Everything below the last line defers to `shouldRetry` — the rule invariant 8 already owns —
236
+ * so the button a reader sees and the retry react-query performs cannot disagree.
237
+ */
238
+ function recoveryFor(error: ApiError, waitSecs: number | null): RecoveryKind {
239
+ // The client raised this one itself, to stop a caller while a dead session was already being
240
+ // handled. Nothing is broken and the person is already on their way to sign-in, so asking
241
+ // them to try again would be the surface contradicting the thing it is reporting.
242
+ if (error.expected) return "none";
243
+ // 401 is "we do not know who you are", which no number of attempts answers. 403 is NOT this:
244
+ // the session is fine and it is a different account that would help, so it falls through.
245
+ if (error.status === 401) return "signin";
246
+ // It named its own expiry, so there is a real number to count down beside a disabled button.
247
+ if (waitSecs !== null) return "wait";
248
+ return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
249
+ }
250
+
183
251
  return function describeError(error: unknown): DescribedError {
184
252
  if (!(error instanceof ApiError)) {
185
253
  // Not a response at all — the request never landed. Almost always the network, and almost
186
- // never worth showing a stack trace for.
187
- return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
254
+ // never worth showing a stack trace for. `shouldRetry` answers a non-response the same way,
255
+ // so these two agree here without either being told about the other.
256
+ return {
257
+ cause: t(`${copyPrefix}network`),
258
+ hint: t(`${copyPrefix}networkHint`),
259
+ recover: "retry",
260
+ };
188
261
  }
189
262
 
190
263
  const waitSecs = retryAfterSecs(error);
@@ -193,11 +266,9 @@ export function createErrorDescriber<
193
266
  says: serverSentence(error),
194
267
  wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
195
268
  waitSecs,
269
+ recover: recoveryFor(error, waitSecs),
196
270
  };
197
271
 
198
- const arm = error.code === undefined ? undefined : codes[error.code];
199
- if (arm) return arm(ctx);
200
-
201
272
  // Anything unmapped: the server's own sentence is still the most specific thing we have,
202
273
  // and support can act on it. A 5xx additionally gets "try again shortly", because that one
203
274
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
@@ -207,10 +278,24 @@ export function createErrorDescriber<
207
278
  // own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
208
279
  // restarts is exactly when that happens, so it reached screens — until the second migration,
209
280
  // whose three apps each guarded against that one string by hand.
210
- const serverWords = error.code === undefined ? "" : error.message;
281
+ const arm = error.code === undefined ? undefined : codes[error.code];
282
+ const copy: ErrorCopy = arm
283
+ ? arm(ctx)
284
+ : {
285
+ cause:
286
+ ctx.says ??
287
+ ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
288
+ hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
289
+ };
290
+
291
+ // The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
292
+ // error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
293
+ // `reference` slot for it since the contract was written.
294
+ const reference = copy.reference ?? error.requestId;
211
295
  return {
212
- cause: ctx.says ?? (serverWords || t(`${copyPrefix}unexpected`)),
213
- hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
296
+ ...copy,
297
+ recover: copy.recover ?? ctx.recover,
298
+ ...(reference !== undefined && { reference }),
214
299
  };
215
300
  };
216
301
  }
package/src/i18n.test.ts CHANGED
@@ -83,4 +83,19 @@ describe("i18nInitOptions", () => {
83
83
  expect(detection.order).toEqual(["localStorage", "navigator"]);
84
84
  expect(detection).not.toHaveProperty("lookupQuerystring");
85
85
  });
86
+
87
+ // An app with a "follow the browser" option has to own the key. Getting back to that state
88
+ // means clearing it and re-running detection — and a caching detector writes the language it
89
+ // just detected back into the key it was told to clear, so the choice re-pins itself and the
90
+ // option can never be reached again. The fourth adopter found this; the package had the write
91
+ // hardcoded on.
92
+ it("leaves the key alone when the app is the one writing it", () => {
93
+ const base = { fallbackLng: "en", supportedLngs: ["en"], storageKey: "app.locale" } as const;
94
+ const app = i18nInitOptions({ ...base, storageWriter: "app" });
95
+
96
+ expect(app.detection.caches).toEqual([]);
97
+ // Still READ: the app writes the key, the detector is what notices it.
98
+ expect(app.detection.lookupLocalStorage).toBe("app.locale");
99
+ expect(i18nInitOptions(base).detection.caches).toEqual(["localStorage"]);
100
+ });
86
101
  });
package/src/i18n.ts CHANGED
@@ -36,6 +36,20 @@ export interface I18nInitOptions {
36
36
  * Only one donor had this. The other has the same split-origin layout and the same problem.
37
37
  */
38
38
  queryKey?: string;
39
+ /**
40
+ * Who WRITES {@link storageKey}. The detector, by default.
41
+ *
42
+ * Pass `"app"` when your own language control writes it. That is not a preference — it is what
43
+ * a "follow the browser" option costs. Going back to following the browser means clearing the
44
+ * key and calling `changeLanguage(undefined)`, which re-runs detection; a detector that caches
45
+ * then writes the language it just detected straight back into the key it was told to clear.
46
+ * The choice re-pins itself and nothing can reach that state again.
47
+ *
48
+ * Three repos on this stack, three answers: one lets the detector write, one writes it from its
49
+ * own control, and one does both — harmless there only because it offers no "follow the
50
+ * browser" state. So the rule, rather than the default: one writer per key.
51
+ */
52
+ storageWriter?: "detector" | "app";
39
53
  }
40
54
 
41
55
  export function i18nInitOptions({
@@ -43,6 +57,7 @@ export function i18nInitOptions({
43
57
  supportedLngs,
44
58
  storageKey,
45
59
  queryKey,
60
+ storageWriter = "detector",
46
61
  }: I18nInitOptions) {
47
62
  return {
48
63
  fallbackLng,
@@ -53,7 +68,8 @@ export function i18nInitOptions({
53
68
  order: queryKey
54
69
  ? ["querystring", "localStorage", "navigator"]
55
70
  : ["localStorage", "navigator"],
56
- caches: ["localStorage"],
71
+ // Read from always, written to only by whoever owns it — see `storageWriter`.
72
+ caches: storageWriter === "detector" ? ["localStorage"] : [],
57
73
  ...(queryKey ? { lookupQuerystring: queryKey } : {}),
58
74
  lookupLocalStorage: storageKey,
59
75
  },
package/src/index.ts CHANGED
@@ -53,7 +53,9 @@ export {
53
53
  type DescribedError,
54
54
  type ErrorArm,
55
55
  type ErrorContext,
56
+ type ErrorCopy,
56
57
  type ErrorDescriberOptions,
58
+ type RecoveryKind,
57
59
  type Translate,
58
60
  } from "./describe-error.ts";
59
61