@terpjs/react-core 0.5.9 → 0.6.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.
@@ -21,6 +21,7 @@ import {
21
21
  import { OverviewPage } from "./OverviewPage";
22
22
  import { Page } from "./Page";
23
23
  import { DetailList, Stack } from "./layout";
24
+ import { Card } from "./ui/Card";
24
25
 
25
26
  afterEach(cleanup);
26
27
 
@@ -120,6 +121,7 @@ describe("runtime slot enforcement", () => {
120
121
  it("passes a DetailPage of record sections and refuses a rogue one", async () => {
121
122
  underContract(
122
123
  <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
124
+ <Card title="A section">the sanctioned visual separation, directly in the slot</Card>
123
125
  <Stack>
124
126
  <DetailList items={[{ label: "Status", value: "open" }]} />
125
127
  </Stack>
@@ -9,6 +9,10 @@ import { createContext, useContext } from "react";
9
9
  * a `data-terp` marker on its root — and refuses the view, fail closed, with the same
10
10
  * agent-directive message the `terp/layout-contract` lint rule phrases.
11
11
  *
12
+ * Both halves govern the slot's DIRECT children only: an allowed container's own
13
+ * subtree (a Card's body, a Stack's rows) is the app's to compose — nesting content
14
+ * inside an allowed component is sanctioned composition, not an escape hatch.
15
+ *
12
16
  * This table is the TypeScript mirror of the spec-as-data source in
13
17
  * `@terpjs/eslint-boundaries/src/layouts.js` (react-core ships standalone, so it cannot
14
18
  * import a lint package); the parity test in ./layoutContract.test.tsx keeps the two
@@ -33,8 +37,9 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
33
37
  "The standard three-level shape: hub bodies are card grids (HubCard only), " +
34
38
  "overview bodies are data collections (DataView / ResourceList + framework " +
35
39
  "states), detail bodies are record sections (DetailList / Stack / Tabs + " +
36
- "framework states). A bespoke screen composes the plain Page, which the " +
37
- "contract deliberately leaves unconstrained.",
40
+ "framework states); Card is allowed in overview and detail bodies as the " +
41
+ "sanctioned visual separation between sections. A bespoke screen composes " +
42
+ "the plain Page, which the contract deliberately leaves unconstrained.",
38
43
  slots: {
39
44
  HubPage: {
40
45
  components: { HubCard: "hubcard" },
@@ -45,6 +50,7 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
45
50
  ResourceList: "resource-list",
46
51
  ModuleNav: "module-nav",
47
52
  Stack: "stack",
53
+ Card: "card",
48
54
  EmptyState: "empty-state",
49
55
  ErrorState: "error-state",
50
56
  LoadingState: "loading-state",
@@ -59,6 +65,7 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
59
65
  Tabs: "tabs",
60
66
  ModuleNav: "module-nav",
61
67
  DataView: "dataview",
68
+ Card: "card",
62
69
  EmptyState: "empty-state",
63
70
  ErrorState: "error-state",
64
71
  LoadingState: "loading-state",
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The type-level route table an app's generated `routes.gen.d.ts` augments (ADR 0092).
3
+ *
4
+ * `buildAppRouter` realises routes at runtime from manifest data, so TanStack Router's
5
+ * own type registry is empty for every Terp app: no route path and no param name is
6
+ * checked anywhere, and a typo ships green. The information to check them is already
7
+ * checked in — the manifests are static data — so `terp routes` extracts it and emits a
8
+ * committed declaration file that augments this interface:
9
+ *
10
+ * ```ts
11
+ * declare module "@terpjs/react-core" {
12
+ * interface TerpRouteTable {
13
+ * "/records": Record<never, never>;
14
+ * "/records/:recordId": { recordId: string };
15
+ * }
16
+ * }
17
+ * ```
18
+ *
19
+ * Paths are keyed in the manifest's stack-agnostic spelling (`:recordId`, not `$recordId`),
20
+ * which is the spelling {@link useTerpNavigate} accepts and translates.
21
+ *
22
+ * Deliberately empty here: with no generated file the table has no keys, every helper
23
+ * below falls back to `string`, and an app that has not generated keeps exactly today's
24
+ * behavior. Generating is what turns these checks on.
25
+ */
26
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
27
+ export interface TerpRouteTable {}
28
+
29
+ /** True when no `routes.gen.d.ts` has augmented {@link TerpRouteTable}. */
30
+ type Ungenerated = keyof TerpRouteTable extends never ? true : false;
31
+
32
+ /** Every route path the app's manifests declare — or `string` before generating. */
33
+ export type TerpRoutePath = Ungenerated extends true ? string : keyof TerpRouteTable & string;
34
+
35
+ /** The params of one declared route (`Record<never, never>` for a paramless route). */
36
+ export type TerpRouteParams<P extends TerpRoutePath> = P extends keyof TerpRouteTable
37
+ ? TerpRouteTable[P]
38
+ : Record<string, string>;
39
+
40
+ /**
41
+ * Every param name declared by any route — or `string` before generating.
42
+ *
43
+ * This is the union across all routes, not per route: it refuses a name no route
44
+ * declares (the reported failure mode, where a typo silently yielded `undefined`),
45
+ * while a name belonging to a *different* route stays a runtime refusal. Read params
46
+ * through {@link useRouteParams} when you want the exact, per-route check.
47
+ */
48
+ export type TerpRouteParamName = Ungenerated extends true
49
+ ? string
50
+ : { [P in keyof TerpRouteTable]: keyof TerpRouteTable[P] }[keyof TerpRouteTable] & string;
51
+
52
+ /**
53
+ * A navigation target: a declared path, plus that path's params when it takes any.
54
+ * A paramless route refuses a `params` object; a parameterised one requires it, with
55
+ * the names the manifest declared. Before generating, this is the loose shape.
56
+ */
57
+ export type TerpNavigateTarget = Ungenerated extends true
58
+ ? { to: string; params?: Record<string, string> }
59
+ : {
60
+ [P in keyof TerpRouteTable & string]: keyof TerpRouteTable[P] extends never
61
+ ? { to: P; params?: undefined }
62
+ : { to: P; params: TerpRouteTable[P] };
63
+ }[keyof TerpRouteTable & string];
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { ModuleManifest } from "@terpjs/contract";
7
7
 
8
- import { buildAppRouter } from "./router";
8
+ import { buildAppRouter, useRouteParam, useRouteParams, useTerpNavigate } from "./router";
9
9
  import { Page } from "./Page";
10
10
  import { TerpProvider, useAuth } from "./TerpProvider";
11
11
 
@@ -16,6 +16,22 @@ function jsonResponse(body: unknown): Response {
16
16
  });
17
17
  }
18
18
 
19
+ /** The login + session-probe fetch a routed test needs to reach a guarded view. */
20
+ function sessionFetch() {
21
+ return vi.fn<typeof fetch>(async (input) => {
22
+ const url = (input as Request).url;
23
+ if (url.endsWith("/api/v1/auth/login")) {
24
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
25
+ }
26
+ return jsonResponse({
27
+ id: "1",
28
+ email: "editor@example.com",
29
+ role_rank: 20,
30
+ role_name: "editor",
31
+ });
32
+ });
33
+ }
34
+
19
35
  afterEach(() => {
20
36
  cleanup();
21
37
  vi.restoreAllMocks();
@@ -126,6 +142,167 @@ describe("buildAppRouter", () => {
126
142
  }
127
143
  });
128
144
 
145
+ it("useRouteParam reads a declared param and refuses an undeclared name, fail closed", async () => {
146
+ // buildAppRouter realises routes at runtime, so TanStack's type registry cannot
147
+ // check a param name for any app — useRouteParam is the sanctioned read: the
148
+ // declared param comes back, an undeclared name throws a directive error instead
149
+ // of silently yielding undefined (the failure mode of the raw `as {...}` cast).
150
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
151
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
152
+ const url = (input as Request).url;
153
+ if (url.endsWith("/api/v1/auth/login")) {
154
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
155
+ }
156
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
157
+ });
158
+ vi.stubGlobal("fetch", fetchMock);
159
+
160
+ function ThingView() {
161
+ const thingId = useRouteParam("thingId");
162
+ return <Page title={`Thing ${thingId}`}>thing body</Page>;
163
+ }
164
+ const router = buildAppRouter(
165
+ [{ name: "things", routes: [{ path: "/things/:thingId", view: "Thing" }], nav: [] }],
166
+ {
167
+ views: { Thing: ThingView },
168
+ title: "Terp",
169
+ history: createMemoryHistory({ initialEntries: ["/things/abc"] }),
170
+ },
171
+ );
172
+ render(
173
+ <TerpProvider baseUrl="https://api.test">
174
+ <LogInOnMount />
175
+ <RouterProvider router={router} />
176
+ </TerpProvider>,
177
+ );
178
+ await waitFor(() =>
179
+ expect(screen.getByRole("heading", { name: "Thing abc" })).toBeInTheDocument(),
180
+ );
181
+ cleanup();
182
+
183
+ function WrongParamView() {
184
+ const nope = useRouteParam("thisParamDoesNotExist");
185
+ return <Page title={`Wrong ${nope}`}>wrong body</Page>;
186
+ }
187
+ const wrongRouter = buildAppRouter(
188
+ [{ name: "things", routes: [{ path: "/things/:thingId", view: "Thing" }], nav: [] }],
189
+ {
190
+ views: { Thing: WrongParamView },
191
+ title: "Terp",
192
+ history: createMemoryHistory({ initialEntries: ["/things/abc"] }),
193
+ },
194
+ );
195
+ render(
196
+ <TerpProvider baseUrl="https://api.test">
197
+ <LogInOnMount />
198
+ <RouterProvider router={wrongRouter} />
199
+ </TerpProvider>,
200
+ );
201
+ // The view throws before it can render; the screen never shows the wrong body.
202
+ await waitFor(() => expect(console.error).toHaveBeenCalled());
203
+ expect(screen.queryByRole("heading", { name: /Wrong/ })).not.toBeInTheDocument();
204
+ });
205
+
206
+ it("useRouteParams reads a whole declared route's params, and refuses a stale one", async () => {
207
+ // The exact read (ADR 0092): keyed by the manifest path, every param that path
208
+ // declares must be present. A generated table that no longer matches the manifest
209
+ // therefore fails closed naming the param, instead of leaking undefined into a request.
210
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
211
+ vi.stubGlobal("fetch", sessionFetch());
212
+
213
+ function PairView() {
214
+ const { spaceId, itemId } = useRouteParams("/spaces/:spaceId/items/:itemId");
215
+ return <Page title={`${spaceId}/${itemId}`}>pair body</Page>;
216
+ }
217
+ render(
218
+ <TerpProvider baseUrl="https://api.test">
219
+ <LogInOnMount />
220
+ <RouterProvider
221
+ router={buildAppRouter(
222
+ [{ name: "spaces", routes: [{ path: "/spaces/:spaceId/items/:itemId", view: "Pair" }] }],
223
+ {
224
+ views: { Pair: PairView },
225
+ title: "Terp",
226
+ history: createMemoryHistory({ initialEntries: ["/spaces/s1/items/i9"] }),
227
+ },
228
+ )}
229
+ />
230
+ </TerpProvider>,
231
+ );
232
+ await waitFor(() =>
233
+ expect(screen.getByRole("heading", { name: "s1/i9" })).toBeInTheDocument(),
234
+ );
235
+ cleanup();
236
+
237
+ // The same view under a route that declares only one of the two params.
238
+ render(
239
+ <TerpProvider baseUrl="https://api.test">
240
+ <LogInOnMount />
241
+ <RouterProvider
242
+ router={buildAppRouter(
243
+ [{ name: "spaces", routes: [{ path: "/spaces/:spaceId", view: "Pair" }] }],
244
+ {
245
+ views: { Pair: PairView },
246
+ title: "Terp",
247
+ history: createMemoryHistory({ initialEntries: ["/spaces/s1"] }),
248
+ },
249
+ )}
250
+ />
251
+ </TerpProvider>,
252
+ );
253
+ await waitFor(() => expect(console.error).toHaveBeenCalled());
254
+ expect(screen.queryByRole("heading", { name: /s1/ })).not.toBeInTheDocument();
255
+ });
256
+
257
+ it("useTerpNavigate navigates by the manifest's path spelling, carrying params", async () => {
258
+ // The manifest spells a param `:id`; TanStack wants `$id`. useTerpNavigate takes the
259
+ // manifest spelling (what the generated table is keyed by) and translates, so a caller
260
+ // never holds two spellings — and the params ride along.
261
+ vi.stubGlobal("fetch", sessionFetch());
262
+
263
+ function ListView() {
264
+ const navigate = useTerpNavigate();
265
+ return (
266
+ <Page title="Things view">
267
+ <button type="button" onClick={() => void navigate({ to: "/things/:thingId", params: { thingId: "abc" } })}>
268
+ open abc
269
+ </button>
270
+ </Page>
271
+ );
272
+ }
273
+ function DetailView() {
274
+ return <Page title={`Thing ${useRouteParam("thingId")}`}>thing body</Page>;
275
+ }
276
+ render(
277
+ <TerpProvider baseUrl="https://api.test">
278
+ <LogInOnMount />
279
+ <RouterProvider
280
+ router={buildAppRouter(
281
+ [
282
+ {
283
+ name: "things",
284
+ routes: [
285
+ { path: "/things", view: "ThingsList" },
286
+ { path: "/things/:thingId", view: "ThingDetail" },
287
+ ],
288
+ },
289
+ ],
290
+ {
291
+ views: { ThingsList: ListView, ThingDetail: DetailView },
292
+ title: "Terp",
293
+ history: createMemoryHistory({ initialEntries: ["/things"] }),
294
+ },
295
+ )}
296
+ />
297
+ </TerpProvider>,
298
+ );
299
+
300
+ fireEvent.click(await screen.findByRole("button", { name: "open abc" }));
301
+ await waitFor(() =>
302
+ expect(screen.getByRole("heading", { name: "Thing abc" })).toBeInTheDocument(),
303
+ );
304
+ });
305
+
129
306
  it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
130
307
  // A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
131
308
  // page reload, silently, with nothing to catch it. Inside a Terp router the default
package/src/router.tsx CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  createRouter,
5
5
  Link,
6
6
  Outlet,
7
+ useNavigate,
8
+ useParams,
7
9
  useRouter,
8
10
  type AnyRoute,
9
11
  type RouterHistory,
@@ -14,6 +16,12 @@ import type { ModuleManifest } from "@terpjs/contract";
14
16
 
15
17
  import { AppShell } from "./AppShell";
16
18
  import { ProfileView } from "./ProfileView";
19
+ import type {
20
+ TerpNavigateTarget,
21
+ TerpRouteParamName,
22
+ TerpRouteParams,
23
+ TerpRoutePath,
24
+ } from "./routeTypes";
17
25
  import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
18
26
  import { visibleNav } from "./nav";
19
27
  import { NavLinkContext } from "./navLink";
@@ -47,6 +55,123 @@ export function routerPath(path: string): string {
47
55
  return path.replace(/(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g, "$1$$$2");
48
56
  }
49
57
 
58
+ /** Read the router's params bag, whatever route matched (a hook: it reads router state). */
59
+ function useCurrentParams(): Record<string, string | undefined> {
60
+ return useParams({ strict: false }) as Record<string, string | undefined>;
61
+ }
62
+
63
+ /** The shared refusal both param reads raise, phrased as a directive. */
64
+ function missingParam(name: string, params: Record<string, string | undefined>): Error {
65
+ const seen = Object.keys(params);
66
+ return new Error(
67
+ `Route param "${name}" is not present on the current route (params seen: ` +
68
+ `${seen.length > 0 ? seen.join(", ") : "none"}). A param is declared in the ` +
69
+ `module manifest's route path (e.g. "/records/:${name}") and must be read ` +
70
+ "under that route — check the name against the manifest, and run `terp routes` " +
71
+ "if the manifest changed.",
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Read one route param by name, fail closed — the untyped core.
77
+ *
78
+ * Internal on purpose. The app-facing {@link useRouteParam} checks the name against the
79
+ * app's generated route table, and this package's own packaged screens (the admin area)
80
+ * must NOT be checked against it: their routes come from this package's manifest, not
81
+ * from the app's, so constraining them to the app's declared params would fail an app
82
+ * that simply declares no params of its own. A packaged screen's route correctness is
83
+ * this package's own test surface.
84
+ */
85
+ export function useDeclaredParam(name: string): string {
86
+ const params = useCurrentParams();
87
+ const value = params[name];
88
+ if (value === undefined) {
89
+ throw missingParam(name, params);
90
+ }
91
+ return value;
92
+ }
93
+
94
+ /**
95
+ * Read one route param, fail closed when it is absent.
96
+ *
97
+ * The name is checked against the app's generated route table when there is one
98
+ * ({@link TerpRouteParamName} — every param name any manifest route declares), and is
99
+ * a plain `string` before `terp routes` has generated. Either way a name the *current*
100
+ * route did not declare throws a directive error instead of silently yielding
101
+ * `undefined`, which is what the raw `useParams({ strict: false }) as {…}` cast did.
102
+ *
103
+ * Reach for {@link useRouteParams} when you want the exact, per-route check: keyed by
104
+ * the route path, it refuses a param that route does not declare, not merely one no
105
+ * route declares.
106
+ */
107
+ export function useRouteParam(name: TerpRouteParamName): string {
108
+ return useDeclaredParam(name);
109
+ }
110
+
111
+ /**
112
+ * Read a declared route's params as a typed object — the exact read (ADR 0092).
113
+ *
114
+ * ```tsx
115
+ * const { recordId } = useRouteParams("/records/:recordId");
116
+ * ```
117
+ *
118
+ * Once `terp routes` has generated the app's route table, the path must be one the
119
+ * manifests declare and the returned object carries exactly that route's params, so a
120
+ * typo in either the path or a param name is a typecheck error rather than a runtime
121
+ * surprise. Before generating, the path is a plain `string` and the result is a
122
+ * string-keyed record.
123
+ *
124
+ * The path is the manifest's stack-agnostic spelling (`:recordId`). Every declared
125
+ * param must be present at runtime; a missing one fails closed, which is how a stale
126
+ * generated table (manifest changed, `terp routes` not re-run) surfaces as an error
127
+ * naming the param instead of `undefined` flowing into a request.
128
+ */
129
+ export function useRouteParams<P extends TerpRoutePath>(path: P): TerpRouteParams<P> {
130
+ const params = useCurrentParams();
131
+ const declared = declaredParamNames(path);
132
+ const resolved: Record<string, string> = {};
133
+ for (const name of declared) {
134
+ const value = params[name];
135
+ if (value === undefined) {
136
+ throw missingParam(name, params);
137
+ }
138
+ resolved[name] = value;
139
+ }
140
+ return resolved as TerpRouteParams<P>;
141
+ }
142
+
143
+ /** The param names a manifest path declares, in declaration order (`:name` segments). */
144
+ function declaredParamNames(path: string): string[] {
145
+ return [...path.matchAll(/(?:^|\/)[:$]([A-Za-z_][A-Za-z0-9_]*)/g)].map((match) => match[1]!);
146
+ }
147
+
148
+ /**
149
+ * Navigate to a declared route, with that route's params (ADR 0092).
150
+ *
151
+ * ```tsx
152
+ * const navigate = useTerpNavigate();
153
+ * void navigate({ to: "/records/:recordId", params: { recordId: row.id } });
154
+ * ```
155
+ *
156
+ * The reason to prefer this over the router's own `navigate`: once the route table is
157
+ * generated, an undeclared path is a typecheck error, a parameterised route *requires*
158
+ * its params, and the param names are the manifest's. A typo'd path was previously a
159
+ * dead link that shipped green — nothing checked it, because the route tree is built at
160
+ * runtime. Paths are written in the manifest spelling and translated to the router's
161
+ * dialect here ({@link routerPath}), so callers never hold two spellings.
162
+ */
163
+ export function useTerpNavigate(): (target: TerpNavigateTarget) => Promise<void> {
164
+ const navigate = useNavigate();
165
+ return (target: TerpNavigateTarget) =>
166
+ navigate({
167
+ to: routerPath(target.to),
168
+ // The reducer form, not a bare object: on a router whose route tree is built at
169
+ // runtime TanStack types `params` as a reducer (or `true`), and merging over the
170
+ // previous params is also the honest semantic for an in-place param change.
171
+ params: (previous: Record<string, unknown>) => ({ ...previous, ...(target.params ?? {}) }),
172
+ });
173
+ }
174
+
50
175
  export interface BuildAppRouterOptions {
51
176
  /** Maps a manifest route's `view` id to the component that renders it. */
52
177
  views: Record<string, ComponentType>;
package/src/ui/Badge.tsx CHANGED
@@ -26,7 +26,11 @@ const toneColor: Record<BadgeTone, string> = {
26
26
  danger: "var(--color-status-danger)",
27
27
  };
28
28
 
29
- const toneSoft: Record<BadgeTone, string> = {
29
+ /**
30
+ * Soft tint per tone — exported (not via the package barrel) so DataView's row/card
31
+ * tinting resolves a tone to the exact same tokens the Badge pill uses.
32
+ */
33
+ export const toneSoftColors: Record<BadgeTone, string> = {
30
34
  neutral: "var(--color-neutral-100)",
31
35
  info: "var(--color-status-info-soft)",
32
36
  success: "var(--color-status-success-soft)",
@@ -37,11 +41,11 @@ const toneSoft: Record<BadgeTone, string> = {
37
41
  const badgeStyle = (tone: BadgeTone): CSSProperties => ({
38
42
  display: "inline-flex",
39
43
  alignItems: "center",
40
- border: `1px solid ${toneSoft[tone]}`,
44
+ border: `1px solid ${toneSoftColors[tone]}`,
41
45
  borderRadius: "var(--radius-full)",
42
46
  padding: "2px var(--space-2)",
43
47
  color: toneColor[tone],
44
- background: toneSoft[tone],
48
+ background: toneSoftColors[tone],
45
49
  fontSize: "var(--font-size-xs)",
46
50
  fontWeight: "var(--font-weight-semibold)" as never,
47
51
  lineHeight: 1.4,
package/src/ui/Card.tsx CHANGED
@@ -52,9 +52,11 @@ const descriptionStyle: CSSProperties = {
52
52
 
53
53
  /**
54
54
  * A token-styled surface that groups one block of a page — the sanctioned way to give
55
- * sections visual separation (border + background + padding) without module CSS. An
56
- * optional header row carries a semantic `<h3>` title, a muted description and an
57
- * `actions` slot; the body stacks its children on the token spacing scale.
55
+ * sections visual separation (border + background + padding) without module CSS, and
56
+ * allowed directly in `OverviewPage` / `DetailPage` body slots under the `standard`
57
+ * layout contract. An optional header row carries a semantic `<h3>` title, a muted
58
+ * description and an `actions` slot; the body stacks its children on the token
59
+ * spacing scale.
58
60
  */
59
61
  export function Card({
60
62
  title,
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { ApiError, unwrap } from "./unwrap";
3
+ import { ApiError, unwrap, unwrapOptional } from "./unwrap";
4
4
 
5
5
  function response(status: number): Response {
6
6
  return new Response(null, { status });
@@ -65,3 +65,34 @@ describe("unwrap", () => {
65
65
  expect(apiError.message).toBe("Row changed.");
66
66
  });
67
67
  });
68
+
69
+ describe("unwrapOptional", () => {
70
+ it("returns the data on a 2xx result, like unwrap", () => {
71
+ expect(unwrapOptional({ data: { id: "s1" }, response: response(200) })).toEqual({
72
+ id: "s1",
73
+ });
74
+ });
75
+
76
+ it("returns null on a 404 — absence is a normal state, not a failure", () => {
77
+ expect(
78
+ unwrapOptional({
79
+ error: { code: "not_found", detail: "No snapshot published yet." },
80
+ response: response(404),
81
+ }),
82
+ ).toBeNull();
83
+ });
84
+
85
+ it("throws the same ApiError as unwrap for every other failure", () => {
86
+ let caught: unknown;
87
+ try {
88
+ unwrapOptional({
89
+ error: { code: "permission_denied", detail: "You do not have permission." },
90
+ response: response(403),
91
+ });
92
+ } catch (error) {
93
+ caught = error;
94
+ }
95
+ expect(caught).toBeInstanceOf(ApiError);
96
+ expect((caught as ApiError).status).toBe(403);
97
+ });
98
+ });
package/src/unwrap.ts CHANGED
@@ -40,6 +40,22 @@ export class ApiError extends Error {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * {@link unwrap} for a resource whose absence is a normal state, not a failure: returns
45
+ * the data on success, `null` on a 404, and throws the same {@link ApiError} for every
46
+ * other failure. The client-side analog of the backend's `BaseService.find` beside
47
+ * `get` — reach for `unwrap` when a missing record ends the request, `unwrapOptional`
48
+ * when "not there yet" is an answer (a `/latest` snapshot that has not been published,
49
+ * an optional singleton). Without it, expressing that state means exception control
50
+ * flow around `unwrap` at every call site.
51
+ */
52
+ export function unwrapOptional<T>(result: FetchResult<T>): T | null {
53
+ if (result.response.status === 404) {
54
+ return null;
55
+ }
56
+ return unwrap(result);
57
+ }
58
+
43
59
  /** Return the result's `data` on success, or throw an {@link ApiError} describing the failure. */
44
60
  export function unwrap<T>(result: FetchResult<T>): T {
45
61
  if (result.error !== undefined || !result.response.ok) {
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { useRecord } from "./useRecord";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("useRecord", () => {
10
+ it("loads on mount and exposes the record", async () => {
11
+ const { result } = renderHook(() =>
12
+ useRecord<{ id: string }>({ get: async () => ({ id: "r1" }) }),
13
+ );
14
+ expect(result.current.loading).toBe(true);
15
+ expect(result.current.item).toBeNull();
16
+ await waitFor(() => expect(result.current.loading).toBe(false));
17
+ expect(result.current.item).toEqual({ id: "r1" });
18
+ expect(result.current.error).toBeNull();
19
+ });
20
+
21
+ it("treats a null get as a normal absent state, not an error (unwrapOptional composes)", async () => {
22
+ const { result } = renderHook(() => useRecord<{ id: string }>({ get: async () => null }));
23
+ await waitFor(() => expect(result.current.loading).toBe(false));
24
+ expect(result.current.item).toBeNull();
25
+ expect(result.current.error).toBeNull();
26
+ });
27
+
28
+ it("reloads when a declared dependency changes (in-place route-param navigation)", async () => {
29
+ const get = vi.fn(async (id: string) => ({ id }));
30
+ const { result, rerender } = renderHook(
31
+ ({ id }: { id: string }) => useRecord({ get: () => get(id) }, [id]),
32
+ { initialProps: { id: "r1" } },
33
+ );
34
+ await waitFor(() => expect(result.current.item).toEqual({ id: "r1" }));
35
+
36
+ rerender({ id: "r2" });
37
+ await waitFor(() => expect(result.current.item).toEqual({ id: "r2" }));
38
+ expect(get).toHaveBeenCalledTimes(2);
39
+
40
+ // A rerender with the same dependency does not refetch.
41
+ rerender({ id: "r2" });
42
+ await waitFor(() => expect(result.current.loading).toBe(false));
43
+ expect(get).toHaveBeenCalledTimes(2);
44
+ });
45
+
46
+ it("captures a get error as a message and its cause", async () => {
47
+ const { result } = renderHook(() =>
48
+ useRecord<string>({
49
+ get: async () => {
50
+ throw new Error("boom");
51
+ },
52
+ }),
53
+ );
54
+ await waitFor(() => expect(result.current.error).toBe("boom"));
55
+ expect(result.current.cause).toBeInstanceOf(Error);
56
+ expect(result.current.item).toBeNull();
57
+ });
58
+
59
+ it("mutate surfaces write failures, rejects, and reloads on success", async () => {
60
+ let label = "before";
61
+ const { result } = renderHook(() => useRecord({ get: async () => ({ label }) }));
62
+ await waitFor(() => expect(result.current.loading).toBe(false));
63
+
64
+ await act(async () => {
65
+ await expect(
66
+ result.current.mutate(async () => {
67
+ throw new Error("Save failed.");
68
+ }),
69
+ ).rejects.toThrow("Save failed.");
70
+ });
71
+ expect(result.current.error).toBe("Save failed.");
72
+
73
+ await act(async () => {
74
+ await result.current.mutate(async () => {
75
+ label = "after";
76
+ });
77
+ });
78
+ expect(result.current.item).toEqual({ label: "after" });
79
+ expect(result.current.error).toBeNull();
80
+ });
81
+ });