@gusnips/react 0.2.0 → 0.4.0

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