@gusnips/react 0.1.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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/dist/api-client.d.ts +139 -0
  4. package/dist/api-client.d.ts.map +1 -0
  5. package/dist/api-client.js +133 -0
  6. package/dist/api-client.js.map +1 -0
  7. package/dist/api-error.d.ts +45 -0
  8. package/dist/api-error.d.ts.map +1 -0
  9. package/dist/api-error.js +52 -0
  10. package/dist/api-error.js.map +1 -0
  11. package/dist/auth-store.d.ts +49 -0
  12. package/dist/auth-store.d.ts.map +1 -0
  13. package/dist/auth-store.js +27 -0
  14. package/dist/auth-store.js.map +1 -0
  15. package/dist/chunk-reload.d.ts +61 -0
  16. package/dist/chunk-reload.d.ts.map +1 -0
  17. package/dist/chunk-reload.js +92 -0
  18. package/dist/chunk-reload.js.map +1 -0
  19. package/dist/cn.d.ts +13 -0
  20. package/dist/cn.d.ts.map +1 -0
  21. package/dist/cn.js +16 -0
  22. package/dist/cn.js.map +1 -0
  23. package/dist/describe-error.d.ts +74 -0
  24. package/dist/describe-error.d.ts.map +1 -0
  25. package/dist/describe-error.js +70 -0
  26. package/dist/describe-error.js.map +1 -0
  27. package/dist/error-boundary.d.ts +61 -0
  28. package/dist/error-boundary.d.ts.map +1 -0
  29. package/dist/error-boundary.js +32 -0
  30. package/dist/error-boundary.js.map +1 -0
  31. package/dist/guards.d.ts +65 -0
  32. package/dist/guards.d.ts.map +1 -0
  33. package/dist/guards.js +52 -0
  34. package/dist/guards.js.map +1 -0
  35. package/dist/hydrate.d.ts +30 -0
  36. package/dist/hydrate.d.ts.map +1 -0
  37. package/dist/hydrate.js +25 -0
  38. package/dist/hydrate.js.map +1 -0
  39. package/dist/i18n.d.ts +70 -0
  40. package/dist/i18n.d.ts.map +1 -0
  41. package/dist/i18n.js +66 -0
  42. package/dist/i18n.js.map +1 -0
  43. package/dist/index.d.ts +12 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +31 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/prerender-contract.d.ts +30 -0
  48. package/dist/prerender-contract.d.ts.map +1 -0
  49. package/dist/prerender-contract.js +30 -0
  50. package/dist/prerender-contract.js.map +1 -0
  51. package/dist/query.d.ts +28 -0
  52. package/dist/query.d.ts.map +1 -0
  53. package/dist/query.js +51 -0
  54. package/dist/query.js.map +1 -0
  55. package/dist/sse.d.ts +64 -0
  56. package/dist/sse.d.ts.map +1 -0
  57. package/dist/sse.js +105 -0
  58. package/dist/sse.js.map +1 -0
  59. package/dist/states.d.ts +69 -0
  60. package/dist/states.d.ts.map +1 -0
  61. package/dist/states.js +2 -0
  62. package/dist/states.js.map +1 -0
  63. package/dist/ui/class-name.d.ts +16 -0
  64. package/dist/ui/class-name.d.ts.map +1 -0
  65. package/dist/ui/class-name.js +13 -0
  66. package/dist/ui/class-name.js.map +1 -0
  67. package/dist/ui/combobox.d.ts +81 -0
  68. package/dist/ui/combobox.d.ts.map +1 -0
  69. package/dist/ui/combobox.js +70 -0
  70. package/dist/ui/combobox.js.map +1 -0
  71. package/dist/ui/dialog.d.ts +39 -0
  72. package/dist/ui/dialog.d.ts.map +1 -0
  73. package/dist/ui/dialog.js +32 -0
  74. package/dist/ui/dialog.js.map +1 -0
  75. package/dist/ui/drawer.d.ts +63 -0
  76. package/dist/ui/drawer.d.ts.map +1 -0
  77. package/dist/ui/drawer.js +55 -0
  78. package/dist/ui/drawer.js.map +1 -0
  79. package/dist/ui/glyphs.d.ts +4 -0
  80. package/dist/ui/glyphs.d.ts.map +1 -0
  81. package/dist/ui/glyphs.js +20 -0
  82. package/dist/ui/glyphs.js.map +1 -0
  83. package/dist/ui/index.d.ts +9 -0
  84. package/dist/ui/index.d.ts.map +1 -0
  85. package/dist/ui/index.js +48 -0
  86. package/dist/ui/index.js.map +1 -0
  87. package/dist/ui/input.d.ts +45 -0
  88. package/dist/ui/input.d.ts.map +1 -0
  89. package/dist/ui/input.js +108 -0
  90. package/dist/ui/input.js.map +1 -0
  91. package/dist/ui/menu.d.ts +70 -0
  92. package/dist/ui/menu.d.ts.map +1 -0
  93. package/dist/ui/menu.js +85 -0
  94. package/dist/ui/menu.js.map +1 -0
  95. package/dist/ui/select.d.ts +69 -0
  96. package/dist/ui/select.d.ts.map +1 -0
  97. package/dist/ui/select.js +48 -0
  98. package/dist/ui/select.js.map +1 -0
  99. package/dist/ui/tabs.d.ts +52 -0
  100. package/dist/ui/tabs.d.ts.map +1 -0
  101. package/dist/ui/tabs.js +62 -0
  102. package/dist/ui/tabs.js.map +1 -0
  103. package/package.json +124 -0
  104. package/src/api-client.test.ts +256 -0
  105. package/src/api-client.ts +305 -0
  106. package/src/api-error.ts +60 -0
  107. package/src/auth-store.ts +59 -0
  108. package/src/chunk-reload.test.ts +46 -0
  109. package/src/chunk-reload.ts +94 -0
  110. package/src/cn.ts +16 -0
  111. package/src/describe-error.test.ts +102 -0
  112. package/src/describe-error.ts +140 -0
  113. package/src/error-boundary.tsx +88 -0
  114. package/src/guards.tsx +99 -0
  115. package/src/hydrate.ts +36 -0
  116. package/src/i18n.test.ts +68 -0
  117. package/src/i18n.ts +101 -0
  118. package/src/index.ts +63 -0
  119. package/src/prerender-contract.ts +31 -0
  120. package/src/query.test.ts +54 -0
  121. package/src/query.ts +79 -0
  122. package/src/sse.test.ts +50 -0
  123. package/src/sse.ts +117 -0
  124. package/src/states.ts +71 -0
  125. package/src/ui/class-name.ts +23 -0
  126. package/src/ui/combobox.test.ts +32 -0
  127. package/src/ui/combobox.tsx +242 -0
  128. package/src/ui/dialog.tsx +87 -0
  129. package/src/ui/drawer.tsx +161 -0
  130. package/src/ui/focus.test.ts +82 -0
  131. package/src/ui/glyphs.tsx +35 -0
  132. package/src/ui/index.ts +145 -0
  133. package/src/ui/input.test.ts +22 -0
  134. package/src/ui/input.tsx +181 -0
  135. package/src/ui/menu.test.ts +73 -0
  136. package/src/ui/menu.tsx +186 -0
  137. package/src/ui/select.tsx +191 -0
  138. package/src/ui/tabs.tsx +95 -0
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ApiError } from "./api-error.ts";
3
+ import { createErrorDescriber, humanizeWait, retryAfterSecs } from "./describe-error.ts";
4
+
5
+ /** A translator that echoes the key, so a test can see exactly which one was asked for. */
6
+ const t = (key: string, params?: Record<string, unknown>) =>
7
+ params && Object.keys(params).length > 0 ? `${key}(${JSON.stringify(params)})` : key;
8
+
9
+ describe("humanizeWait", () => {
10
+ // The thresholds are 90s and 90min, not 60 and 60. "in 75 seconds" must not round down to
11
+ // the vaguer "in 1 minute" — the whole job of this function is to be more useful than the
12
+ // raw number, and a lossy rounding at the boundary is less useful.
13
+ it("keeps the more precise unit past the round boundary", () => {
14
+ expect(humanizeWait(t, 75, "errors.")).toBe('errors.waitSeconds({"count":75})');
15
+ expect(humanizeWait(t, 95, "errors.")).toBe('errors.waitMinutes({"count":2})');
16
+ expect(humanizeWait(t, 80 * 60, "errors.")).toBe('errors.waitMinutes({"count":80})');
17
+ expect(humanizeWait(t, 2 * 3600, "errors.")).toBe('errors.waitHours({"count":2})');
18
+ });
19
+
20
+ it("never says 'in 0 seconds'", () => {
21
+ expect(humanizeWait(t, 0.2, "errors.")).toBe('errors.waitSeconds({"count":1})');
22
+ });
23
+ });
24
+
25
+ describe("retryAfterSecs", () => {
26
+ it("reads the number out of details, and refuses anything else", () => {
27
+ expect(
28
+ retryAfterSecs(
29
+ new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: 30 } }),
30
+ ),
31
+ ).toBe(30);
32
+ expect(
33
+ retryAfterSecs(
34
+ new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: "30" } }),
35
+ ),
36
+ ).toBeNull();
37
+ expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
38
+ expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe("createErrorDescriber", () => {
43
+ const describeError = createErrorDescriber({
44
+ t,
45
+ knownMessageKeys: ["quotaDay", "suspended"],
46
+ codes: {
47
+ QUOTA_EXCEEDED: ({ says, wait }) => ({
48
+ cause: says ?? "errors.quota",
49
+ hint: wait ? `errors.retryIn(${wait})` : undefined,
50
+ }),
51
+ },
52
+ });
53
+
54
+ it("reads a thrown non-response as the network, with a way forward", () => {
55
+ const { cause, hint } = describeError(new TypeError("Failed to fetch"));
56
+ expect(cause).toBe("errors.network");
57
+ // The hint is the point. A cause with no hint is the dead end this module exists to stop.
58
+ expect(hint).toBe("errors.networkHint");
59
+ });
60
+
61
+ it("resolves a messageKey this build carries, with its params", () => {
62
+ const error = new ApiError(429, {
63
+ code: "QUOTA_EXCEEDED",
64
+ message: "Daily cap reached",
65
+ messageKey: "serverErrors.quotaDay",
66
+ params: { limit: 100 },
67
+ details: { retryAfterSecs: 3600 },
68
+ });
69
+ expect(describeError(error).cause).toBe('serverErrors.quotaDay({"limit":100})');
70
+ });
71
+
72
+ // A deploy can land ahead of the bundle a tab is still running, so the server names a
73
+ // sentence this build has never heard of. Falling back to the server's English beats
74
+ // rendering the raw key at somebody.
75
+ it("falls back to the server's English for a key this build does not have", () => {
76
+ const error = new ApiError(429, {
77
+ code: "QUOTA_EXCEEDED",
78
+ message: "Daily cap reached",
79
+ messageKey: "serverErrors.shippedLastTuesday",
80
+ });
81
+ expect(describeError(error).cause).toBe("errors.quota");
82
+ });
83
+
84
+ // The server names a SENTENCE, not any key in the app. A messageKey outside the namespace
85
+ // is ignored rather than resolved — otherwise a compromised or careless server could point
86
+ // the client at arbitrary copy.
87
+ it("ignores a messageKey outside the server namespace", () => {
88
+ const error = new ApiError(500, {
89
+ code: "UNKNOWN",
90
+ message: "boom",
91
+ messageKey: "billing.upgradeNow",
92
+ });
93
+ expect(describeError(error).cause).toBe("boom");
94
+ });
95
+
96
+ it("offers a retry on a 5xx and withholds it on a 4xx", () => {
97
+ // A 5xx genuinely clears on its own. A 4xx does not, and saying so would cost the reader
98
+ // another attempt for nothing.
99
+ expect(describeError(new ApiError(503, null)).hint).toBe("errors.retrySoon");
100
+ expect(describeError(new ApiError(403, null)).hint).toBeUndefined();
101
+ });
102
+ });
@@ -0,0 +1,140 @@
1
+ import { ApiError } from "./api-error.ts";
2
+
3
+ /**
4
+ * Turning anything thrown into copy a person can act on.
5
+ *
6
+ * The API already answers with a message written for a developer, a stable `messageKey` for the
7
+ * domain refusals, and `details` carrying the parts that make a refusal actionable. This is the
8
+ * one place that decides what the SCREEN says — which is not always the same sentence.
9
+ * "Monthly quota spent, upgrade or buy a pack" is right in a JSON body and wrong under a button
10
+ * that could just say what to do next.
11
+ *
12
+ * `cause` is what happened. `hint` is what to do about it. **A surface that shows only the first
13
+ * half is the dead end this exists to prevent** — which is why the type has two fields and not
14
+ * one, and why `hint` being absent should be a deliberate choice at each call site rather than
15
+ * the default nobody noticed.
16
+ *
17
+ * The per-code switch stays in the product: two donors' switches shared their SHAPE and almost
18
+ * no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
19
+ * which is the part that was written twice and got subtly different both times.
20
+ */
21
+
22
+ export interface DescribedError {
23
+ /** What happened, in the reader's words. */
24
+ cause: string;
25
+ /** What to do about it. Its absence should be a decision, not an oversight. */
26
+ hint?: string;
27
+ }
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;
31
+
32
+ /** Seconds until a refusal clears, when the envelope carries them. */
33
+ export function retryAfterSecs(error: ApiError): number | null {
34
+ const details = error.details;
35
+ if (typeof details !== "object" || details === null) return null;
36
+ const value = (details as { retryAfterSecs?: unknown }).retryAfterSecs;
37
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
38
+ }
39
+
40
+ /**
41
+ * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
42
+ *
43
+ * The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
44
+ * 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
46
+ * `count` plural.
47
+ */
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) });
53
+ }
54
+
55
+ export interface ErrorDescriberOptions<Code extends string> {
56
+ t: Translate;
57
+ /**
58
+ * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
59
+ * ignored — the server names a sentence, it does not get to name any key in the app.
60
+ */
61
+ messageKeyPrefix?: string;
62
+ /**
63
+ * The keys this build actually carries, usually `Object.keys(en.serverErrors)`.
64
+ *
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.
69
+ */
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>>;
75
+ }
76
+
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
+ }
86
+
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>({
92
+ t,
93
+ messageKeyPrefix = "serverErrors.",
94
+ knownMessageKeys,
95
+ copyPrefix = "errors.",
96
+ codes = {},
97
+ }: ErrorDescriberOptions<Code>): (error: unknown) => DescribedError {
98
+ const known = Array.isArray(knownMessageKeys)
99
+ ? new Set<string>(knownMessageKeys)
100
+ : new Set(Object.keys(knownMessageKeys ?? {}));
101
+
102
+ const has = (name: string): boolean => known.size === 0 || known.has(name);
103
+ const key = (name: string): string => `${copyPrefix}${name}`;
104
+
105
+ 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;
110
+ }
111
+
112
+ return function describeError(error: unknown): DescribedError {
113
+ if (!(error instanceof ApiError)) {
114
+ // Not a response at all — the request never landed. Almost always the network, and almost
115
+ // never worth showing a stack trace for.
116
+ return { cause: t(key("network")), hint: t(key("networkHint")) };
117
+ }
118
+
119
+ const waitSecs = retryAfterSecs(error);
120
+ const ctx: ErrorContext = {
121
+ error,
122
+ says: serverSentence(error),
123
+ wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
124
+ waitSecs,
125
+ t,
126
+ };
127
+
128
+ const arm = error.code === undefined ? undefined : codes[error.code as Code];
129
+ if (arm) return arm(ctx);
130
+
131
+ // Anything unmapped: the server's own sentence is still the most specific thing we have,
132
+ // and support can act on it. A 5xx additionally gets "try again shortly", because that one
133
+ // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
134
+ // the reader another attempt.
135
+ return {
136
+ cause: ctx.says ?? (error.message || t(key("unexpected"))),
137
+ hint: error.status >= 500 ? t(key("retrySoon")) : undefined,
138
+ };
139
+ };
140
+ }
@@ -0,0 +1,88 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from "react";
2
+
3
+ /**
4
+ * The last stop before a white screen.
5
+ *
6
+ * React unmounts the whole tree when a render throws, and an app with no boundary anywhere
7
+ * answers that with a blank page: no words, no navigation, no reload — the most complete dead
8
+ * end a product can produce, and the one the never-dead-end rule cannot reach, because by then
9
+ * there is no component left to render a state from.
10
+ *
11
+ * It is deliberately dumb. No retry loop, no error reporting, no reset on a timer: it catches,
12
+ * it hands the error to a `fallback` the app draws in its own words, and it offers `reset`.
13
+ * Every donor that baked its own UI in here had to keep a second copy for its second surface;
14
+ * a render prop has no such problem, and the brand stays in the product where it belongs.
15
+ *
16
+ * A class, because `getDerivedStateFromError` has no hook equivalent — this is the one thing in
17
+ * the kit React still has no other way to express.
18
+ */
19
+
20
+ export interface ErrorBoundaryFallbackProps {
21
+ error: Error;
22
+ /** Put the children back. */
23
+ reset: () => void;
24
+ /**
25
+ * True when the throw was a failed lazy-chunk import — a deploy landed while this tab was
26
+ * open, so the fix is a reload and NOT a retry (a rejected dynamic import rethrows on
27
+ * re-render forever). Draw the "updating" screen, not the crash screen.
28
+ */
29
+ isChunkError: boolean;
30
+ }
31
+
32
+ export interface ErrorBoundaryProps {
33
+ /** Drawn instead of the children once something has thrown. */
34
+ fallback: (props: ErrorBoundaryFallbackProps) => ReactNode;
35
+ /**
36
+ * Changing this value clears the error and remounts the children — pass the route, so
37
+ * navigating away from a screen that broke actually leaves it rather than carrying its
38
+ * wreckage to every page after it.
39
+ */
40
+ resetKey?: string;
41
+ /** Called once per catch. Report to whatever the app reports to. */
42
+ onError?: (error: Error, info: ErrorInfo) => void;
43
+ /**
44
+ * Classify a chunk-load failure. Pass `isChunkLoadError` from this package; it is a
45
+ * parameter rather than a hard import so an app that does not lazy-load pays nothing.
46
+ */
47
+ isChunkError?: (error: unknown) => boolean;
48
+ children: ReactNode;
49
+ }
50
+
51
+ interface State {
52
+ error: Error | null;
53
+ /** Which `resetKey` the current error belongs to. */
54
+ key: string | undefined;
55
+ }
56
+
57
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
58
+ override state: State = { error: null, key: undefined };
59
+
60
+ static getDerivedStateFromError(error: Error): Pick<State, "error"> {
61
+ return { error };
62
+ }
63
+
64
+ static getDerivedStateFromProps(props: ErrorBoundaryProps, state: State): State | null {
65
+ if (state.error === null) return { error: null, key: props.resetKey };
66
+ // The error belongs to the screen it happened on. Somewhere else is a fresh start, and
67
+ // staying broken there would strand somebody who already did the sensible thing and
68
+ // navigated away.
69
+ return state.key === props.resetKey ? null : { error: null, key: props.resetKey };
70
+ }
71
+
72
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
73
+ this.props.onError?.(error, info);
74
+ if (!this.props.onError) console.error("Unhandled render error", error, info.componentStack);
75
+ }
76
+
77
+ private readonly reset = (): void => this.setState({ error: null, key: undefined });
78
+
79
+ override render(): ReactNode {
80
+ const { error } = this.state;
81
+ if (error === null) return this.props.children;
82
+ return this.props.fallback({
83
+ error,
84
+ reset: this.reset,
85
+ isChunkError: this.props.isChunkError?.(error) ?? false,
86
+ });
87
+ }
88
+ }
package/src/guards.tsx ADDED
@@ -0,0 +1,99 @@
1
+ import type { ReactNode } from "react";
2
+ import { Navigate, useLocation } from "react-router-dom";
3
+
4
+ /**
5
+ * Route guards.
6
+ *
7
+ * Two donors wrote these four, gave two of them the same names, and explained them in nearly
8
+ * the same words. What differed was one line — and that line is invariant 4, so it is the
9
+ * reason this file exists rather than being copied a ninth time.
10
+ *
11
+ * These are factories because the destinations are the app's: `paths.signIn` is not something a
12
+ * package can know, and one donor hardcoded `/login` in four places for exactly that reason.
13
+ *
14
+ * A guard is a COURTESY, never the boundary. The API refuses what it refuses whatever any of
15
+ * these decide. What they buy is that somebody who types a URL lands on one screen that
16
+ * explains itself instead of four panels each failing separately.
17
+ */
18
+
19
+ /** What the guard needs off the session store. */
20
+ export interface SessionState {
21
+ isAuthenticated: boolean;
22
+ isLoading: boolean;
23
+ }
24
+
25
+ /**
26
+ * The answer to a "who is this?" query, in the three states it actually has.
27
+ *
28
+ * This shape is invariant 4. A guard reading `!me?.isStaff` collapses "loading", "failed" and
29
+ * "no" into one branch — so an operator arriving while `/auth/me` is 500ing is told the page
30
+ * does not exist. Wrong cause, no retry, and no request id to quote to support. One donor hit
31
+ * that and fixed it; the other still has the collapsed version.
32
+ */
33
+ export type MeQuery<TMe> =
34
+ { status: "loading" } | { status: "error"; error: unknown } | { status: "success"; data: TMe };
35
+
36
+ export interface GuardOptions {
37
+ /** Drawn while the session or the profile is still resolving. */
38
+ loading: ReactNode;
39
+ }
40
+
41
+ /**
42
+ * Signed in, or off to sign in — remembering where they were headed, so the redirect afterwards
43
+ * lands on the page they actually wanted rather than the home screen.
44
+ */
45
+ export function createRequireAuth(
46
+ useSession: () => SessionState,
47
+ signInPath: string,
48
+ { loading }: GuardOptions,
49
+ ) {
50
+ return function RequireAuth({ children }: { children: ReactNode }) {
51
+ const { isAuthenticated, isLoading } = useSession();
52
+ const location = useLocation();
53
+ if (isLoading) return <>{loading}</>;
54
+ if (!isAuthenticated)
55
+ return (
56
+ <Navigate to={signInPath} replace state={{ from: location.pathname + location.search }} />
57
+ );
58
+ return <>{children}</>;
59
+ };
60
+ }
61
+
62
+ /** The mirror: somebody already signed in has no business on the sign-in screen. */
63
+ export function createRequireAnonymous(
64
+ useSession: () => SessionState,
65
+ homePath: string,
66
+ { loading }: GuardOptions,
67
+ ) {
68
+ return function RequireAnonymous({ children }: { children: ReactNode }) {
69
+ const { isAuthenticated, isLoading } = useSession();
70
+ if (isLoading) return <>{loading}</>;
71
+ if (isAuthenticated) return <Navigate to={homePath} replace />;
72
+ return <>{children}</>;
73
+ };
74
+ }
75
+
76
+ /**
77
+ * A gate on something the profile says — suspended, staff, on a given plan.
78
+ *
79
+ * `allow` gets the profile and answers yes or no. `onDenied` draws the refusal; `onError` draws
80
+ * the failure, and they are SEPARATE arguments on purpose. That separation is the whole point
81
+ * of this file: "you may not see this" and "we could not find out" are different sentences, and
82
+ * showing the first when the second is true is how an outage becomes a support ticket about
83
+ * permissions.
84
+ */
85
+ export function createRequireProfile<TMe>(useMe: () => MeQuery<TMe>, { loading }: GuardOptions) {
86
+ return function requireProfile(
87
+ allow: (me: TMe) => boolean,
88
+ onDenied: ReactNode | (() => ReactNode),
89
+ onError: (error: unknown) => ReactNode,
90
+ ) {
91
+ return function RequireProfile({ children }: { children: ReactNode }) {
92
+ const query = useMe();
93
+ if (query.status === "loading") return <>{loading}</>;
94
+ if (query.status === "error") return <>{onError(query.error)}</>;
95
+ if (!allow(query.data)) return <>{typeof onDenied === "function" ? onDenied() : onDenied}</>;
96
+ return <>{children}</>;
97
+ };
98
+ };
99
+ }
package/src/hydrate.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The browser half of prerendering: deciding whether to hydrate or to mount fresh.
3
+ *
4
+ * Three separate repos invented all of this independently — same constant name, same string
5
+ * value, same decision, and near-identical explaining comments. That is not a coincidence worth
6
+ * deduping; it is one lesson learned three times, and it belongs somewhere it can be learned
7
+ * once.
8
+ *
9
+ * The constants themselves live in `prerender-contract.ts`, which imports nothing, because the
10
+ * BUILD needs them too and must not pull `react-dom/client` into a Node process to get a string.
11
+ */
12
+ import type { ReactNode } from "react";
13
+ import { createRoot, hydrateRoot } from "react-dom/client";
14
+ import { PRERENDERED_ROUTE_ATTR } from "./prerender-contract.ts";
15
+
16
+ export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
17
+
18
+ /**
19
+ * Hydrate the render this file IS, and mount fresh over anything else.
20
+ *
21
+ * "Does the root have children" is the wrong question, and getting it wrong is silent. A static
22
+ * host answers every address it does not publish with the nearest `404.html` — and that file has
23
+ * the not-found page rendered INTO it — so an address served from a shell arrives at a full root
24
+ * holding somebody else's markup. Hydrating that is React reconciling two different pages: it
25
+ * recovers by throwing the whole tree away and says so in the console, which is a page that
26
+ * works and a bug nobody sees. The file names the route it is a render of, so we can simply ask.
27
+ *
28
+ * `route` is passed in rather than read from `window.location` on purpose. A localized app
29
+ * serves `/pt/pricing` from a file rendered for `/pricing`, with the locale carried in the
30
+ * router's `basename` — so the address and the route are different strings, and only the caller
31
+ * knows which one the build wrote.
32
+ */
33
+ export function hydrateOrMount(rootEl: Element, tree: ReactNode, route: string): void {
34
+ if (rootEl.getAttribute(PRERENDERED_ROUTE_ATTR) === route) hydrateRoot(rootEl, tree);
35
+ else createRoot(rootEl).render(tree);
36
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyBrandVars, i18nInitOptions } from "./i18n.ts";
3
+
4
+ describe("applyBrandVars", () => {
5
+ it("fills placeholders anywhere in the tree and leaves the input alone", () => {
6
+ const input = { a: { b: ["Welcome to {{brand}}", "at {{brandDomain}}"] }, n: 1 };
7
+ const out = applyBrandVars(input, { brand: "Example", brandDomain: "example.com" });
8
+
9
+ expect(out).toEqual({ a: { b: ["Welcome to Example", "at example.com"] }, n: 1 });
10
+ expect(input.a.b[0]).toBe("Welcome to {{brand}}");
11
+ });
12
+
13
+ // The names come from `vars`, so adding one needs no edit here. Both donors hardcoded their
14
+ // own list inside the regex and both had to remember to keep it in step.
15
+ it("derives the placeholder names from vars, not from a hardcoded list", () => {
16
+ expect(applyBrandVars("{{anythingAtAll}}", { anythingAtAll: "yes" })).toBe("yes");
17
+ });
18
+
19
+ it("leaves an unknown placeholder alone rather than blanking it", () => {
20
+ // Blanking would silently ship "Contact for help". Leaving it visible fails loudly.
21
+ expect(applyBrandVars("Contact {{nobody}}", { brand: "Example" })).toBe("Contact {{nobody}}");
22
+ });
23
+
24
+ it("is a no-op with no vars", () => {
25
+ const input = { a: "{{brand}}" };
26
+ expect(applyBrandVars(input, {})).toBe(input);
27
+ });
28
+ });
29
+
30
+ describe("i18nInitOptions", () => {
31
+ // The comment in both donors names the bug: `nonExplicitSupportedLngs` validates the BASE
32
+ // subtag, which rejects "pt-BR" (its base "pt" is not listed) and silently falls the whole
33
+ // app back to English. Its absence is load-bearing.
34
+ it("does not set nonExplicitSupportedLngs", () => {
35
+ const options = i18nInitOptions({
36
+ fallbackLng: "en",
37
+ supportedLngs: ["en", "pt-BR"],
38
+ storageKey: "app.locale",
39
+ });
40
+ expect(options).not.toHaveProperty("nonExplicitSupportedLngs");
41
+ });
42
+
43
+ // Only one donor had the query parameter, and it is the only detection source somebody chose
44
+ // on purpose and just now — so it goes first, ahead of storage and the browser.
45
+ it("detects the query parameter first when one is given", () => {
46
+ expect(
47
+ i18nInitOptions({
48
+ fallbackLng: "en",
49
+ supportedLngs: ["en"],
50
+ storageKey: "app.locale",
51
+ queryKey: "lang",
52
+ }).detection,
53
+ ).toMatchObject({
54
+ order: ["querystring", "localStorage", "navigator"],
55
+ lookupQuerystring: "lang",
56
+ });
57
+ });
58
+
59
+ it("omits the query source entirely when no key is given", () => {
60
+ const { detection } = i18nInitOptions({
61
+ fallbackLng: "en",
62
+ supportedLngs: ["en"],
63
+ storageKey: "app.locale",
64
+ });
65
+ expect(detection.order).toEqual(["localStorage", "navigator"]);
66
+ expect(detection).not.toHaveProperty("lookupQuerystring");
67
+ });
68
+ });
package/src/i18n.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The i18next bootstrap two donors wrote nearly comment-for-comment.
3
+ *
4
+ * Each browser surface spreads these into its own init with its own bundled resources:
5
+ *
6
+ * ```ts
7
+ * i18n.use(LanguageDetector).use(initReactI18next).init({ resources, ...i18nInitOptions({…}) })
8
+ * ```
9
+ *
10
+ * Keeping the options here is what stops a console, a storefront and a docs site in the same
11
+ * product from drifting apart on the details below — each of which is a bug somebody already
12
+ * shipped.
13
+ */
14
+
15
+ export interface I18nInitOptions {
16
+ /** The language everything falls back to. */
17
+ fallbackLng: string;
18
+ /** Every language this app ships, as the exact tags used in the catalogs. */
19
+ supportedLngs: readonly string[];
20
+ /**
21
+ * Where the reader's choice is remembered.
22
+ *
23
+ * One key everywhere is a convention, not a mechanism: `localStorage` is scoped to an ORIGIN,
24
+ * so a console on `app.example.com` shares nothing with a storefront on `example.com` no
25
+ * matter what the key is called. Which is why {@link queryKey} exists.
26
+ */
27
+ storageKey: string;
28
+ /**
29
+ * The query parameter that hands a language ACROSS an origin, e.g. `"lang"`.
30
+ *
31
+ * When set it is detected FIRST, ahead of storage and the browser — it is the only one of the
32
+ * three that somebody chose on purpose and just now. A reader who picked Português on the
33
+ * storefront and followed a link into the app means it more than their laptop's locale does.
34
+ * It is cached on arrival, so it decides once and no URL inside the app has to carry it.
35
+ *
36
+ * Only one donor had this. The other has the same split-origin layout and the same problem.
37
+ */
38
+ queryKey?: string;
39
+ }
40
+
41
+ export function i18nInitOptions({
42
+ fallbackLng,
43
+ supportedLngs,
44
+ storageKey,
45
+ queryKey,
46
+ }: I18nInitOptions) {
47
+ return {
48
+ fallbackLng,
49
+ supportedLngs: [...supportedLngs],
50
+ load: "currentOnly" as const,
51
+ interpolation: { escapeValue: false }, // React already escapes.
52
+ detection: {
53
+ order: queryKey
54
+ ? ["querystring", "localStorage", "navigator"]
55
+ : ["localStorage", "navigator"],
56
+ caches: ["localStorage"],
57
+ ...(queryKey ? { lookupQuerystring: queryKey } : {}),
58
+ lookupLocalStorage: storageKey,
59
+ },
60
+ react: { useSuspense: false },
61
+ // `nonExplicitSupportedLngs` is omitted deliberately, and both donors say so in the same
62
+ // words. Enabling it validates the BASE subtag against `supportedLngs`, which rejects a
63
+ // region-coded "pt-BR" (its base "pt" is not in the list) and silently falls the whole app
64
+ // back to English. Detected sub-locales like "en-US" already resolve to their base through
65
+ // i18next's ordinary fallback hierarchy, so the flag buys nothing and breaks pt-BR.
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Substitute `{{brand}}`-style placeholders through a bundled resource tree, once, before
71
+ * i18next ever sees it.
72
+ *
73
+ * Done here rather than through interpolation because i18next v26 has no global interpolation
74
+ * defaults, and these strings are often resolved by dynamic key — so a wrapping `t()` would not
75
+ * reach them. The point is that a rename or a domain move is ONE edit in the brand constants
76
+ * instead of a sweep across every locale file.
77
+ *
78
+ * Returns a fresh tree; the input is untouched. The placeholder names come from `vars`, so
79
+ * adding one needs no change here — two donors hardcoded their own list in the regex and both
80
+ * had to remember to update it.
81
+ */
82
+ export function applyBrandVars<T>(resources: T, vars: Record<string, string>): T {
83
+ const names = Object.keys(vars);
84
+ if (names.length === 0) return resources;
85
+ const pattern = new RegExp(
86
+ `\\{\\{(${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\}\\}`,
87
+ "g",
88
+ );
89
+
90
+ const fill = (s: string): string => s.replace(pattern, (_, name: string) => vars[name] ?? "");
91
+
92
+ const walk = (value: unknown): unknown => {
93
+ if (typeof value === "string") return fill(value);
94
+ if (Array.isArray(value)) return value.map(walk);
95
+ if (value && typeof value === "object")
96
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walk(v)]));
97
+ return value;
98
+ };
99
+
100
+ return walk(resources) as T;
101
+ }