@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.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/api-client.d.ts +139 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +133 -0
- package/dist/api-client.js.map +1 -0
- package/dist/api-error.d.ts +45 -0
- package/dist/api-error.d.ts.map +1 -0
- package/dist/api-error.js +52 -0
- package/dist/api-error.js.map +1 -0
- package/dist/auth-store.d.ts +49 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +27 -0
- package/dist/auth-store.js.map +1 -0
- package/dist/chunk-reload.d.ts +61 -0
- package/dist/chunk-reload.d.ts.map +1 -0
- package/dist/chunk-reload.js +92 -0
- package/dist/chunk-reload.js.map +1 -0
- package/dist/cn.d.ts +13 -0
- package/dist/cn.d.ts.map +1 -0
- package/dist/cn.js +16 -0
- package/dist/cn.js.map +1 -0
- package/dist/describe-error.d.ts +74 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +70 -0
- package/dist/describe-error.js.map +1 -0
- package/dist/error-boundary.d.ts +61 -0
- package/dist/error-boundary.d.ts.map +1 -0
- package/dist/error-boundary.js +32 -0
- package/dist/error-boundary.js.map +1 -0
- package/dist/guards.d.ts +65 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +52 -0
- package/dist/guards.js.map +1 -0
- package/dist/hydrate.d.ts +30 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +25 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/i18n.d.ts +70 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +66 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/prerender-contract.d.ts +30 -0
- package/dist/prerender-contract.d.ts.map +1 -0
- package/dist/prerender-contract.js +30 -0
- package/dist/prerender-contract.js.map +1 -0
- package/dist/query.d.ts +28 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +51 -0
- package/dist/query.js.map +1 -0
- package/dist/sse.d.ts +64 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +105 -0
- package/dist/sse.js.map +1 -0
- package/dist/states.d.ts +69 -0
- package/dist/states.d.ts.map +1 -0
- package/dist/states.js +2 -0
- package/dist/states.js.map +1 -0
- package/dist/ui/class-name.d.ts +16 -0
- package/dist/ui/class-name.d.ts.map +1 -0
- package/dist/ui/class-name.js +13 -0
- package/dist/ui/class-name.js.map +1 -0
- package/dist/ui/combobox.d.ts +81 -0
- package/dist/ui/combobox.d.ts.map +1 -0
- package/dist/ui/combobox.js +70 -0
- package/dist/ui/combobox.js.map +1 -0
- package/dist/ui/dialog.d.ts +39 -0
- package/dist/ui/dialog.d.ts.map +1 -0
- package/dist/ui/dialog.js +32 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/ui/drawer.d.ts +63 -0
- package/dist/ui/drawer.d.ts.map +1 -0
- package/dist/ui/drawer.js +55 -0
- package/dist/ui/drawer.js.map +1 -0
- package/dist/ui/glyphs.d.ts +4 -0
- package/dist/ui/glyphs.d.ts.map +1 -0
- package/dist/ui/glyphs.js +20 -0
- package/dist/ui/glyphs.js.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +48 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/input.d.ts +45 -0
- package/dist/ui/input.d.ts.map +1 -0
- package/dist/ui/input.js +108 -0
- package/dist/ui/input.js.map +1 -0
- package/dist/ui/menu.d.ts +70 -0
- package/dist/ui/menu.d.ts.map +1 -0
- package/dist/ui/menu.js +85 -0
- package/dist/ui/menu.js.map +1 -0
- package/dist/ui/select.d.ts +69 -0
- package/dist/ui/select.d.ts.map +1 -0
- package/dist/ui/select.js +48 -0
- package/dist/ui/select.js.map +1 -0
- package/dist/ui/tabs.d.ts +52 -0
- package/dist/ui/tabs.d.ts.map +1 -0
- package/dist/ui/tabs.js +62 -0
- package/dist/ui/tabs.js.map +1 -0
- package/package.json +124 -0
- package/src/api-client.test.ts +256 -0
- package/src/api-client.ts +305 -0
- package/src/api-error.ts +60 -0
- package/src/auth-store.ts +59 -0
- package/src/chunk-reload.test.ts +46 -0
- package/src/chunk-reload.ts +94 -0
- package/src/cn.ts +16 -0
- package/src/describe-error.test.ts +102 -0
- package/src/describe-error.ts +140 -0
- package/src/error-boundary.tsx +88 -0
- package/src/guards.tsx +99 -0
- package/src/hydrate.ts +36 -0
- package/src/i18n.test.ts +68 -0
- package/src/i18n.ts +101 -0
- package/src/index.ts +63 -0
- package/src/prerender-contract.ts +31 -0
- package/src/query.test.ts +54 -0
- package/src/query.ts +79 -0
- package/src/sse.test.ts +50 -0
- package/src/sse.ts +117 -0
- package/src/states.ts +71 -0
- package/src/ui/class-name.ts +23 -0
- package/src/ui/combobox.test.ts +32 -0
- package/src/ui/combobox.tsx +242 -0
- package/src/ui/dialog.tsx +87 -0
- package/src/ui/drawer.tsx +161 -0
- package/src/ui/focus.test.ts +82 -0
- package/src/ui/glyphs.tsx +35 -0
- package/src/ui/index.ts +145 -0
- package/src/ui/input.test.ts +22 -0
- package/src/ui/input.tsx +181 -0
- package/src/ui/menu.test.ts +73 -0
- package/src/ui/menu.tsx +186 -0
- package/src/ui/select.tsx +191 -0
- package/src/ui/tabs.tsx +95 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @gusnips/react — the headless runtime under a Vite + React SPA.
|
|
2
|
+
//
|
|
3
|
+
// No styling, no brand, no Node built-ins.
|
|
4
|
+
//
|
|
5
|
+
// Three things live behind their own subpath, and the rule picking them is invariant 15 stated
|
|
6
|
+
// as a test rather than a judgement: **a peer marked `optional` must not be reachable from this
|
|
7
|
+
// barrel.** An optional peer the barrel imports anyway is not optional — it is a required peer
|
|
8
|
+
// with the error moved from install time to the adopter's first build, which is the worse of the
|
|
9
|
+
// two places to find out.
|
|
10
|
+
//
|
|
11
|
+
// @gusnips/react/ui the Base UI wrappers → @base-ui/react
|
|
12
|
+
// @gusnips/react/store createAuthStore → zustand
|
|
13
|
+
// @gusnips/react/guards the route guards → react-router-dom
|
|
14
|
+
//
|
|
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.
|
|
21
|
+
|
|
22
|
+
export { cn } from "./cn.ts";
|
|
23
|
+
|
|
24
|
+
export { ApiError, isAbortError } from "./api-error.ts";
|
|
25
|
+
export {
|
|
26
|
+
createApiClient,
|
|
27
|
+
type ApiClient,
|
|
28
|
+
type ApiClientOptions,
|
|
29
|
+
type RefreshResult,
|
|
30
|
+
type RequestOptions,
|
|
31
|
+
type SessionAdapter,
|
|
32
|
+
} from "./api-client.ts";
|
|
33
|
+
export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
|
34
|
+
|
|
35
|
+
export { queryDefaults, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
ErrorBoundary,
|
|
39
|
+
type ErrorBoundaryFallbackProps,
|
|
40
|
+
type ErrorBoundaryProps,
|
|
41
|
+
} from "./error-boundary.tsx";
|
|
42
|
+
export {
|
|
43
|
+
createErrorDescriber,
|
|
44
|
+
humanizeWait,
|
|
45
|
+
retryAfterSecs,
|
|
46
|
+
type DescribedError,
|
|
47
|
+
type ErrorContext,
|
|
48
|
+
type ErrorDescriberOptions,
|
|
49
|
+
type Translate,
|
|
50
|
+
} from "./describe-error.ts";
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
installPreloadErrorHandler,
|
|
54
|
+
isChunkLoadError,
|
|
55
|
+
isPreloadHintFailure,
|
|
56
|
+
reloadOnceForChunkError,
|
|
57
|
+
} from "./chunk-reload.ts";
|
|
58
|
+
|
|
59
|
+
export { hydrateOrMount, PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./hydrate.ts";
|
|
60
|
+
|
|
61
|
+
export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
|
|
62
|
+
|
|
63
|
+
export type { EmptyStateProps, ErrorStateProps } from "./states.ts";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two constants the build and the browser both need — and **nothing else in this file**.
|
|
3
|
+
*
|
|
4
|
+
* It is separate from `hydrate.ts` for one reason: that module imports `react-dom/client`, and
|
|
5
|
+
* `@gusnips/vite` needs these constants to WRITE the attribute at build time. If the constants
|
|
6
|
+
* lived beside the hydration helper, a build script asking for a string would pull the browser
|
|
7
|
+
* renderer into a Node process, and every `entry-server.tsx` that touched this package would do
|
|
8
|
+
* the same. Zero imports here is the whole point; keep it that way.
|
|
9
|
+
*
|
|
10
|
+
* The contract itself was invented three times independently, with the same name and the same
|
|
11
|
+
* string value each time. See `hydrate.ts` for why it exists at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The attribute a prerendered file names its own route in — written by the build, read by the
|
|
16
|
+
* browser entry.
|
|
17
|
+
*
|
|
18
|
+
* One constant because the writer and the reader are in different builds, and a typo between
|
|
19
|
+
* them would show up only as a silent full re-render: a page that works, and a bug nobody sees.
|
|
20
|
+
*/
|
|
21
|
+
export const PRERENDERED_ROUTE_ATTR = "data-prerendered-route";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a 404 shell writes instead of a route.
|
|
25
|
+
*
|
|
26
|
+
* It is the pattern that actually matched — the catch-all — and it can never equal an address,
|
|
27
|
+
* which is the property that matters: every route a static host answers from the shell mounts
|
|
28
|
+
* fresh rather than hydrating the not-found page over itself. A real 404 pays one redundant
|
|
29
|
+
* client render for that, and keeps the markup a crawler reads.
|
|
30
|
+
*/
|
|
31
|
+
export const SHELL_ROUTE = "*";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ApiError } from "./api-error.ts";
|
|
3
|
+
import { shouldRetry } from "./query.ts";
|
|
4
|
+
|
|
5
|
+
const refusal = (status: number, code?: string) =>
|
|
6
|
+
new ApiError(status, code ? { code, message: code } : null);
|
|
7
|
+
|
|
8
|
+
// Invariant 8, and the reason this module exists: three donors wrote three retry rules and
|
|
9
|
+
// each was right about something the other two got wrong. Every case below is one of those.
|
|
10
|
+
describe("shouldRetry", () => {
|
|
11
|
+
it("retries a request that never landed", () => {
|
|
12
|
+
// Offline, DNS, a dropped connection. Nothing about it is an answer.
|
|
13
|
+
expect(shouldRetry(new TypeError("Failed to fetch"))).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("retries 5xx", () => {
|
|
17
|
+
expect(shouldRetry(refusal(500))).toBe(true);
|
|
18
|
+
expect(shouldRetry(refusal(503))).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not retry a 4xx that is an answer", () => {
|
|
22
|
+
// A 402, a 403 and a 404 say exactly the same thing three times.
|
|
23
|
+
for (const status of [400, 401, 402, 403, 404, 409, 422, 451]) {
|
|
24
|
+
expect(shouldRetry(refusal(status))).toBe(false);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Only one donor allowed this through, and it is the one 4xx a second attempt genuinely fixes.
|
|
29
|
+
it("retries 408", () => {
|
|
30
|
+
expect(shouldRetry(refusal(408))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("retries a burst 429", () => {
|
|
34
|
+
expect(shouldRetry(refusal(429, "RATE_LIMIT_EXCEEDED"))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The sharpest rule of the three, and only one donor had it. A spent monthly quota and an
|
|
38
|
+
// empty balance both arrive as 429 or 402, and neither clears by waiting — retrying burns
|
|
39
|
+
// another request against the limiter and cannot succeed. The status alone cannot tell them
|
|
40
|
+
// apart from a burst limit, which is why the caller names the codes.
|
|
41
|
+
it("does not retry a limit the code says is durable", () => {
|
|
42
|
+
const durable = ["QUOTA_EXCEEDED", "PAYMENT_REQUIRED"];
|
|
43
|
+
expect(shouldRetry(refusal(429, "QUOTA_EXCEEDED"), durable)).toBe(false);
|
|
44
|
+
expect(shouldRetry(refusal(402, "PAYMENT_REQUIRED"), durable)).toBe(false);
|
|
45
|
+
// …and the burst limit still retries, which is the distinction the whole rule is for.
|
|
46
|
+
expect(shouldRetry(refusal(429, "RATE_LIMIT_EXCEEDED"), durable)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A durable code wins even over a 5xx: if the server says the limit does not clear by
|
|
50
|
+
// waiting, its status code is not the more specific claim.
|
|
51
|
+
it("lets a durable code override the status", () => {
|
|
52
|
+
expect(shouldRetry(refusal(503, "QUOTA_EXCEEDED"), ["QUOTA_EXCEEDED"])).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DefaultOptions } from "@tanstack/react-query";
|
|
2
|
+
import { ApiError } from "./api-error.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* react-query defaults — the merge of three repos that each got part of this right.
|
|
6
|
+
*
|
|
7
|
+
* The default is three blind retries, which is wrong for an API that answers refusals
|
|
8
|
+
* precisely: a 402, a 403 or a 451 says exactly the same thing three times, and a 429 we hammer
|
|
9
|
+
* is a 429 we deserve. All three donors knew that much. What each knew alone:
|
|
10
|
+
*
|
|
11
|
+
* - one allowed **408** through, the only 4xx that a second attempt genuinely fixes;
|
|
12
|
+
* - one refused to retry a 429 whose CODE says the limit is durable — a monthly quota or a
|
|
13
|
+
* spent balance clears by buying, not by waiting, so retrying burns another request against
|
|
14
|
+
* the limiter and cannot succeed;
|
|
15
|
+
* - one set **`refetchOnReconnect`**, which none of the others had, and which is the single
|
|
16
|
+
* most useful refetch there is: coming back from a tunnel is exactly when the screen is stale.
|
|
17
|
+
*
|
|
18
|
+
* This is invariant 8.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 4xx statuses a second attempt can fix. Everything else in the 4xx family is an answer. */
|
|
22
|
+
const RETRYABLE_CLIENT_STATUS = new Set([408, 429]);
|
|
23
|
+
|
|
24
|
+
export interface QueryDefaultsOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
|
|
27
|
+
* balance that needs topping up. They usually arrive as 402 or 429; the status alone cannot
|
|
28
|
+
* tell them apart from a burst limit, which is why the caller names them.
|
|
29
|
+
*/
|
|
30
|
+
durableLimitCodes?: readonly string[];
|
|
31
|
+
/** How many times to retry a transient failure. Two donors used 2, one used 1. */
|
|
32
|
+
maxRetries?: number;
|
|
33
|
+
staleTime?: number;
|
|
34
|
+
gcTime?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Left to the caller on purpose. A dashboard someone leaves open wants it on; a console
|
|
37
|
+
* whose data changes only when the operator changes it wants it off, and one donor turned it
|
|
38
|
+
* off deliberately. There is no right answer to inherit.
|
|
39
|
+
*/
|
|
40
|
+
refetchOnWindowFocus?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Should this failure be retried?
|
|
45
|
+
*
|
|
46
|
+
* Exported on its own because an app with its own QueryClient config still wants this rule, and
|
|
47
|
+
* because it is the part worth testing.
|
|
48
|
+
*/
|
|
49
|
+
export function shouldRetry(error: unknown, durableLimitCodes: readonly string[] = []): boolean {
|
|
50
|
+
// No response at all — offline, DNS, a dropped connection. The request never landed, so
|
|
51
|
+
// nothing about it is an answer, and a second attempt is exactly right.
|
|
52
|
+
if (!(error instanceof ApiError)) return true;
|
|
53
|
+
|
|
54
|
+
if (error.code !== undefined && durableLimitCodes.includes(error.code)) return false;
|
|
55
|
+
if (error.status >= 500) return true;
|
|
56
|
+
return RETRYABLE_CLIENT_STATUS.has(error.status);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function queryDefaults({
|
|
60
|
+
durableLimitCodes = [],
|
|
61
|
+
maxRetries = 2,
|
|
62
|
+
staleTime = 2 * 60 * 1000,
|
|
63
|
+
gcTime = 10 * 60 * 1000,
|
|
64
|
+
refetchOnWindowFocus = false,
|
|
65
|
+
}: QueryDefaultsOptions = {}): DefaultOptions {
|
|
66
|
+
return {
|
|
67
|
+
queries: {
|
|
68
|
+
staleTime,
|
|
69
|
+
gcTime,
|
|
70
|
+
retry: (failureCount, error) =>
|
|
71
|
+
failureCount < maxRetries && shouldRetry(error, durableLimitCodes),
|
|
72
|
+
refetchOnWindowFocus,
|
|
73
|
+
refetchOnReconnect: true,
|
|
74
|
+
},
|
|
75
|
+
// A mutation is not idempotent by default. Retrying one that already reached the server is
|
|
76
|
+
// how a customer gets charged twice; all three donors agreed on this without discussion.
|
|
77
|
+
mutations: { retry: false },
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/sse.test.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createSseParser, type SseFrame } from "./sse.ts";
|
|
3
|
+
|
|
4
|
+
function collect(...chunks: string[]): SseFrame[] {
|
|
5
|
+
const frames: SseFrame[] = [];
|
|
6
|
+
const parser = createSseParser((f) => frames.push(f));
|
|
7
|
+
for (const chunk of chunks) parser.feed(chunk);
|
|
8
|
+
return frames;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("createSseParser", () => {
|
|
12
|
+
// The bug one donor shipped: assigning `data` instead of appending, so a multi-line
|
|
13
|
+
// payload arrived as its last line only. Silent — no error, no warning, a truncated message.
|
|
14
|
+
it("joins repeated data lines instead of overwriting them", () => {
|
|
15
|
+
expect(collect("data: one\ndata: two\ndata: three\n\n")[0]?.data).toBe("one\ntwo\nthree");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("holds a frame split across reads until it completes", () => {
|
|
19
|
+
expect(collect("data: hel", "lo\n", "\n")).toEqual([
|
|
20
|
+
{ event: "message", data: "hello", id: undefined },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The spec strips exactly one space after the colon. `.trim()` is the obvious wrong thing:
|
|
25
|
+
// it eats leading and trailing whitespace a payload may actually mean.
|
|
26
|
+
it("strips one leading space, not all whitespace", () => {
|
|
27
|
+
expect(collect("data: padded \n\n")[0]?.data).toBe(" padded ");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("defaults an unnamed frame to `message`, and carries event and id", () => {
|
|
31
|
+
expect(collect("event: tick\nid: 42\ndata: x\n\n")[0]).toEqual({
|
|
32
|
+
event: "tick",
|
|
33
|
+
data: "x",
|
|
34
|
+
id: "42",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// What lets a server keep the connection warm without waking the consumer on every beat.
|
|
39
|
+
it("does not dispatch a frame with no data", () => {
|
|
40
|
+
expect(collect("event: heartbeat\n\n", ": just a comment\n\n")).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("handles CRLF line endings", () => {
|
|
44
|
+
expect(collect("event: tick\r\ndata: x\r\n\r\n")[0]?.event).toBe("tick");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not parse the payload", () => {
|
|
48
|
+
expect(collect('data: {"n":1}\n\n')[0]?.data).toBe('{"n":1}');
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-sent events: the framing and the transport, kept apart.
|
|
3
|
+
*
|
|
4
|
+
* Three donors had three readers and each had half the answer. One was spec-correct about the
|
|
5
|
+
* wire but welded to `TextDecoderStream`, so the React Native app — whose fetch has no body
|
|
6
|
+
* reader — could not use a line of it. One was a platform-free `feed(text)` that three
|
|
7
|
+
* transports shared, and it silently dropped data. One was a whole second transport with its
|
|
8
|
+
* own fetch and its own 401 handling, bypassing the app's only API client.
|
|
9
|
+
*
|
|
10
|
+
* So: {@link createSseParser} is the state machine and knows about no platform at all;
|
|
11
|
+
* {@link readSseStream} is fifteen lines of `ReadableStream` on top of it. A caller on a
|
|
12
|
+
* platform without body readers keeps its own fifteen lines and shares the machine underneath.
|
|
13
|
+
* (providerkit reached this exact split from the other direction and for the same reason.)
|
|
14
|
+
*
|
|
15
|
+
* And streams ride the ordinary API client — `client.request(path, {headers: {Accept:
|
|
16
|
+
* "text/event-stream"}, timeoutMs: null})`. Not `EventSource`, which cannot carry an
|
|
17
|
+
* Authorization header, and not a second fetch, which would need its own copy of the refresh
|
|
18
|
+
* logic and would drift from it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** One frame off the wire. `data` is raw text; see {@link createSseParser} on why. */
|
|
22
|
+
export interface SseFrame {
|
|
23
|
+
/** The `event:` field, or `"message"` — which is what the spec says an unnamed frame is. */
|
|
24
|
+
event: string;
|
|
25
|
+
/** Every `data:` line in the frame, joined with newlines. Never parsed here. */
|
|
26
|
+
data: string;
|
|
27
|
+
/** The `id:` field, if the server sent one. Feed it back as `Last-Event-ID` to resume. */
|
|
28
|
+
id: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An incremental parser. Feed it text in whatever sizes the transport delivers.
|
|
33
|
+
*
|
|
34
|
+
* Frames split across reads are held until they complete, so a payload cut mid-line is never
|
|
35
|
+
* lost or truncated — which is the failure this shape exists to prevent, and the one that only
|
|
36
|
+
* shows up under a slow network.
|
|
37
|
+
*
|
|
38
|
+
* Three rules that are easy to get wrong, and one donor got each of them wrong:
|
|
39
|
+
*
|
|
40
|
+
* - **`data:` repeats.** A frame may carry several `data:` lines and they are JOINED with
|
|
41
|
+
* newlines. One donor assigned instead of appending, so a multi-line payload arrived as its
|
|
42
|
+
* last line only — no error, no warning, just a truncated message.
|
|
43
|
+
* - **Strip ONE leading space after the colon**, not all whitespace. `.trim()` is the obvious
|
|
44
|
+
* thing and it corrupts any payload whose own content begins or ends with a space.
|
|
45
|
+
* - **A frame with no `data:` is not dispatched.** That is what lets a server send bare
|
|
46
|
+
* `event: heartbeat` keepalives, or `:` comment lines, without waking a consumer.
|
|
47
|
+
*
|
|
48
|
+
* `data` is handed back as a raw string and deliberately NOT JSON-parsed. One donor parsed here
|
|
49
|
+
* and its three consumers wanted three different things — one wraps non-JSON, one uses a
|
|
50
|
+
* tolerant parser, one parses directly. Parsing in the parser picks a policy for all of them.
|
|
51
|
+
*/
|
|
52
|
+
export function createSseParser(onFrame: (frame: SseFrame) => void): {
|
|
53
|
+
feed: (text: string) => void;
|
|
54
|
+
} {
|
|
55
|
+
let buffer = "";
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
feed(text: string) {
|
|
59
|
+
// A server may send CRLF; the frame boundary is a blank line either way.
|
|
60
|
+
buffer += text.replace(/\r\n/g, "\n");
|
|
61
|
+
let end = buffer.indexOf("\n\n");
|
|
62
|
+
while (end !== -1) {
|
|
63
|
+
const frame = parseFrame(buffer.slice(0, end));
|
|
64
|
+
buffer = buffer.slice(end + 2);
|
|
65
|
+
if (frame) onFrame(frame);
|
|
66
|
+
end = buffer.indexOf("\n\n");
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The SSE wire format: `field: value` lines, `data` repeatable, a leading `:` is a comment. */
|
|
73
|
+
function parseFrame(raw: string): SseFrame | null {
|
|
74
|
+
let event = "message";
|
|
75
|
+
let id: string | undefined;
|
|
76
|
+
const data: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const line of raw.split("\n")) {
|
|
79
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
80
|
+
const colon = line.indexOf(":");
|
|
81
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
82
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
83
|
+
if (field === "event") event = value;
|
|
84
|
+
else if (field === "id") id = value;
|
|
85
|
+
else if (field === "data") data.push(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return data.length === 0 ? null : { event, data: data.join("\n"), id };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read a `Response` body as SSE. Resolves when the server closes the stream.
|
|
93
|
+
*
|
|
94
|
+
* Pass the response from the ordinary API client, so the stream inherits the token, the
|
|
95
|
+
* single-flight refresh, and a 402 or a 404 arriving as an `ApiError` the page can explain
|
|
96
|
+
* rather than an `onerror` event with nothing in it.
|
|
97
|
+
*
|
|
98
|
+
* Cancels the reader on the way out however it leaves — a stream abandoned without cancelling
|
|
99
|
+
* holds the connection open until the server gives up on it.
|
|
100
|
+
*/
|
|
101
|
+
export async function readSseStream(
|
|
102
|
+
response: Response,
|
|
103
|
+
onFrame: (frame: SseFrame) => void,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
if (!response.body) return;
|
|
106
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
107
|
+
const parser = createSseParser(onFrame);
|
|
108
|
+
try {
|
|
109
|
+
for (;;) {
|
|
110
|
+
const { value, done } = await reader.read();
|
|
111
|
+
if (done) break;
|
|
112
|
+
parser.feed(value);
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
await reader.cancel().catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/states.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two contracts that make "never dead-end the user" a compile error instead of a rule
|
|
5
|
+
* somebody remembers.
|
|
6
|
+
*
|
|
7
|
+
* **No component ships here, on purpose.** Seven repos have an `EmptyState.tsx` and an
|
|
8
|
+
* `ErrorState.tsx`, and read side by side they overlap 9–37%: one draws a tinted icon badge,
|
|
9
|
+
* one a branded illustration set, one a mascot. What they genuinely share is the PROP SHAPE —
|
|
10
|
+
* which is the rule itself, and the only part a package can hold without deciding how eight
|
|
11
|
+
* brands look. Same reasoning as the styled Button: share behaviour, skin per product.
|
|
12
|
+
*
|
|
13
|
+
* Import the type, write your own component against it, and the compiler enforces the rest.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A panel with nothing in it yet.
|
|
18
|
+
*
|
|
19
|
+
* The prop shape IS the rule: somebody who lands here must learn what this panel is FOR
|
|
20
|
+
* (`title`), what belongs IN it (`description`), and how to FILL it (`action`). The first two
|
|
21
|
+
* are required because two of the three are what make it an orientation rather than a shrug.
|
|
22
|
+
* `action` is optional only because some panels fill themselves once a job upstream finishes.
|
|
23
|
+
*/
|
|
24
|
+
export interface EmptyStateProps {
|
|
25
|
+
/** What this panel is for. "No lookups yet" — not "No results". */
|
|
26
|
+
title: string;
|
|
27
|
+
/** What belongs here, and what it will show once it does. */
|
|
28
|
+
description: ReactNode;
|
|
29
|
+
/** The way forward, as a control. Usually one button. */
|
|
30
|
+
action?: ReactNode;
|
|
31
|
+
/**
|
|
32
|
+
* `3` inside a panel that already sits under a page heading — the default and the common
|
|
33
|
+
* case. `1` when this composer IS the page (a 404, a suspended account), which otherwise
|
|
34
|
+
* ships a document with no `h1` in it at all.
|
|
35
|
+
*
|
|
36
|
+
* Only one donor had this, and it is the difference between a screen reader announcing a
|
|
37
|
+
* page and announcing nothing.
|
|
38
|
+
*/
|
|
39
|
+
headingLevel?: 1 | 2 | 3;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Something did not work.
|
|
45
|
+
*
|
|
46
|
+
* `problem` names what failed, `cause` says why it most likely happened, `fix` says what to do
|
|
47
|
+
* about it, and `action` is that fix as a control. **`fix` and `action` are both required** — a
|
|
48
|
+
* required prop is the only version of "never dead-end" that a caller in a hurry cannot skip,
|
|
49
|
+
* and the donor that made them required is the one whose error screens all have a way out.
|
|
50
|
+
*
|
|
51
|
+
* Not a place for a stack trace. `cause` is what a person can act on ("the site answered too
|
|
52
|
+
* slowly"), with the request id, if there is one, alongside it in `reference`.
|
|
53
|
+
*
|
|
54
|
+
* {@link DescribedError} from `describe-error.ts` produces `cause` and `fix` from a thrown
|
|
55
|
+
* `ApiError`, which is the seam these two were designed against.
|
|
56
|
+
*/
|
|
57
|
+
export interface ErrorStateProps {
|
|
58
|
+
/** What failed, in the reader's words. "Couldn't load your numbers". */
|
|
59
|
+
problem: string;
|
|
60
|
+
/** Why it most likely happened. */
|
|
61
|
+
cause?: ReactNode;
|
|
62
|
+
/** The way forward, in words. */
|
|
63
|
+
fix: ReactNode;
|
|
64
|
+
/** The way forward, as a control — usually a retry button. */
|
|
65
|
+
action: ReactNode;
|
|
66
|
+
/** A request id or code, for a support thread. Set in mono, never shouted. */
|
|
67
|
+
reference?: ReactNode;
|
|
68
|
+
/** See {@link EmptyStateProps.headingLevel}. */
|
|
69
|
+
headingLevel?: 1 | 2 | 3;
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ClassValue } from "clsx";
|
|
2
|
+
import { cn } from "../cn.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A Base UI part's `className`: a plain string, or a function of that part's own state
|
|
6
|
+
* (`open`, `transitionStatus`, `highlighted`, …).
|
|
7
|
+
*/
|
|
8
|
+
export type StateClassName<State> = string | ((state: State) => string | undefined) | undefined;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Merge a wrapper's own classes with the caller's, keeping BOTH forms working.
|
|
12
|
+
*
|
|
13
|
+
* `cn()` on its own silently DROPS the function form — clsx walks strings, arrays and plain
|
|
14
|
+
* objects, and a function is none of the three — so a caller styling by `open` or
|
|
15
|
+
* `transitionStatus` would get an empty class and no warning. Always returning a function
|
|
16
|
+
* keeps one code path for both.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeClassName<State>(
|
|
19
|
+
base: ClassValue,
|
|
20
|
+
extra: StateClassName<State>,
|
|
21
|
+
): (state: State) => string {
|
|
22
|
+
return (state) => cn(base, typeof extra === "function" ? extra(state) : extra);
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { comboboxInputValue } from "./combobox.tsx";
|
|
3
|
+
|
|
4
|
+
interface City {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const recife: City = { id: "rec", name: "Recife" };
|
|
10
|
+
const label = (city: City) => city.name;
|
|
11
|
+
|
|
12
|
+
describe("comboboxInputValue", () => {
|
|
13
|
+
// Show the label while the list is open and every keystroke is overwritten by the old
|
|
14
|
+
// selection — the field types backwards.
|
|
15
|
+
it("shows what the user is typing while the list is open", () => {
|
|
16
|
+
expect(comboboxInputValue(true, "rec", recife, label)).toBe("rec");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Show the query while the list is closed and the field goes blank the moment focus
|
|
20
|
+
// leaves, even though a value is set.
|
|
21
|
+
it("shows the selected item once the list closes", () => {
|
|
22
|
+
expect(comboboxInputValue(false, "rec", recife, label)).toBe("Recife");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is empty with nothing selected", () => {
|
|
26
|
+
expect(comboboxInputValue(false, "rec", null, label)).toBe("");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("still shows an open query when nothing is selected yet", () => {
|
|
30
|
+
expect(comboboxInputValue(true, "rec", null, label)).toBe("rec");
|
|
31
|
+
});
|
|
32
|
+
});
|