@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,62 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Tabs as Primitive } from "@base-ui/react/tabs";
3
+ import { cn } from "../cn.js";
4
+ import { mergeClassName } from "./class-name.js";
5
+ // Root and Panel pass straight through: a wrapper around either would add a class string and
6
+ // nothing else, and Base UI already owns arrow-key navigation, activation mode and the
7
+ // tab/panel `aria-controls` wiring.
8
+ export const Tabs = Primitive.Root;
9
+ export const TabPanel = Primitive.Panel;
10
+ /**
11
+ * The rail, and the reason this file exists.
12
+ *
13
+ * It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel
14
+ * below the fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto`
15
+ * makes the element a scroll container, a scroll container clips everything its children paint
16
+ * outside the padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from
17
+ * `visible` to `auto` as well. A self-scrolling rail therefore crops the focus ring on all
18
+ * FOUR sides of whichever tab the keyboard is on — top and bottom always, the left of the
19
+ * first tab and the right of the last.
20
+ *
21
+ * So the scroll goes on a wrapper, and the wrapper's padding is the room the ring needs. The
22
+ * number is derived, not inherited: `@gusnips/tokens` draws `outline: 2px solid` at
23
+ * `outline-offset: 2px`, so the ring paints from 2px to 4px beyond a tab's border box, and
24
+ * `p-1` is exactly those 4px. An outline reaches FURTHER than the `ring-2` box-shadow the
25
+ * donor had here, so the wrapper matters more now than it did there, not less.
26
+ *
27
+ * Naming the rail is not optional and not a comment: pass `label`, or point
28
+ * `aria-labelledby` at a heading. A tablist with neither is announced as nothing at all.
29
+ */
30
+ export function TabList({ label, className, wrapperClassName, children, ...rest }) {
31
+ return (_jsx("div", { className: cn("w-fit max-w-full overflow-x-auto p-1", wrapperClassName), children: _jsx(Primitive.List, { "aria-label": label, ...rest,
32
+ // `relative`, because TabIndicator positions itself against the rail.
33
+ className: mergeClassName("relative flex w-fit", className), children: children }) }));
34
+ }
35
+ // Styled off `aria-selected`, never a parallel `data-active`: the primitive writes both from
36
+ // one state, and styling the accessibility contract is what keeps what a screen reader
37
+ // announces and what an eye sees from ever drifting apart.
38
+ //
39
+ // `shrink-0` and `whitespace-nowrap` are what make the rail scroll instead of squashing — a
40
+ // tab that shrinks to fit is a tab nobody can read, and the scroll above exists precisely so
41
+ // it does not have to.
42
+ const tabClasses = "shrink-0 cursor-pointer px-3 py-1.5 whitespace-nowrap text-muted-foreground select-none aria-selected:text-foreground [@media(pointer:coarse)]:min-h-11";
43
+ export function Tab({ className, ...rest }) {
44
+ return _jsx(Primitive.Tab, { ...rest, className: mergeClassName(tabClasses, className) });
45
+ }
46
+ /**
47
+ * The travelling marker under the active tab — a SEPARATE part, because the two donors
48
+ * disagreed about whether to have one at all and both were right about their own app. Render
49
+ * it or do not; the rail works either way.
50
+ *
51
+ * It must be a child of `TabList`: Base UI measures the active tab against the enclosing
52
+ * `[role="tablist"]` and writes `--active-tab-left` / `--active-tab-width` onto this element.
53
+ *
54
+ * `renderBeforeHydration` is on by default because this package's apps are prerendered — with
55
+ * it off, every prerendered page shows the rail with no marker until React arrives. It writes
56
+ * one inline script to do that; under a strict CSP without Base UI's `CspProvider` the script
57
+ * is blocked and the behaviour falls back to exactly what `false` would have done.
58
+ */
59
+ export function TabIndicator({ className, ...rest }) {
60
+ return (_jsx(Primitive.Indicator, { "aria-hidden": "true", renderBeforeHydration: true, ...rest, className: mergeClassName("absolute bottom-0 left-0 h-0.5 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] bg-foreground transition-[translate,width]", className) }));
61
+ }
62
+ //# sourceMappingURL=tabs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabs.js","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAC9B,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,6FAA6F;AAC7F,uFAAuF;AACvF,oCAAoC;AACpC,MAAM,CAAC,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC;AACnC,MAAM,CAAC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC;AAYxC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAgB;IAC7F,OAAO,CACL,cAAK,SAAS,EAAE,EAAE,CAAC,sCAAsC,EAAE,gBAAgB,CAAC,YAC1E,KAAC,SAAS,CAAC,IAAI,kBACD,KAAK,KACb,IAAI;YACR,sEAAsE;YACtE,SAAS,EAAE,cAAc,CAAC,qBAAqB,EAAE,SAAS,CAAC,YAE1D,QAAQ,GACM,GACb,CACP,CAAC;AACJ,CAAC;AAED,6FAA6F;AAC7F,uFAAuF;AACvF,2DAA2D;AAC3D,EAAE;AACF,4FAA4F;AAC5F,6FAA6F;AAC7F,uBAAuB;AACvB,MAAM,UAAU,GACd,yJAAyJ,CAAC;AAE5J,MAAM,UAAU,GAAG,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAuB;IAC7D,OAAO,KAAC,SAAS,CAAC,GAAG,OAAK,IAAI,EAAE,SAAS,EAAE,cAAc,CAAC,UAAU,EAAE,SAAS,CAAC,GAAI,CAAC;AACvF,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAA6B;IAC5E,OAAO,CACL,KAAC,SAAS,CAAC,SAAS,mBACN,MAAM,EAClB,qBAAqB,WACjB,IAAI,EACR,SAAS,EAAE,cAAc,CACvB,4IAA4I,EAC5I,SAAS,CACV,GACD,CACH,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,124 @@
1
+ {
2
+ "name": "@gusnips/react",
3
+ "version": "0.1.0",
4
+ "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, chunk-reload recovery, i18n bootstrap.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Gustavo Salomé",
8
+ "homepage": "https://github.com/gusnips/frontkit/tree/main/react#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/gusnips/frontkit/issues"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./ui": {
18
+ "types": "./dist/ui/index.d.ts",
19
+ "default": "./dist/ui/index.js"
20
+ },
21
+ "./store": {
22
+ "types": "./dist/auth-store.d.ts",
23
+ "default": "./dist/auth-store.js"
24
+ },
25
+ "./guards": {
26
+ "types": "./dist/guards.d.ts",
27
+ "default": "./dist/guards.js"
28
+ },
29
+ "./contract": {
30
+ "types": "./dist/prerender-contract.d.ts",
31
+ "default": "./dist/prerender-contract.js"
32
+ }
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "sideEffects": false,
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "keywords": [
49
+ "react",
50
+ "vite",
51
+ "spa",
52
+ "zustand",
53
+ "react-query",
54
+ "i18next",
55
+ "base-ui",
56
+ "headless"
57
+ ],
58
+ "scripts": {
59
+ "build": "tsc",
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "vitest run --passWithNoTests",
62
+ "test:watch": "vitest",
63
+ "lint": "eslint src",
64
+ "sync:docs": "cp ../LICENSE .",
65
+ "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build && bun run sync:docs",
66
+ "release:patch": "bun pm version patch && bun publish --access public",
67
+ "release:minor": "bun pm version minor && bun publish --access public",
68
+ "release:major": "bun pm version major && bun publish --access public"
69
+ },
70
+ "dependencies": {
71
+ "@gusnips/http": "0.1.0",
72
+ "clsx": "^2.1.1",
73
+ "tailwind-merge": "^3.6.0"
74
+ },
75
+ "peerDependencies": {
76
+ "@base-ui/react": "^1.6.0",
77
+ "@tanstack/react-query": "^5",
78
+ "i18next": ">=25",
79
+ "react": "^19",
80
+ "react-dom": "^19",
81
+ "react-i18next": ">=16",
82
+ "react-router-dom": "^7",
83
+ "zustand": "^5"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@base-ui/react": {
87
+ "optional": true
88
+ },
89
+ "@tanstack/react-query": {
90
+ "optional": true
91
+ },
92
+ "i18next": {
93
+ "optional": true
94
+ },
95
+ "react-i18next": {
96
+ "optional": true
97
+ },
98
+ "react-router-dom": {
99
+ "optional": true
100
+ },
101
+ "zustand": {
102
+ "optional": true
103
+ }
104
+ },
105
+ "devDependencies": {
106
+ "@base-ui/react": "^1.8.0",
107
+ "@tanstack/react-query": "^5.102.8",
108
+ "@types/react": "^19.2.14",
109
+ "@types/react-dom": "^19.2.3",
110
+ "i18next": "^26.3.1",
111
+ "react": "^19.2.4",
112
+ "react-dom": "^19.2.4",
113
+ "react-i18next": "^17.0.8",
114
+ "react-router-dom": "^7.18.3",
115
+ "typescript": "^5.9.3",
116
+ "vitest": "^4.1.2",
117
+ "zustand": "^5.0.15"
118
+ },
119
+ "repository": {
120
+ "type": "git",
121
+ "url": "git+https://github.com/gusnips/frontkit.git",
122
+ "directory": "react"
123
+ }
124
+ }
@@ -0,0 +1,256 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { ApiError, isAbortError } from "./api-error.ts";
3
+ import { createApiClient, type RefreshResult, type SessionAdapter } from "./api-client.ts";
4
+
5
+ function stubFetch(handler: (url: string, init: RequestInit) => Response | Promise<Response>) {
6
+ const spy = vi.fn(handler);
7
+ vi.stubGlobal("fetch", spy as unknown as typeof fetch);
8
+ return spy;
9
+ }
10
+
11
+ const ok = (data: unknown) =>
12
+ new Response(JSON.stringify({ data }), {
13
+ status: 200,
14
+ headers: { "Content-Type": "application/json" },
15
+ });
16
+
17
+ const unauthorized = () =>
18
+ new Response(JSON.stringify({ error: { code: "UNAUTHORIZED", message: "no" } }), { status: 401 });
19
+
20
+ function session(overrides: Partial<SessionAdapter> = {}): SessionAdapter {
21
+ return {
22
+ getToken: async () => "old-token",
23
+ refresh: async (): Promise<RefreshResult> => ({ token: "new-token", reachedAuth: true }),
24
+ signOut: async () => {},
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ afterEach(() => vi.unstubAllGlobals());
30
+
31
+ describe("createApiClient", () => {
32
+ it("unwraps the success envelope", async () => {
33
+ stubFetch(() => ok({ id: 7 }));
34
+ const api = createApiClient({
35
+ baseUrl: "https://api.test",
36
+ session: session(),
37
+ onSessionDead: () => {},
38
+ });
39
+ await expect(api.get<{ id: number }>("/me")).resolves.toEqual({ id: 7 });
40
+ });
41
+
42
+ it("throws an ApiError carrying the whole envelope and the request id", async () => {
43
+ stubFetch(
44
+ () =>
45
+ new Response(
46
+ JSON.stringify({
47
+ error: { code: "QUOTA_EXCEEDED", message: "spent", details: { retryAfterSecs: 60 } },
48
+ }),
49
+ { status: 429, headers: { "x-request-id": "req_1" } },
50
+ ),
51
+ );
52
+ const api = createApiClient({
53
+ baseUrl: "https://api.test",
54
+ session: session(),
55
+ onSessionDead: () => {},
56
+ });
57
+
58
+ const error = await api.get("/x").catch((e: unknown) => e);
59
+ expect(error).toBeInstanceOf(ApiError);
60
+ expect(error).toMatchObject({
61
+ status: 429,
62
+ code: "QUOTA_EXCEEDED",
63
+ details: { retryAfterSecs: 60 },
64
+ requestId: "req_1",
65
+ });
66
+ });
67
+
68
+ /**
69
+ * The single-flight rule, and the reason all three donors found it the hard way: the auth
70
+ * server rotates the refresh token on use, so six parallel 401s sending six refreshes means
71
+ * five of them invalidate the winner — and the person is signed out in the middle of a load
72
+ * that was working.
73
+ */
74
+ it("sends one refresh for six concurrent 401s", async () => {
75
+ let refreshes = 0;
76
+ let token = "old-token";
77
+ stubFetch((_url, init) => {
78
+ const auth = new Headers(init.headers).get("Authorization");
79
+ return auth === "Bearer new-token" ? ok({}) : unauthorized();
80
+ });
81
+
82
+ const api = createApiClient({
83
+ baseUrl: "https://api.test",
84
+ session: session({
85
+ getToken: async () => token,
86
+ refresh: async () => {
87
+ refreshes++;
88
+ // A real rotation: the old token is dead the moment a refresh succeeds.
89
+ await new Promise((r) => setTimeout(r, 5));
90
+ token = "new-token";
91
+ return { token, reachedAuth: true };
92
+ },
93
+ }),
94
+ onSessionDead: () => {},
95
+ });
96
+
97
+ await Promise.all(Array.from({ length: 6 }, () => api.get("/me")));
98
+ expect(refreshes).toBe(1);
99
+ });
100
+
101
+ /**
102
+ * Invariant 3. Only one of three donors had this, and it is the difference between a Wi-Fi
103
+ * blip and losing your place: a refresh that never got an ANSWER out of auth says nothing
104
+ * about whether the session is good, so the 401 falls through as an ordinary error and the
105
+ * next request refreshes cleanly once the connection is back.
106
+ */
107
+ it("does not sign anyone out when the refresh never reached auth", async () => {
108
+ stubFetch(() => unauthorized());
109
+ const onSessionDead = vi.fn();
110
+ const signOut = vi.fn(async () => {});
111
+
112
+ const api = createApiClient({
113
+ baseUrl: "https://api.test",
114
+ session: session({
115
+ refresh: async () => ({ token: null, reachedAuth: false }),
116
+ signOut,
117
+ }),
118
+ onSessionDead,
119
+ refreshRetryDelayMs: 0,
120
+ });
121
+
122
+ const error = await api.get("/me").catch((e: unknown) => e);
123
+ expect(onSessionDead).not.toHaveBeenCalled();
124
+ expect(signOut).not.toHaveBeenCalled();
125
+ // It surfaces as an ordinary 401 the caller can report, not as an expected sign-out.
126
+ expect(error).toBeInstanceOf(ApiError);
127
+ expect((error as ApiError).expected).toBe(false);
128
+ });
129
+
130
+ it("signs out once when auth actually says no", async () => {
131
+ stubFetch(() => unauthorized());
132
+ const onSessionDead = vi.fn();
133
+ const api = createApiClient({
134
+ baseUrl: "https://api.test",
135
+ session: session({ refresh: async () => ({ token: null, reachedAuth: true }) }),
136
+ onSessionDead,
137
+ refreshRetryDelayMs: 0,
138
+ });
139
+
140
+ const first = await api.get("/a").catch((e: unknown) => e);
141
+ await api.get("/b").catch(() => {});
142
+
143
+ // Latched: whichever request got there first owns the sign-out.
144
+ expect(onSessionDead).toHaveBeenCalledTimes(1);
145
+ // Still throws, because location.replace() does not stop the current frame — a caller's
146
+ // own onError has to run or a half-finished screen keeps rendering against data that will
147
+ // never arrive. `expected` is what keeps it out of crash reporting.
148
+ expect((first as ApiError).expected).toBe(true);
149
+ });
150
+
151
+ /**
152
+ * providerkit's invariant 2, on the web side: a caller's Stop and our deadline must stay
153
+ * distinguishable. `AbortSignal.timeout` rejects with a **TimeoutError**, not an AbortError,
154
+ * so `isAbortError` stays false for it — which is what lets a timeout be retried and reported
155
+ * while a navigation-cancelled request is silently dropped.
156
+ */
157
+ it("distinguishes its own timeout from the caller's abort", async () => {
158
+ // Note the `aborted` check before the listener. A signal that fired before fetch was ever
159
+ // called never emits the event, so a listener alone hangs forever — real `fetch` checks the
160
+ // flag first, and so must anything standing in for it. That already-aborted race is exactly
161
+ // what `AbortSignal.any` in the client exists to cover.
162
+ stubFetch(
163
+ (_url, init) =>
164
+ new Promise<Response>((_resolve, reject) => {
165
+ const signal = init.signal as AbortSignal | null;
166
+ if (signal?.aborted) return reject(signal.reason as Error);
167
+ signal?.addEventListener("abort", () => reject(signal.reason as Error));
168
+ }),
169
+ );
170
+ const api = createApiClient({
171
+ baseUrl: "https://api.test",
172
+ session: session(),
173
+ onSessionDead: () => {},
174
+ timeoutMs: 10,
175
+ });
176
+
177
+ const timedOut = await api.get("/slow").catch((e: unknown) => e);
178
+ expect((timedOut as DOMException).name).toBe("TimeoutError");
179
+ expect(isAbortError(timedOut)).toBe(false);
180
+
181
+ const controller = new AbortController();
182
+ const cancelled = api.get("/slow", { signal: controller.signal }).catch((e: unknown) => e);
183
+ controller.abort();
184
+ expect(isAbortError(await cancelled)).toBe(true);
185
+ });
186
+
187
+ // A 204 has no body, so `res.json()` on one throws `Unexpected end of JSON input` — which
188
+ // reads like a malformed response and is really a success nobody was allowed to parse. The
189
+ // donor's dashboard DELETEs all answer 204, so `del` must not go looking for data.
190
+ it("deletes without reading the body, and reads it only when asked", async () => {
191
+ const fetched = stubFetch((_url, init) =>
192
+ init.method === "DELETE" && !String(_url).endsWith("/queue/1")
193
+ ? new Response(null, { status: 204 })
194
+ : ok({ removed: true }),
195
+ );
196
+ const api = createApiClient({
197
+ baseUrl: "https://api.test",
198
+ session: session(),
199
+ onSessionDead: () => {},
200
+ });
201
+
202
+ await expect(api.del("/keys/1")).resolves.toBeUndefined();
203
+ await expect(api.delJson<{ removed: boolean }>("/queue/1")).resolves.toEqual({ removed: true });
204
+ expect(fetched).toHaveBeenCalledTimes(2);
205
+ });
206
+
207
+ it("reports a failure to onError once, and still throws it", async () => {
208
+ stubFetch(
209
+ () =>
210
+ new Response(
211
+ JSON.stringify({
212
+ error: {
213
+ code: "FORBIDDEN",
214
+ message: "suspended",
215
+ messageKey: "serverErrors.suspended",
216
+ },
217
+ }),
218
+ { status: 403 },
219
+ ),
220
+ );
221
+ const seen: ApiError[] = [];
222
+ const api = createApiClient({
223
+ baseUrl: "https://api.test",
224
+ session: session(),
225
+ onSessionDead: () => {},
226
+ onError: (error) => seen.push(error),
227
+ });
228
+
229
+ const thrown = await api.get("/numbers").catch((e: unknown) => e);
230
+ expect(seen).toHaveLength(1);
231
+ expect(seen[0]?.messageKey).toBe("serverErrors.suspended");
232
+ // The same object, not a copy: a listener that stashes it can compare identity later.
233
+ expect(thrown).toBe(seen[0]);
234
+ });
235
+
236
+ it("does not let a broken listener replace the API failure", async () => {
237
+ stubFetch(
238
+ () =>
239
+ new Response(JSON.stringify({ error: { code: "NOT_FOUND", message: "no" } }), {
240
+ status: 404,
241
+ }),
242
+ );
243
+ const api = createApiClient({
244
+ baseUrl: "https://api.test",
245
+ session: session(),
246
+ onSessionDead: () => {},
247
+ onError: () => {
248
+ throw new Error("the listener is buggy");
249
+ },
250
+ });
251
+
252
+ const thrown = await api.get("/x").catch((e: unknown) => e);
253
+ expect(thrown).toBeInstanceOf(ApiError);
254
+ expect((thrown as ApiError).status).toBe(404);
255
+ });
256
+ });