ruact 0.0.3 → 0.0.4

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.
@@ -16,6 +16,13 @@
16
16
  // import path are part of the locked API — do NOT change without coordinated
17
17
  // codegen + Vite-plugin updates.
18
18
 
19
+ // Story 9.5 — `useQuery` is a React hook, so the runtime now depends on React.
20
+ // React is declared as a `peerDependency` (every host already has it; the
21
+ // runtime never bundles its own copy). This is the runtime's first React
22
+ // import — the mutation path (`_makeRef` / `_makeServerFunction`) stays
23
+ // React-free.
24
+ import { useState, useEffect } from "react";
25
+
19
26
  const RUNTIME_VERSION = 1;
20
27
 
21
28
  // Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
@@ -149,6 +156,91 @@ export function _makeServerFunction(descriptor) {
149
156
  };
150
157
  }
151
158
 
159
+ /**
160
+ * Story 9.5 — the read-side (query) accessor. The codegen emits
161
+ * `_makeQuery({ path, kind: "query" })` for every method on a mounted
162
+ * `Ruact::Query` subclass. The returned callable issues a **GET** to the
163
+ * named query route (`GET /q/<jsId>`), serializing `params` into the query
164
+ * string.
165
+ *
166
+ * Reads are CSRF-free (NFR27 / the 2026-06-02 ADR addendum voids the old
167
+ * `POST /__ruact/fn/:id` query mechanism and restores HTTP GET semantics):
168
+ * no request body, no `X-CSRF-Token`. It shares `parseResponse` /
169
+ * `RuactActionError` / `redirect: "error"` with the mutation path so the
170
+ * success + failure shapes are identical.
171
+ *
172
+ * Usually consumed through {useQuery}, but the returned function is a plain
173
+ * `(params?) => Promise<unknown>` so it also works in imperative code.
174
+ *
175
+ * FR88 wire format: only primitives (string / number / boolean / null) are
176
+ * encoded into the query string; arrays and objects throw a `TypeError`
177
+ * client-side too — though the server-side sanitization is authoritative.
178
+ *
179
+ * @param {{ path: string, kind?: string }} descriptor
180
+ * @returns {(params?: Record<string, unknown>) => Promise<unknown>}
181
+ */
182
+ export function _makeQuery(descriptor) {
183
+ const { path } = descriptor || {};
184
+ return function ruactQueryCall(params) {
185
+ const url = buildQueryUrl(path, params);
186
+ return ruactQueryGet(url, path);
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Story 9.5 — React hook for reading a server query. Pass a query reference
192
+ * (the codegen-emitted `_makeQuery` accessor) and optional params; the hook
193
+ * issues `GET /q/<jsId>` on mount (and whenever the serialized params change)
194
+ * and returns `{ data, loading, error }`:
195
+ *
196
+ * - `loading` is true until the first resolution;
197
+ * - `data` carries the JSON-decoded response on success (and the last
198
+ * successful value while a subsequent refetch is in flight);
199
+ * - `error` carries the structured `RuactActionError` (or a transport
200
+ * `Error`) on failure, and is reset to `null` on a successful refetch.
201
+ *
202
+ * A superseded in-flight response (params changed, or the component
203
+ * unmounted) is dropped — the hook never sets state for a stale request.
204
+ *
205
+ * Request de-duplication across components is Story 9.6; this hook fetches
206
+ * once per mount.
207
+ *
208
+ * @param {(params?: Record<string, unknown>) => Promise<unknown>} reference
209
+ * @param {Record<string, unknown>} [params]
210
+ * @returns {{ data: unknown, loading: boolean, error: unknown }}
211
+ */
212
+ export function useQuery(reference, params) {
213
+ const [state, setState] = useState({ data: undefined, loading: true, error: null });
214
+
215
+ // Re-run the effect by VALUE, not identity: an inline `{ q: input }` literal
216
+ // is a fresh object every render, which would refetch on every render if used
217
+ // directly as a dependency. Serializing collapses equal params to one key.
218
+ const paramsKey = params == null ? "" : JSON.stringify(params);
219
+
220
+ useEffect(() => {
221
+ let active = true;
222
+ setState((prev) => ({ data: prev.data, loading: true, error: null }));
223
+ // Wrap in Promise.resolve so a synchronous throw inside `reference`
224
+ // (e.g. a non-primitive param rejected by `buildQueryUrl`) lands on the
225
+ // error branch instead of escaping the effect.
226
+ Promise.resolve()
227
+ .then(() => reference(params))
228
+ .then((data) => {
229
+ if (active) setState({ data, loading: false, error: null });
230
+ })
231
+ .catch((error) => {
232
+ if (active) setState((prev) => ({ data: prev.data, loading: false, error }));
233
+ });
234
+ return () => {
235
+ active = false;
236
+ };
237
+ // `params` is intentionally tracked via the serialized `paramsKey`.
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, [reference, paramsKey]);
240
+
241
+ return state;
242
+ }
243
+
152
244
  // Story 8.2 — picks the argument the wire request should serialize from
153
245
  // `_makeRef`'s call-args, following the rules documented in the JSDoc
154
246
  // above. Exported through `__internals` for the vitest suite (AC10) — it
@@ -218,6 +310,8 @@ export const __internals = {
218
310
  pickWirePayload,
219
311
  interpolatePath,
220
312
  followRedirectIfPresent,
313
+ buildQueryUrl,
314
+ buildQueryFetchInit,
221
315
  };
222
316
 
223
317
  // v1 (Story 8.1) — POST to the synthetic endpoint. A thin wrapper over the
@@ -265,6 +359,88 @@ async function ruactInvoke({ method, url, args, label }) {
265
359
  return parseResponse(response);
266
360
  }
267
361
 
362
+ // Story 9.5 — the GET fetch core for queries. Mirrors `ruactInvoke`'s
363
+ // structure (loud transport error, structured `RuactActionError` on !ok,
364
+ // shared `parseResponse`) but for a read: GET, no body, no CSRF.
365
+ async function ruactQueryGet(url, label) {
366
+ let response;
367
+ try {
368
+ response = await fetch(url, buildQueryFetchInit());
369
+ } catch (err) {
370
+ throw new Error(
371
+ `ruact query ${label} request failed: ${err?.message ?? err}`,
372
+ );
373
+ }
374
+ if (!response.ok) {
375
+ const body = await safeParseBody(response);
376
+ throw new RuactActionError({ name: label, status: response.status, body, response });
377
+ }
378
+ return parseResponse(response);
379
+ }
380
+
381
+ // Story 9.5 — serialize the params object into the GET query string. FR88
382
+ // wire format: only primitives (string / number / boolean / null) are
383
+ // encoded. Arrays and objects throw a `TypeError` client-side for immediate
384
+ // local feedback; the server-side sanitization in `query_dispatch.rb` is the
385
+ // authoritative gate. `null` is encoded as an empty value (`key=`).
386
+ function buildQueryUrl(path, params) {
387
+ if (params == null) return path;
388
+ if (typeof params !== "object" || Array.isArray(params)) {
389
+ throw new TypeError(
390
+ `ruact useQuery for ${path}: params must be a plain object of ` +
391
+ "string / number / boolean / null values",
392
+ );
393
+ }
394
+ const search = new URLSearchParams();
395
+ // `null` is sent as a BARE key (`?q`, no `=`) — Rack parses that as `nil`,
396
+ // whereas `?q=` parses as `""`. This keeps `null` distinguishable from the
397
+ // empty string at the Ruby query-method boundary (the server allowlist
398
+ // accepts both `nil` and `String`). Bare keys are collected separately
399
+ // because `URLSearchParams` always emits `key=`; their order relative to
400
+ // the `=`-valued params is irrelevant server-side.
401
+ const bareKeys = [];
402
+ for (const [key, value] of Object.entries(params)) {
403
+ if (value === undefined) continue;
404
+ if (value === null) {
405
+ bareKeys.push(encodeURIComponent(key));
406
+ } else if (
407
+ typeof value === "string" ||
408
+ typeof value === "number" ||
409
+ typeof value === "boolean"
410
+ ) {
411
+ search.append(key, String(value));
412
+ } else {
413
+ throw new TypeError(
414
+ `ruact useQuery for ${path}: param "${key}" must be a string, number, ` +
415
+ "boolean, or null — arrays and objects are rejected",
416
+ );
417
+ }
418
+ }
419
+ const qs = [search.toString(), ...bareKeys].filter((s) => s.length > 0).join("&");
420
+ return qs.length === 0 ? path : `${path}?${qs}`;
421
+ }
422
+
423
+ // Story 9.5 — fetch init for a query GET. No body, no `X-CSRF-Token` (reads
424
+ // are CSRF-free). `defaultHeaders` still apply (e.g. an API-mode
425
+ // `Authorization: Bearer …`), with the gem's own `Accept` winning. Keeps
426
+ // `redirect: "error"` so an auth `redirect_to "/login"` surfaces as a loud
427
+ // failure rather than resolving with the login page HTML.
428
+ function buildQueryFetchInit() {
429
+ const RESERVED = new Set(["accept"]);
430
+ const extra =
431
+ typeof runtimeOptions.defaultHeaders === "function"
432
+ ? runtimeOptions.defaultHeaders()
433
+ : runtimeOptions.defaultHeaders;
434
+ const headers = {};
435
+ if (extra && typeof extra === "object") {
436
+ for (const [key, value] of Object.entries(extra)) {
437
+ if (!RESERVED.has(key.toLowerCase())) headers[key] = value;
438
+ }
439
+ }
440
+ headers.Accept = "application/json";
441
+ return { method: "GET", credentials: "same-origin", redirect: "error", headers };
442
+ }
443
+
268
444
  // Story 9.3 (D7) — substitute dynamic path segments (`:id`, …) from the single
269
445
  // call argument. Values are read BY NAME (FormData.get / object property); the
270
446
  // argument is still sent as the body. A missing required segment fails loudly
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruact-server-functions-runtime",
3
- "version": "0.2.0",
4
- "description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2). Provides `_makeRef(name)` (POSTs to `/__ruact/fn/:name` with CSRF + JSON / FormData support, including the useActionState two-arg shape) and `revalidate(path?)` (Flight refetch via the installed ruact-router).",
3
+ "version": "0.3.0",
4
+ "description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2 + 9.5). Provides `_makeRef(name)` / `_makeServerFunction(descriptor)` (mutations: POST/PUT/PATCH/DELETE with CSRF + JSON / FormData), `revalidate(path?)` (Flight refetch), and `_makeQuery(descriptor)` + `useQuery(ref, params?)` (reads: GET /q/<jsId>, CSRF-free, FR88 primitive params → { data, loading, error }).",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "./index.d.ts",
@@ -16,7 +16,14 @@
16
16
  "scripts": {
17
17
  "test": "vitest run"
18
18
  },
19
+ "peerDependencies": {
20
+ "react": ">=18"
21
+ },
19
22
  "devDependencies": {
23
+ "@testing-library/react": "^16.1.0",
24
+ "jsdom": "^25.0.1",
25
+ "react": "^19.0.0",
26
+ "react-dom": "^19.0.0",
20
27
  "vitest": "^2.1.9"
21
28
  }
22
29
  }
@@ -0,0 +1,181 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Story 9.5 — vitest coverage for the read-side runtime: the `useQuery` React
4
+ // hook (loading/data/error transitions, param passing, value-stable refetch)
5
+ // and the `_makeQuery` GET wire format (query-string encoding, FR88 primitive
6
+ // allowlist, CSRF-free init). Runs under jsdom so `@testing-library/react`'s
7
+ // `renderHook` can drive the hook through React's effect lifecycle.
8
+
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+ import { renderHook, waitFor } from "@testing-library/react";
11
+
12
+ import { useQuery, _makeQuery, RuactActionError, __internals } from "./index.js";
13
+
14
+ let originalFetch;
15
+
16
+ beforeEach(() => {
17
+ originalFetch = globalThis.fetch;
18
+ });
19
+
20
+ afterEach(() => {
21
+ globalThis.fetch = originalFetch;
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ function mockFetchOk(jsonBody, { status = 200, contentType = "application/json" } = {}) {
26
+ const response = {
27
+ ok: true,
28
+ status,
29
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? contentType : null) },
30
+ text: vi.fn().mockResolvedValue(typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody)),
31
+ };
32
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
33
+ return response;
34
+ }
35
+
36
+ function mockFetchError(status, bodyText) {
37
+ const response = {
38
+ ok: false,
39
+ status,
40
+ headers: { get: () => "text/plain" },
41
+ text: vi.fn().mockResolvedValue(bodyText),
42
+ };
43
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
44
+ return response;
45
+ }
46
+
47
+ describe("Story 9.5 — useQuery hook contract", () => {
48
+ it("starts loading, then resolves to { data, loading: false, error: null }", async () => {
49
+ const ref = vi.fn().mockResolvedValue({ items: [1, 2] });
50
+ const { result } = renderHook(() => useQuery(ref));
51
+
52
+ expect(result.current.loading).toBe(true);
53
+ expect(result.current.data).toBeUndefined();
54
+
55
+ await waitFor(() => expect(result.current.loading).toBe(false));
56
+ expect(result.current.data).toEqual({ items: [1, 2] });
57
+ expect(result.current.error).toBe(null);
58
+ });
59
+
60
+ it("transitions loading → error on a rejected reference", async () => {
61
+ const err = new RuactActionError({ name: "/q/categories", status: 500, body: "boom", response: {} });
62
+ const ref = vi.fn().mockRejectedValue(err);
63
+ const { result } = renderHook(() => useQuery(ref));
64
+
65
+ await waitFor(() => expect(result.current.loading).toBe(false));
66
+ expect(result.current.error).toBe(err);
67
+ expect(result.current.data).toBeUndefined();
68
+ });
69
+
70
+ it("passes params to the query reference", async () => {
71
+ const ref = vi.fn().mockResolvedValue("ok");
72
+ const params = { q: "ruby", limit: 5 };
73
+ renderHook(() => useQuery(ref, params));
74
+
75
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
76
+ expect(ref).toHaveBeenCalledWith(params);
77
+ });
78
+
79
+ it("does not refetch when params are value-equal across renders", async () => {
80
+ const ref = vi.fn().mockResolvedValue("ok");
81
+ const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
82
+ initialProps: { p: { q: "a" } },
83
+ });
84
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
85
+
86
+ rerender({ p: { q: "a" } }); // fresh object literal, identical value
87
+ await Promise.resolve();
88
+ expect(ref).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it("refetches when params change by value", async () => {
92
+ const ref = vi.fn().mockResolvedValue("ok");
93
+ const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
94
+ initialProps: { p: { q: "a" } },
95
+ });
96
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
97
+
98
+ rerender({ p: { q: "b" } });
99
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(2));
100
+ expect(ref).toHaveBeenLastCalledWith({ q: "b" });
101
+ });
102
+
103
+ it("surfaces a synchronous throw from the reference as an error (not an unhandled rejection)", async () => {
104
+ const ref = () => {
105
+ throw new TypeError("params must be a plain object");
106
+ };
107
+ const { result } = renderHook(() => useQuery(ref, [1, 2]));
108
+ await waitFor(() => expect(result.current.loading).toBe(false));
109
+ expect(result.current.error).toBeInstanceOf(TypeError);
110
+ });
111
+ });
112
+
113
+ describe("Story 9.5 — useQuery against _makeQuery end-to-end (GET wire)", () => {
114
+ it("issues GET /q/<id>?<params> and resolves the JSON body through the hook", async () => {
115
+ mockFetchOk([{ value: 1, label: "Books" }]);
116
+ const categories = _makeQuery({ path: "/q/categories", kind: "query" });
117
+
118
+ const { result } = renderHook(() => useQuery(categories, { q: "bo" }));
119
+ await waitFor(() => expect(result.current.loading).toBe(false));
120
+
121
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
122
+ const [url, init] = globalThis.fetch.mock.calls[0];
123
+ expect(url).toBe("/q/categories?q=bo");
124
+ expect(init.method).toBe("GET");
125
+ expect(result.current.data).toEqual([{ value: 1, label: "Books" }]);
126
+ });
127
+
128
+ it("carries the structured RuactActionError into the hook's error on a 4xx", async () => {
129
+ mockFetchError(400, "bad");
130
+ const search = _makeQuery({ path: "/q/search", kind: "query" });
131
+
132
+ const { result } = renderHook(() => useQuery(search));
133
+ await waitFor(() => expect(result.current.loading).toBe(false));
134
+
135
+ expect(result.current.error).toBeInstanceOf(RuactActionError);
136
+ expect(result.current.error.status).toBe(400);
137
+ });
138
+ });
139
+
140
+ describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
141
+ const { buildQueryUrl, buildQueryFetchInit } = __internals;
142
+
143
+ it("omits the query string entirely when no params are given", () => {
144
+ expect(buildQueryUrl("/q/categories", undefined)).toBe("/q/categories");
145
+ expect(buildQueryUrl("/q/categories", null)).toBe("/q/categories");
146
+ expect(buildQueryUrl("/q/categories", {})).toBe("/q/categories");
147
+ });
148
+
149
+ it("encodes string / number / boolean primitives", () => {
150
+ expect(buildQueryUrl("/q/search", { q: "a b", limit: 5, active: true })).toBe(
151
+ "/q/search?q=a+b&limit=5&active=true",
152
+ );
153
+ });
154
+
155
+ it("encodes null as a BARE key (Rack parses `?q` as nil, distinct from `?q=` empty string)", () => {
156
+ expect(buildQueryUrl("/q/search", { q: null })).toBe("/q/search?q");
157
+ // value-bearing params keep `key=value`; a null alongside is a bare key
158
+ expect(buildQueryUrl("/q/search", { limit: 5, q: null })).toBe("/q/search?limit=5&q");
159
+ });
160
+
161
+ it("rejects an array value (FR88 — arrays are not primitives)", () => {
162
+ expect(() => buildQueryUrl("/q/search", { q: [1, 2] })).toThrow(/arrays and objects are rejected/);
163
+ });
164
+
165
+ it("rejects an object value", () => {
166
+ expect(() => buildQueryUrl("/q/search", { q: { deep: 1 } })).toThrow(/arrays and objects are rejected/);
167
+ });
168
+
169
+ it("rejects a top-level array of params", () => {
170
+ expect(() => buildQueryUrl("/q/search", [1, 2])).toThrow(/plain object/);
171
+ });
172
+
173
+ it("builds a GET init with Accept JSON, no body, no CSRF, redirect: error", () => {
174
+ const init = buildQueryFetchInit();
175
+ expect(init.method).toBe("GET");
176
+ expect(init.body).toBeUndefined();
177
+ expect(init.headers.Accept).toBe("application/json");
178
+ expect(init.headers["X-CSRF-Token"]).toBeUndefined();
179
+ expect(init.redirect).toBe("error");
180
+ });
181
+ });
@@ -49,12 +49,18 @@ const RESERVED_JS_IDENTIFIERS = new Set([
49
49
  ]);
50
50
 
51
51
  // Story 8.2 R12 (2026-05-17) — names ALREADY bound at module top by the
52
- // codegen itself: `_makeRef` (imported from the runtime) and `revalidate`
53
- // (re-exported unconditionally from the runtime). A snapshot that
54
- // declared either as an action `js_identifier` would emit a duplicate
55
- // binding and crash at module-load time. Mirrors Ruby
56
- // `NameBridge::RESERVED_BY_RUACT`.
57
- const RESERVED_BY_RUACT = new Set(["_makeRef", "_makeServerFunction", "revalidate"]);
52
+ // codegen itself: the runtime imports (`_makeRef`, `_makeServerFunction`,
53
+ // `_makeQuery`) and the re-exports (`revalidate`, `useQuery`). A snapshot that
54
+ // declared any as a `js_identifier` would emit a duplicate binding and crash
55
+ // at module-load time. Mirrors Ruby `NameBridge::RESERVED_BY_RUACT`.
56
+ // Story 9.5 added `_makeQuery` + `useQuery`.
57
+ const RESERVED_BY_RUACT = new Set([
58
+ "_makeQuery",
59
+ "_makeRef",
60
+ "_makeServerFunction",
61
+ "revalidate",
62
+ "useQuery",
63
+ ]);
58
64
 
59
65
  // Story 9.3 — the route-driven snapshot schema version + its verb allowlist.
60
66
  // A version-2 snapshot renders `_makeServerFunction({...})` calls; `render`
@@ -62,6 +68,10 @@ const RESERVED_BY_RUACT = new Set(["_makeRef", "_makeServerFunction", "revalidat
62
68
  // Mirrors Ruby `Codegen::VERSION_V2` / `V2_HTTP_METHODS`.
63
69
  export const VERSION_V2 = 2;
64
70
  const V2_HTTP_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
71
+ // Story 9.5 — queries are GET-only (the `POST /__ruact/fn/:id` query mechanism
72
+ // is voided by the 2026-06-02 ADR addendum). Mirrors Ruby
73
+ // `Codegen::V2::QUERY_HTTP_METHODS`.
74
+ const V2_QUERY_HTTP_METHODS = new Set(["GET"]);
65
75
 
66
76
  /**
67
77
  * Absolute path to the placeholder runtime bundled inside the gem. Used as the
@@ -305,16 +315,18 @@ function validateSnapshotV2(snapshot) {
305
315
  }
306
316
  seen.add(fn.js_identifier);
307
317
 
308
- if (fn.kind !== "action") {
318
+ if (fn.kind !== "action" && fn.kind !== "query") {
309
319
  throw new Error(
310
320
  `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
311
- `kind ${JSON.stringify(fn.kind)} (v2 entries are always "action").`,
321
+ `kind ${JSON.stringify(fn.kind)} (v2 entries are "action" or "query").`,
312
322
  );
313
323
  }
314
- if (!V2_HTTP_METHODS.has(fn.http_method)) {
324
+ // Story 9.5 — actions carry a mutation verb; queries are GET-only.
325
+ const allowedMethods = fn.kind === "query" ? V2_QUERY_HTTP_METHODS : V2_HTTP_METHODS;
326
+ if (!allowedMethods.has(fn.http_method)) {
315
327
  throw new Error(
316
328
  `ruact server-function codegen: v2 snapshot entry "${fn.js_identifier}" has invalid ` +
317
- `http_method ${JSON.stringify(fn.http_method)} (must be POST/PUT/PATCH/DELETE).`,
329
+ `http_method ${JSON.stringify(fn.http_method)} (must be one of ${JSON.stringify([...allowedMethods])}).`,
318
330
  );
319
331
  }
320
332
  if (typeof fn.path !== "string" || !fn.path.startsWith("/")) {
@@ -369,11 +381,21 @@ function validateSnapshotV2(snapshot) {
369
381
  function renderV2(snapshot) {
370
382
  validateSnapshotV2(snapshot);
371
383
  const { version, generated_at, functions } = snapshot;
384
+
385
+ const hasQuery = functions.some((fn) => fn.kind === "query");
386
+ const hasAction = functions.some((fn) => fn.kind === "action");
387
+
388
+ // Story 9.5 — import only what is used. Keep `_makeServerFunction` in the
389
+ // empty case so the no-functions module stays byte-identical to Story 9.3.
390
+ const imports = [];
391
+ if (hasAction || functions.length === 0) imports.push("_makeServerFunction");
392
+ if (hasQuery) imports.push("_makeQuery");
393
+
372
394
  let out = "";
373
395
  out += "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n";
374
396
  out += `// Source: Rails route table (version ${version})\n`;
375
397
  out += `// Generated at: ${generated_at}\n`;
376
- out += `import { _makeServerFunction } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
398
+ out += `import { ${imports.join(", ")} } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
377
399
 
378
400
  if (functions.length === 0) {
379
401
  out += "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n";
@@ -386,12 +408,17 @@ function renderV2(snapshot) {
386
408
  }
387
409
  out += "\n";
388
410
  out += `export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
411
+ // Story 9.5 — `useQuery` re-export ONLY when queries are present (keeps the
412
+ // action-only / empty modules byte-identical to Story 9.3).
413
+ if (hasQuery) out += `export { useQuery } from "${RUNTIME_IMPORT_SPECIFIER}";\n`;
389
414
  return out;
390
415
  }
391
416
 
392
417
  function renderExportV2(fn) {
393
- // Same intersection signature as v1 actions (Story 8.2) — every v2 entry is
394
- // an action. Mirrors `Ruact::ServerFunctions::Codegen::ACTION_SIGNATURE`.
418
+ if (fn.kind === "query") return renderQueryExportV2(fn);
419
+
420
+ // The same intersection signature as v1 actions (Story 8.2). Mirrors
421
+ // `Ruact::ServerFunctions::Codegen::ACTION_SIGNATURE`.
395
422
  const signature =
396
423
  "((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>)";
397
424
  const method = JSON.stringify(String(fn.http_method));
@@ -404,6 +431,22 @@ function renderExportV2(fn) {
404
431
  );
405
432
  }
406
433
 
434
+ // Story 9.5 — a query export binds a `_makeQuery` accessor carrying its GET
435
+ // descriptor `{ path, kind: "query" }`; `useQuery(<id>, …)` consumes it. The
436
+ // signature accepts params only when the query method declares kwargs (FR88).
437
+ // Mirrors `Ruact::ServerFunctions::Codegen::V2.render_query_export`.
438
+ function renderQueryExportV2(fn) {
439
+ const signature = fn.accepts_params
440
+ ? "(params: Record<string, unknown>) => Promise<unknown>"
441
+ : "() => Promise<unknown>";
442
+ const pathLit = JSON.stringify(String(fn.path));
443
+ const descriptor = `{ path: ${pathLit}, kind: "query" }`;
444
+ return (
445
+ `export const ${fn.js_identifier}: ${signature} =\n` +
446
+ ` _makeQuery(${descriptor});\n`
447
+ );
448
+ }
449
+
407
450
  function renderExport(fn) {
408
451
  // Story 8.2 (refined 2026-05-17 per review patch R1) — action signature
409
452
  // is an intersection of two call signatures so `<form action={fn}>`
@@ -864,3 +864,98 @@ describe("Story 9.3 — route-driven (v2) render + parity", () => {
864
864
  expect(jsOutput).toBe(rubyOutput);
865
865
  });
866
866
  });
867
+
868
+ describe("Story 9.5 — v2 query entries render + parity", () => {
869
+ const queryFixture = {
870
+ version: 2,
871
+ generated_at: "2026-06-10T00:00:00Z",
872
+ functions: [
873
+ {
874
+ js_identifier: "createPost",
875
+ kind: "action",
876
+ http_method: "POST",
877
+ path: "/posts",
878
+ segments: [],
879
+ controller: "posts",
880
+ action: "create",
881
+ },
882
+ {
883
+ js_identifier: "categories",
884
+ kind: "query",
885
+ http_method: "GET",
886
+ path: "/q/categories",
887
+ segments: [],
888
+ accepts_params: false,
889
+ controller: "CatalogQuery",
890
+ action: "categories",
891
+ },
892
+ {
893
+ js_identifier: "searchUsers",
894
+ kind: "query",
895
+ http_method: "GET",
896
+ path: "/q/searchUsers",
897
+ segments: [],
898
+ accepts_params: true,
899
+ controller: "CatalogQuery",
900
+ action: "search_users",
901
+ },
902
+ ],
903
+ };
904
+
905
+ it("emits _makeQuery refs with GET descriptors + the useQuery re-export", () => {
906
+ const out = render(queryFixture);
907
+ expect(out).toContain('import { _makeServerFunction, _makeQuery } from "ruact/server-functions-runtime";');
908
+ expect(out).toContain(
909
+ 'export const categories: () => Promise<unknown> =\n _makeQuery({ path: "/q/categories", kind: "query" });',
910
+ );
911
+ expect(out).toContain(
912
+ 'export const searchUsers: (params: Record<string, unknown>) => Promise<unknown> =\n' +
913
+ ' _makeQuery({ path: "/q/searchUsers", kind: "query" });',
914
+ );
915
+ expect(out).toContain('export { useQuery } from "ruact/server-functions-runtime";');
916
+ });
917
+
918
+ it("accepts GET for a query entry but rejects a non-GET verb", () => {
919
+ expect(() =>
920
+ render({
921
+ version: 2,
922
+ generated_at: "x",
923
+ functions: [
924
+ { js_identifier: "categories", kind: "query", http_method: "POST", path: "/q/categories", segments: [] },
925
+ ],
926
+ }),
927
+ ).toThrow(/invalid.*http_method/i);
928
+ });
929
+
930
+ it("rejects an unknown kind (action/query only)", () => {
931
+ expect(() =>
932
+ render({
933
+ version: 2,
934
+ generated_at: "x",
935
+ functions: [
936
+ { js_identifier: "x", kind: "mutation", http_method: "GET", path: "/q/x", segments: [] },
937
+ ],
938
+ }),
939
+ ).toThrow(/"action" or "query"/);
940
+ });
941
+
942
+ it("Ruby's render and JS's render produce byte-identical query output", () => {
943
+ const jsOutput = render(queryFixture);
944
+ const gemLibPath = path.resolve(
945
+ path.dirname(new URL(import.meta.url).pathname),
946
+ "..",
947
+ "..",
948
+ "..",
949
+ "lib",
950
+ );
951
+ const fixtureJson = JSON.stringify(queryFixture);
952
+ const script =
953
+ 'require "ruact/server_functions/codegen"; ' +
954
+ 'require "json"; ' +
955
+ `print Ruact::ServerFunctions::Codegen.render(JSON.parse('${fixtureJson}'))`;
956
+ const rubyOutput = execFileSync("ruby", ["-I", gemLibPath, "-e", script], {
957
+ encoding: "utf8",
958
+ });
959
+ expect(jsOutput).toBe(rubyOutput);
960
+ });
961
+ });
@@ -9,7 +9,13 @@ import { defineConfig } from "vitest/config";
9
9
  export default defineConfig({
10
10
  test: {
11
11
  environment: "node",
12
- include: ["**/*.test.mjs", "../ruact-server-functions-runtime/*.test.mjs"],
12
+ // The runtime's `index.test.mjs` is node-environment + dependency-free, so
13
+ // it rides along here for a single parity-plus-runtime run. Its jsdom +
14
+ // React hook tests (`usequery.test.mjs`, Story 9.5) need
15
+ // `@testing-library/react` from the runtime package's own node_modules and
16
+ // run via that package's `npm test` — they are intentionally NOT globbed
17
+ // in here (a `*.test.mjs` glob would pull them in and fail to resolve).
18
+ include: ["**/*.test.mjs", "../ruact-server-functions-runtime/index.test.mjs"],
13
19
  globals: false,
14
20
  },
15
21
  });