@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,305 @@
1
+ import { isApiError, type ApiSuccess } from "@gusnips/http";
2
+ import { ApiError } from "./api-error.ts";
3
+
4
+ /**
5
+ * The app's one door to the API. Nothing else should call `fetch`.
6
+ *
7
+ * Every route answers the same envelope, so unwrapping and error-shaping belong here once
8
+ * rather than in every hook. Three donors built this independently and all three arrived at
9
+ * single-flight token refresh — see {@link SessionAdapter.refresh} for the reason, which is
10
+ * the sharpest thing in this file.
11
+ *
12
+ * Four things deliberately did NOT come across from the donors, because they belong to a
13
+ * product and not to a client:
14
+ *
15
+ * - **Toasts.** Whether a failure is spoken aloud is a design decision that differs per surface.
16
+ * Throw, and let the caller decide.
17
+ * - **In-flight GET deduplication.** react-query already dedups by key, so the client's copy is
18
+ * redundant — and one donor's version returned a `clone()` of a body already being consumed,
19
+ * which is a race with no symptom until it has one.
20
+ * - **Service-status tracking and impersonation.** One donor's, and firmly its own.
21
+ * - **Analytics correlation headers.** Injected through {@link ApiClientOptions.headers}.
22
+ */
23
+
24
+ /** What a refresh attempt actually learned. */
25
+ export interface RefreshResult {
26
+ token: string | null;
27
+ /**
28
+ * False when the refresh never got an ANSWER out of the auth server — a dropped connection,
29
+ * a 5xx, a captive portal.
30
+ *
31
+ * This flag is invariant 3, and only one of three donors had it. Losing a packet says nothing
32
+ * about whether a session is still good, so a client that collapses "auth said no" into
33
+ * "auth did not answer" signs people out over a Wi-Fi blip and costs them their place for
34
+ * nothing. Auth libraries usually name this: Supabase throws `AuthRetryableFetchError`, so
35
+ * an adapter answers `reachedAuth: !(error instanceof AuthRetryableFetchError)`.
36
+ */
37
+ reachedAuth: boolean;
38
+ }
39
+
40
+ /**
41
+ * The seam onto whatever holds the session. Supabase in every donor so far, but nothing here
42
+ * knows that — an adapter is four lines and keeps the auth vendor out of this package.
43
+ */
44
+ export interface SessionAdapter {
45
+ getToken(): Promise<string | null>;
46
+ refresh(): Promise<RefreshResult>;
47
+ signOut(): Promise<void>;
48
+ }
49
+
50
+ export interface ApiClientOptions {
51
+ /** Origin + prefix, e.g. `https://api.example.com/v1`. No trailing slash. */
52
+ baseUrl: string;
53
+ session: SessionAdapter;
54
+ /**
55
+ * Extra headers, computed per request.
56
+ *
57
+ * A function, not an object, and that matters: one donor read
58
+ * `document.documentElement.lang` inline in its request builder, which crashes a prerender
59
+ * outright. Anything that touches the DOM, the current locale or the clock goes in here,
60
+ * where it runs only when a request is actually being sent.
61
+ */
62
+ headers?: () => Record<string, string>;
63
+ /**
64
+ * The session is gone and cannot be renewed — send them to sign in.
65
+ *
66
+ * The client does not know the route, and it deliberately still THROWS after calling this:
67
+ * `location.replace()` does not stop the current frame, so a caller's own `onError` must
68
+ * still run or a half-finished screen keeps rendering against data that will never arrive.
69
+ * The error it throws carries `expected: true`.
70
+ */
71
+ onSessionDead: () => void;
72
+ /**
73
+ * Every failed response, seen once, just before it is thrown.
74
+ *
75
+ * For a reaction that belongs to the whole app rather than to one call site. The donor's case
76
+ * is the sharp one: a mid-session account suspension 403s every authed route except
77
+ * `GET /auth/me`, so the moment one arrives the app has to refresh `me` and route to the
78
+ * screen that explains it — otherwise every query on the page fails at once and the shell
79
+ * half-renders behind an error storm until `me` goes stale on its own.
80
+ *
81
+ * It cannot live at a call site, because the point is that it fires from whichever call
82
+ * happened to be first. It went in the donor's client directly, which made the client import
83
+ * its query cache and its query keys — a cycle that this hook removes.
84
+ *
85
+ * Observation only: the error is thrown either way, and throwing from here would replace a
86
+ * real API failure with whatever the listener hit.
87
+ */
88
+ onError?: (error: ApiError) => void;
89
+ /**
90
+ * Abort a request that has not answered. Default 30s.
91
+ *
92
+ * Neither donor bounded its authenticated requests at all — both bounded only their keyless
93
+ * clients — so a stalled connection was a spinner with no end. providerkit learned the same
94
+ * lesson about streams: a request with no deadline is a bug that only shows up on a bad
95
+ * network, which is exactly when nobody can reproduce it.
96
+ */
97
+ timeoutMs?: number;
98
+ /** How many times to refresh-and-retry a 401. Default 2. */
99
+ maxRefreshAttempts?: number;
100
+ /** Pause between refresh attempts. Default 500ms. */
101
+ refreshRetryDelayMs?: number;
102
+ /**
103
+ * How long to wait for `signOut()` before redirecting anyway. Default 3s.
104
+ *
105
+ * A fail-safe, and one donor added it after the failure it prevents: awaiting `signOut()`
106
+ * covers a rejection but not a HANG, and a hang leaves the tab signed out in name only —
107
+ * still on the page, every request 401ing, nothing left that could redirect it.
108
+ */
109
+ signOutTimeoutMs?: number;
110
+ /** Header carrying the server's request id, echoed onto {@link ApiError.requestId}. */
111
+ requestIdHeader?: string;
112
+ }
113
+
114
+ export interface RequestOptions extends Omit<RequestInit, "headers"> {
115
+ headers?: Record<string, string>;
116
+ /** Override the client's default timeout for this call. `null` disables it (streams). */
117
+ timeoutMs?: number | null;
118
+ }
119
+
120
+ export interface ApiClient {
121
+ /** The raw `Response`, past auth and error handling. For blobs, streams and downloads. */
122
+ request(path: string, options?: RequestOptions): Promise<Response>;
123
+ /** `data` out of the success envelope. */
124
+ get<T>(path: string, options?: RequestOptions): Promise<T>;
125
+ post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
126
+ put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
127
+ patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
128
+ /**
129
+ * DELETE, discarding whatever comes back. Returns `void` because 204 is the usual answer and
130
+ * a 204 has NO BODY — `res.json()` on one throws `Unexpected end of JSON input`, which reads
131
+ * like a malformed response and is really just a success nobody was allowed to parse.
132
+ *
133
+ * Split from {@link ApiClient.delJson} rather than guessing per response, because the guess is
134
+ * the part that hides a bug: a route that was supposed to answer with data and returned
135
+ * nothing should fail loudly here, not hand back a silent `undefined` that surfaces three
136
+ * layers away. The donor that hit this had written exactly these two functions.
137
+ */
138
+ del(path: string, options?: RequestOptions): Promise<void>;
139
+ /** The DELETE that answers with something worth reading — a queue entry it handed back. */
140
+ delJson<T>(path: string, options?: RequestOptions): Promise<T>;
141
+ /** The whole envelope, for a list route whose counts live in `meta`. */
142
+ page<T, M>(path: string, options?: RequestOptions): Promise<ApiSuccess<T, M>>;
143
+ }
144
+
145
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
146
+
147
+ export function createApiClient({
148
+ baseUrl,
149
+ session,
150
+ headers: extraHeaders,
151
+ onSessionDead,
152
+ onError,
153
+ timeoutMs = 30_000,
154
+ maxRefreshAttempts = 2,
155
+ refreshRetryDelayMs = 500,
156
+ signOutTimeoutMs = 3_000,
157
+ requestIdHeader = "x-request-id",
158
+ }: ApiClientOptions): ApiClient {
159
+ /**
160
+ * Single-flight refresh.
161
+ *
162
+ * A page that fires six queries at once must not send six refreshes. The auth server rotates
163
+ * the refresh token on use, so the losers of that race each invalidate the winner — and the
164
+ * person is signed out in the middle of a load that was working. All three donors found this
165
+ * the hard way and all three fixed it the same way.
166
+ */
167
+ let refreshing: Promise<RefreshResult> | null = null;
168
+ function refresh(): Promise<RefreshResult> {
169
+ refreshing ??= session
170
+ .refresh()
171
+ .catch(() => ({ token: null, reachedAuth: false }))
172
+ .finally(() => {
173
+ refreshing = null;
174
+ });
175
+ return refreshing;
176
+ }
177
+
178
+ /** Latches, so whichever request gets here first owns the sign-out and the rest are no-ops. */
179
+ let signingOut = false;
180
+ function sessionDead(): never {
181
+ if (!signingOut) {
182
+ signingOut = true;
183
+ let done = false;
184
+ const go = (): void => {
185
+ if (done) return;
186
+ done = true;
187
+ onSessionDead();
188
+ };
189
+ void session
190
+ .signOut()
191
+ .catch(() => {})
192
+ .finally(go);
193
+ setTimeout(go, signOutTimeoutMs);
194
+ }
195
+ // Thrown even though the redirect is under way: see `onSessionDead`.
196
+ throw new ApiError(
197
+ 401,
198
+ { code: "UNAUTHORIZED", message: "Session expired" },
199
+ { expected: true },
200
+ );
201
+ }
202
+
203
+ function buildHeaders(token: string | null, extra?: Record<string, string>): Headers {
204
+ const headers = new Headers({ ...extraHeaders?.(), ...extra });
205
+ if (token) headers.set("Authorization", `Bearer ${token}`);
206
+ return headers;
207
+ }
208
+
209
+ async function send(
210
+ path: string,
211
+ token: string | null,
212
+ { headers, timeoutMs: perCall, signal, ...init }: RequestOptions,
213
+ ): Promise<Response> {
214
+ const budget = perCall === undefined ? timeoutMs : perCall;
215
+ if (budget === null)
216
+ return fetch(`${baseUrl}${path}`, { ...init, signal, headers: buildHeaders(token, headers) });
217
+
218
+ // `AbortSignal.any` rather than a listener: it also catches the already-aborted race, where
219
+ // a caller's signal fired before we ever attached. It keeps the caller's abort and our
220
+ // timeout distinguishable at the source, which is what lets `isAbortError` stay honest.
221
+ const timeout = AbortSignal.timeout(budget);
222
+ return fetch(`${baseUrl}${path}`, {
223
+ ...init,
224
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
225
+ headers: buildHeaders(token, headers),
226
+ });
227
+ }
228
+
229
+ async function request(path: string, options: RequestOptions = {}): Promise<Response> {
230
+ let res = await send(path, await session.getToken(), options);
231
+
232
+ if (res.status === 401) {
233
+ // Only a refresh that actually REACHED auth proves the session is gone. Anything else is
234
+ // a network problem, and the 401 falls through as an ordinary error — the next request
235
+ // refreshes cleanly once the connection is back. Invariant 3.
236
+ let answered = false;
237
+ for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
238
+ if (attempt > 0) await sleep(refreshRetryDelayMs);
239
+ const result = await refresh();
240
+ answered = result.reachedAuth;
241
+ if (!result.token) continue;
242
+ res = await send(path, result.token, options);
243
+ if (res.status !== 401) break;
244
+ }
245
+ if (res.status === 401 && answered) sessionDead();
246
+ }
247
+
248
+ if (!res.ok) {
249
+ const body: unknown = await res.json().catch(() => null);
250
+ const error = new ApiError(res.status, isApiError(body) ? body.error : null, {
251
+ requestId: res.headers.get(requestIdHeader) ?? undefined,
252
+ });
253
+ // A listener that throws must not become the error the caller sees: the API failure is
254
+ // the real news, and swallowing it for a bug in a side effect would send everyone
255
+ // debugging the wrong thing.
256
+ try {
257
+ onError?.(error);
258
+ } catch {
259
+ // ignored on purpose — see above
260
+ }
261
+ throw error;
262
+ }
263
+ return res;
264
+ }
265
+
266
+ /**
267
+ * The success envelope. `request` has already thrown on anything that is not one, so what is
268
+ * left is `{data, meta?}` by contract — this is the single place that assertion is made.
269
+ */
270
+ async function envelope<T, M>(res: Response): Promise<ApiSuccess<T, M>> {
271
+ return (await res.json()) as ApiSuccess<T, M>;
272
+ }
273
+
274
+ const json = (body: unknown, options: RequestOptions = {}): RequestOptions =>
275
+ body === undefined
276
+ ? options
277
+ : {
278
+ ...options,
279
+ body: JSON.stringify(body),
280
+ headers: { "Content-Type": "application/json", ...options.headers },
281
+ };
282
+
283
+ async function data<T>(path: string, options: RequestOptions): Promise<T> {
284
+ return (await envelope<T, never>(await request(path, options))).data;
285
+ }
286
+
287
+ return {
288
+ request,
289
+ get: <T>(path: string, options: RequestOptions = {}) =>
290
+ data<T>(path, { ...options, method: "GET" }),
291
+ post: <T>(path: string, body?: unknown, options: RequestOptions = {}) =>
292
+ data<T>(path, json(body, { ...options, method: "POST" })),
293
+ put: <T>(path: string, body?: unknown, options: RequestOptions = {}) =>
294
+ data<T>(path, json(body, { ...options, method: "PUT" })),
295
+ patch: <T>(path: string, body?: unknown, options: RequestOptions = {}) =>
296
+ data<T>(path, json(body, { ...options, method: "PATCH" })),
297
+ del: async (path: string, options: RequestOptions = {}) => {
298
+ await request(path, { ...options, method: "DELETE" });
299
+ },
300
+ delJson: <T>(path: string, options: RequestOptions = {}) =>
301
+ data<T>(path, { ...options, method: "DELETE" }),
302
+ page: async <T, M>(path: string, options: RequestOptions = {}) =>
303
+ envelope<T, M>(await request(path, { ...options, method: "GET" })),
304
+ };
305
+ }
@@ -0,0 +1,60 @@
1
+ import type { ApiError as ApiErrorBody } from "@gusnips/http";
2
+
3
+ /**
4
+ * A refusal, with the whole envelope intact.
5
+ *
6
+ * `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries
7
+ * the parts that make a refusal actionable — the `resetAt` on a 429, the plan that lifts a 402.
8
+ * Keeping all of it means a caller never has to re-parse a response body that has already been
9
+ * read once.
10
+ *
11
+ * A real `class` and not an interface with a factory: one donor used the interface shape, which
12
+ * works until something needs `instanceof` across a module boundary or wants a subclass.
13
+ */
14
+ export class ApiError extends Error {
15
+ readonly status: number;
16
+ readonly code: string | undefined;
17
+ readonly messageKey: string | undefined;
18
+ readonly params: Record<string, string | number> | undefined;
19
+ readonly details: unknown;
20
+ readonly requestId: string | undefined;
21
+
22
+ /**
23
+ * The client raised this itself to stop a caller while it was already handling the
24
+ * situation — today, the 401 thrown after a dead session, where the person is already on
25
+ * their way to the sign-in page.
26
+ *
27
+ * It is control flow, not a fault. Exception reporting should skip it, and an error surface
28
+ * should not ask somebody to retry something that is not broken. One donor had this and it is
29
+ * the difference between a clean sign-out and a crash report every time a session expires.
30
+ */
31
+ readonly expected: boolean;
32
+
33
+ constructor(
34
+ status: number,
35
+ body: ApiErrorBody["error"] | null,
36
+ opts: { requestId?: string; expected?: boolean; message?: string } = {},
37
+ ) {
38
+ super(opts.message ?? body?.message ?? `Request failed (${status})`);
39
+ this.name = "ApiError";
40
+ this.status = status;
41
+ this.code = body?.code;
42
+ this.messageKey = body?.messageKey;
43
+ this.params = body?.params;
44
+ this.details = body?.details;
45
+ this.requestId = opts.requestId;
46
+ this.expected = opts.expected ?? false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
52
+ * component waiting on it.
53
+ *
54
+ * Worth its own predicate because it is the one failure that must NOT be reported, retried or
55
+ * shown: nobody is waiting for the answer. Distinguishing it from a real failure is what keeps
56
+ * a route change from filling the console with errors nobody caused.
57
+ */
58
+ export function isAbortError(error: unknown): boolean {
59
+ return error instanceof DOMException && error.name === "AbortError";
60
+ }
@@ -0,0 +1,59 @@
1
+ import { create, type StoreApi, type UseBoundStore } from "zustand";
2
+
3
+ /**
4
+ * Session FLAGS only.
5
+ *
6
+ * Who the person is — their plan, their staff bit, whether they are suspended — comes from
7
+ * `GET /auth/me` through react-query, never from here. Mirroring server state in a client store
8
+ * is how two sources of truth start disagreeing, and the one that is wrong is always the one on
9
+ * screen. Three repos wrote this same store; two of them wrote that same warning in a comment.
10
+ *
11
+ * `TUser` stays generic because it is the one part that differs: one product carries an
12
+ * anonymous-browsing flag, another does not. Keep it to what a GUARD needs — an id and an
13
+ * email is what all three donors had.
14
+ */
15
+ export interface AuthState<TUser> {
16
+ user: TUser | null;
17
+ isAuthenticated: boolean;
18
+ /**
19
+ * True until the first session lookup resolves. The guards must not bounce somebody to
20
+ * /login while we are still finding out whether they are signed in.
21
+ */
22
+ isLoading: boolean;
23
+ /**
24
+ * Set (or clear) the signed-in user. This ENDS the loading state — knowing who they are is
25
+ * what the bootstrap was waiting for. Two of the three donors left `isLoading` alone here and
26
+ * relied on a separate `setLoading(false)`; forgetting that call leaves every guard spinning,
27
+ * so the safe default is to do it in one write.
28
+ */
29
+ setUser: (user: TUser | null) => void;
30
+ setLoading: (isLoading: boolean) => void;
31
+ /** Sign-out: clears the user and ends the loading state in one write. */
32
+ clear: () => void;
33
+ }
34
+
35
+ /**
36
+ * Create the store.
37
+ *
38
+ * The whole reason this is a factory and not a store: `isLoading` must start FALSE where there
39
+ * is no window, and only a factory can decide that at the call site rather than at import.
40
+ *
41
+ * A session bootstrap can only be in flight in a browser. The BUILD renders this app to files
42
+ * with no window at all, so `true` there is a wait that never ends — one donor shipped a route
43
+ * guard holding its loading screen forever, and prerendered a public page as 1,174 bytes of
44
+ * `role="status"`: a spinner as the indexable body of a page whose entire purpose was to be
45
+ * found. With no browser there is no session and never will be, which is exactly the state a
46
+ * first-time visitor arrives in, so that is what the page should render.
47
+ *
48
+ * Only one of the three donors knew this. It is invariant 7.
49
+ */
50
+ export function createAuthStore<TUser>(): UseBoundStore<StoreApi<AuthState<TUser>>> {
51
+ return create<AuthState<TUser>>((set) => ({
52
+ user: null,
53
+ isAuthenticated: false,
54
+ isLoading: typeof window !== "undefined",
55
+ setUser: (user) => set({ user, isAuthenticated: Boolean(user), isLoading: false }),
56
+ setLoading: (isLoading) => set({ isLoading }),
57
+ clear: () => set({ user: null, isAuthenticated: false, isLoading: false }),
58
+ }));
59
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isChunkLoadError, isPreloadHintFailure } from "./chunk-reload.ts";
3
+
4
+ // No browser gives a failed dynamic import a shared type, so this is matched by message and
5
+ // every phrasing has to be here. A miss is a white screen after a deploy — the exact failure
6
+ // this module exists to catch — and it only reproduces on a real deploy, so the test is the
7
+ // only place these strings get checked.
8
+ describe("isChunkLoadError", () => {
9
+ it.each([
10
+ ["Chrome/Edge", "Failed to fetch dynamically imported module: https://x/assets/a.js"],
11
+ ["Firefox", "error loading dynamically imported module"],
12
+ ["Safari", "Importing a module script failed."],
13
+ ["webpack", "Loading chunk 42 failed."],
14
+ // The one that actually fires on a static host: the missing .js is answered with the SPA
15
+ // fallback, so the browser refuses the HTML rather than reporting a 404.
16
+ [
17
+ "SPA fallback",
18
+ "Expected a JavaScript module script but the server responded with a MIME type of text/html",
19
+ ],
20
+ ])("matches %s", (_browser, message) => {
21
+ expect(isChunkLoadError(new Error(message))).toBe(true);
22
+ });
23
+
24
+ it("matches a named ChunkLoadError whatever its message", () => {
25
+ const error = new Error("nothing recognisable");
26
+ error.name = "ChunkLoadError";
27
+ expect(isChunkLoadError(error)).toBe(true);
28
+ });
29
+
30
+ it("does not match an ordinary render error", () => {
31
+ expect(isChunkLoadError(new TypeError("Cannot read properties of undefined"))).toBe(false);
32
+ expect(isChunkLoadError(null)).toBe(false);
33
+ });
34
+ });
35
+
36
+ // The distinction that matters: swallowing a real module failure resolves the import with
37
+ // `undefined`, React.lazy reads `.default` off nothing, and the developer gets a TypeError with
38
+ // only React frames in it — naming no chunk. Only the CSS hint is safe to swallow.
39
+ describe("isPreloadHintFailure", () => {
40
+ it("matches Vite's CSS preload hint and nothing else", () => {
41
+ expect(isPreloadHintFailure(new Error("Unable to preload CSS for /assets/a.css"))).toBe(true);
42
+ expect(isPreloadHintFailure(new Error("Failed to fetch dynamically imported module"))).toBe(
43
+ false,
44
+ );
45
+ });
46
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Surviving a deploy that lands while a tab is open.
3
+ *
4
+ * Every app on this stack is a Vite SPA with `lazy()` routes, deployed on push. When a build
5
+ * replaces the one a tab is running, the next navigation asks for a chunk whose hashed name no
6
+ * longer exists — and a static host answers a missing asset with the SPA fallback, so the
7
+ * import gets HTML where it expected a module. React's lazy boundary throws, and the person
8
+ * gets a white screen on a click that worked a minute ago.
9
+ *
10
+ * One repo in the fleet had all of this. The others had none of it, and none of them knew.
11
+ */
12
+
13
+ const RELOAD_FLAG_KEY = "frontkit:chunk-reload-at";
14
+ const RELOAD_WINDOW_MS = 60_000;
15
+
16
+ /**
17
+ * A lazy-chunk fetch failure — recoverable by reloading.
18
+ *
19
+ * Matched by message because no browser gives it a shared type. Every phrasing in the wild is
20
+ * here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
21
+ * dynamically imported module", Safari "Importing a module script failed", webpack's
22
+ * ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
23
+ * which is the one that actually fires on a static host, because the missing `.js` is answered
24
+ * with `index.html`.
25
+ *
26
+ * A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
27
+ * handler can classify the same failure without importing a component.
28
+ */
29
+ export function isChunkLoadError(error: unknown): boolean {
30
+ if (error instanceof Error && error.name === "ChunkLoadError") return true;
31
+ const message = (error instanceof Error ? error.message : String(error) || "").toLowerCase();
32
+ return (
33
+ message.includes("dynamically imported module") ||
34
+ message.includes("importing a module script failed") ||
35
+ message.includes("mime type") ||
36
+ message.includes("loading chunk")
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Hard-reload once to pick up the fresh deploy. Returns false when the guard suppressed it.
42
+ *
43
+ * The guard is the part that matters. Reloading on a chunk error is obvious; reloading on a
44
+ * chunk error that the reload does not fix is an infinite loop with the user inside it. At most
45
+ * one reload per minute, so a genuinely missing chunk degrades to the error UI — which can at
46
+ * least say something — instead of flickering forever.
47
+ *
48
+ * `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament,
49
+ * and a stale flag in another tab must not suppress a reload this one needs. Wrapped because
50
+ * storage throws outright when site data is blocked, and a privacy setting must not be the
51
+ * reason somebody cannot recover.
52
+ */
53
+ export function reloadOnceForChunkError(): boolean {
54
+ const now = Date.now();
55
+ try {
56
+ const last = Number(sessionStorage.getItem(RELOAD_FLAG_KEY) ?? 0);
57
+ if (now - last < RELOAD_WINDOW_MS) return false;
58
+ sessionStorage.setItem(RELOAD_FLAG_KEY, String(now));
59
+ } catch {
60
+ // Storage blocked — proceed with the reload rather than refusing to recover.
61
+ }
62
+ window.location.reload();
63
+ return true;
64
+ }
65
+
66
+ /**
67
+ * Vite's *hint* failure, which is not the same thing and must not be treated as one.
68
+ *
69
+ * Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
70
+ * When that link fails, the import itself still runs right after and usually succeeds — so this
71
+ * one is worth swallowing, where a failure of the import is the real thing
72
+ * {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
73
+ * for a stylesheet link; script hints never reject.
74
+ */
75
+ export function isPreloadHintFailure(error: unknown): boolean {
76
+ return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
77
+ }
78
+
79
+ /**
80
+ * Install the `vite:preloadError` listener. Call once, from the browser entry.
81
+ *
82
+ * Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
83
+ * deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
84
+ * `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
85
+ * The user gets the crash screen and the developer gets a TypeError with only React frames in
86
+ * it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
87
+ * runs. A real module failure is left to throw, where the error boundary can recognise it.
88
+ */
89
+ export function installPreloadErrorHandler(): void {
90
+ window.addEventListener("vite:preloadError", (event) => {
91
+ const payload = (event as Event & { payload?: unknown }).payload;
92
+ if (isPreloadHintFailure(payload)) event.preventDefault();
93
+ });
94
+ }
package/src/cn.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /**
5
+ * Compose class names, letting later Tailwind utilities win over earlier ones
6
+ * (`p-2 p-4` → `p-4`).
7
+ *
8
+ * That precedence is the whole point, and it is why this is not `clsx` alone: a caller's
9
+ * `className` has to be able to OVERRIDE a component's defaults rather than fight them at
10
+ * equal specificity, where the winner would be whichever rule Tailwind happened to emit last.
11
+ *
12
+ * Ten repos wrote this function. All ten wrote it identically.
13
+ */
14
+ export function cn(...inputs: ClassValue[]): string {
15
+ return twMerge(clsx(inputs));
16
+ }