ruact 0.0.3 → 0.0.5

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.
@@ -0,0 +1,368 @@
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
+ // Story 9.6 — start every case from a clean in-flight registry so a test that
19
+ // deliberately leaves a request pending cannot leak a shared promise into the
20
+ // next one.
21
+ __internals.__resetQueryDedup();
22
+ });
23
+
24
+ afterEach(() => {
25
+ globalThis.fetch = originalFetch;
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ function mockFetchOk(jsonBody, { status = 200, contentType = "application/json" } = {}) {
30
+ const response = {
31
+ ok: true,
32
+ status,
33
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? contentType : null) },
34
+ text: vi.fn().mockResolvedValue(typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody)),
35
+ };
36
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
37
+ return response;
38
+ }
39
+
40
+ function mockFetchError(status, bodyText) {
41
+ const response = {
42
+ ok: false,
43
+ status,
44
+ headers: { get: () => "text/plain" },
45
+ text: vi.fn().mockResolvedValue(bodyText),
46
+ };
47
+ globalThis.fetch = vi.fn().mockResolvedValue(response);
48
+ return response;
49
+ }
50
+
51
+ // Story 9.6 — a fetch stub that returns a PENDING promise so two hooks can both
52
+ // be in-flight at once (the precondition for exercising dedup). The test
53
+ // resolves it manually AFTER both hooks have mounted.
54
+ function deferredFetch() {
55
+ let settle;
56
+ const fetchMock = vi.fn(
57
+ () =>
58
+ new Promise((resolve) => {
59
+ settle = resolve;
60
+ }),
61
+ );
62
+ globalThis.fetch = fetchMock;
63
+ return {
64
+ fetchMock,
65
+ resolveOk(jsonBody, { status = 200, contentType = "application/json" } = {}) {
66
+ settle({
67
+ ok: true,
68
+ status,
69
+ headers: { get: (n) => (n.toLowerCase() === "content-type" ? contentType : null) },
70
+ text: () => Promise.resolve(typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody)),
71
+ });
72
+ },
73
+ resolveError(status, bodyText) {
74
+ settle({
75
+ ok: false,
76
+ status,
77
+ headers: { get: () => "text/plain" },
78
+ text: () => Promise.resolve(bodyText),
79
+ });
80
+ },
81
+ };
82
+ }
83
+
84
+ describe("Story 9.5 — useQuery hook contract", () => {
85
+ it("starts loading, then resolves to { data, loading: false, error: null }", async () => {
86
+ const ref = vi.fn().mockResolvedValue({ items: [1, 2] });
87
+ const { result } = renderHook(() => useQuery(ref));
88
+
89
+ expect(result.current.loading).toBe(true);
90
+ expect(result.current.data).toBeUndefined();
91
+
92
+ await waitFor(() => expect(result.current.loading).toBe(false));
93
+ expect(result.current.data).toEqual({ items: [1, 2] });
94
+ expect(result.current.error).toBe(null);
95
+ });
96
+
97
+ it("transitions loading → error on a rejected reference", async () => {
98
+ const err = new RuactActionError({ name: "/q/categories", status: 500, body: "boom", response: {} });
99
+ const ref = vi.fn().mockRejectedValue(err);
100
+ const { result } = renderHook(() => useQuery(ref));
101
+
102
+ await waitFor(() => expect(result.current.loading).toBe(false));
103
+ expect(result.current.error).toBe(err);
104
+ expect(result.current.data).toBeUndefined();
105
+ });
106
+
107
+ it("passes params to the query reference", async () => {
108
+ const ref = vi.fn().mockResolvedValue("ok");
109
+ const params = { q: "ruby", limit: 5 };
110
+ renderHook(() => useQuery(ref, params));
111
+
112
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
113
+ expect(ref).toHaveBeenCalledWith(params);
114
+ });
115
+
116
+ it("does not refetch when params are value-equal across renders", async () => {
117
+ const ref = vi.fn().mockResolvedValue("ok");
118
+ const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
119
+ initialProps: { p: { q: "a" } },
120
+ });
121
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
122
+
123
+ rerender({ p: { q: "a" } }); // fresh object literal, identical value
124
+ await Promise.resolve();
125
+ expect(ref).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it("refetches when params change by value", async () => {
129
+ const ref = vi.fn().mockResolvedValue("ok");
130
+ const { rerender } = renderHook(({ p }) => useQuery(ref, p), {
131
+ initialProps: { p: { q: "a" } },
132
+ });
133
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(1));
134
+
135
+ rerender({ p: { q: "b" } });
136
+ await waitFor(() => expect(ref).toHaveBeenCalledTimes(2));
137
+ expect(ref).toHaveBeenLastCalledWith({ q: "b" });
138
+ });
139
+
140
+ it("surfaces a synchronous throw from the reference as an error (not an unhandled rejection)", async () => {
141
+ const ref = () => {
142
+ throw new TypeError("params must be a plain object");
143
+ };
144
+ const { result } = renderHook(() => useQuery(ref, [1, 2]));
145
+ await waitFor(() => expect(result.current.loading).toBe(false));
146
+ expect(result.current.error).toBeInstanceOf(TypeError);
147
+ });
148
+ });
149
+
150
+ describe("Story 9.5 — useQuery against _makeQuery end-to-end (GET wire)", () => {
151
+ it("issues GET /q/<id>?<params> and resolves the JSON body through the hook", async () => {
152
+ mockFetchOk([{ value: 1, label: "Books" }]);
153
+ const categories = _makeQuery({ path: "/q/categories", kind: "query" });
154
+
155
+ const { result } = renderHook(() => useQuery(categories, { q: "bo" }));
156
+ await waitFor(() => expect(result.current.loading).toBe(false));
157
+
158
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
159
+ const [url, init] = globalThis.fetch.mock.calls[0];
160
+ expect(url).toBe("/q/categories?q=bo");
161
+ expect(init.method).toBe("GET");
162
+ expect(result.current.data).toEqual([{ value: 1, label: "Books" }]);
163
+ });
164
+
165
+ it("carries the structured RuactActionError into the hook's error on a 4xx", async () => {
166
+ mockFetchError(400, "bad");
167
+ const search = _makeQuery({ path: "/q/search", kind: "query" });
168
+
169
+ const { result } = renderHook(() => useQuery(search));
170
+ await waitFor(() => expect(result.current.loading).toBe(false));
171
+
172
+ expect(result.current.error).toBeInstanceOf(RuactActionError);
173
+ expect(result.current.error.status).toBe(400);
174
+ });
175
+ });
176
+
177
+ describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
178
+ const { buildQueryUrl, buildQueryFetchInit } = __internals;
179
+
180
+ it("omits the query string entirely when no params are given", () => {
181
+ expect(buildQueryUrl("/q/categories", undefined)).toBe("/q/categories");
182
+ expect(buildQueryUrl("/q/categories", null)).toBe("/q/categories");
183
+ expect(buildQueryUrl("/q/categories", {})).toBe("/q/categories");
184
+ });
185
+
186
+ it("encodes string / number / boolean primitives", () => {
187
+ expect(buildQueryUrl("/q/search", { q: "a b", limit: 5, active: true })).toBe(
188
+ "/q/search?q=a+b&limit=5&active=true",
189
+ );
190
+ });
191
+
192
+ it("encodes null as a BARE key (Rack parses `?q` as nil, distinct from `?q=` empty string)", () => {
193
+ expect(buildQueryUrl("/q/search", { q: null })).toBe("/q/search?q");
194
+ // value-bearing params keep `key=value`; a null alongside is a bare key
195
+ expect(buildQueryUrl("/q/search", { limit: 5, q: null })).toBe("/q/search?limit=5&q");
196
+ });
197
+
198
+ it("rejects an array value (FR88 — arrays are not primitives)", () => {
199
+ expect(() => buildQueryUrl("/q/search", { q: [1, 2] })).toThrow(/arrays and objects are rejected/);
200
+ });
201
+
202
+ it("rejects an object value", () => {
203
+ expect(() => buildQueryUrl("/q/search", { q: { deep: 1 } })).toThrow(/arrays and objects are rejected/);
204
+ });
205
+
206
+ it("rejects a top-level array of params", () => {
207
+ expect(() => buildQueryUrl("/q/search", [1, 2])).toThrow(/plain object/);
208
+ });
209
+
210
+ it("builds a GET init with Accept JSON, no body, no CSRF, redirect: error", () => {
211
+ const init = buildQueryFetchInit();
212
+ expect(init.method).toBe("GET");
213
+ expect(init.body).toBeUndefined();
214
+ expect(init.headers.Accept).toBe("application/json");
215
+ expect(init.headers["X-CSRF-Token"]).toBeUndefined();
216
+ expect(init.redirect).toBe("error");
217
+ });
218
+ });
219
+
220
+ describe("Story 9.6 — useQuery in-flight request de-duplication", () => {
221
+ it("shares ONE network request across concurrent callers (same ref + same params) — AC1", async () => {
222
+ const deferred = deferredFetch();
223
+ const categories = _makeQuery({ path: "/q/categories", kind: "query" });
224
+
225
+ const a = renderHook(() => useQuery(categories, { q: "bo" }));
226
+ const b = renderHook(() => useQuery(categories, { q: "bo" }));
227
+
228
+ // Both hooks are in-flight: the second joined the first's request.
229
+ await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
230
+
231
+ deferred.resolveOk([{ value: 1, label: "Books" }]);
232
+ await waitFor(() => expect(a.result.current.loading).toBe(false));
233
+ await waitFor(() => expect(b.result.current.loading).toBe(false));
234
+
235
+ // Still exactly one network call, and both callers got the same data.
236
+ expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
237
+ expect(a.result.current.data).toEqual([{ value: 1, label: "Books" }]);
238
+ expect(b.result.current.data).toEqual([{ value: 1, label: "Books" }]);
239
+ });
240
+
241
+ it("propagates the SAME error instance to ALL sharers of an in-flight request — AC1", async () => {
242
+ const deferred = deferredFetch();
243
+ const search = _makeQuery({ path: "/q/search", kind: "query" });
244
+
245
+ const a = renderHook(() => useQuery(search, { q: "x" }));
246
+ const b = renderHook(() => useQuery(search, { q: "x" }));
247
+ await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
248
+
249
+ deferred.resolveError(500, "boom");
250
+ await waitFor(() => expect(a.result.current.loading).toBe(false));
251
+ await waitFor(() => expect(b.result.current.loading).toBe(false));
252
+
253
+ expect(a.result.current.error).toBeInstanceOf(RuactActionError);
254
+ expect(a.result.current.error).toBe(b.result.current.error); // identical instance
255
+ expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
256
+ });
257
+
258
+ it("collapses params that differ only in key ORDER onto one request — AC2", async () => {
259
+ const deferred = deferredFetch();
260
+ const search = _makeQuery({ path: "/q/search", kind: "query" });
261
+
262
+ renderHook(() => useQuery(search, { a: 1, b: 2 }));
263
+ renderHook(() => useQuery(search, { b: 2, a: 1 }));
264
+
265
+ await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
266
+ // Give any stray second request a chance to fire before asserting once.
267
+ await Promise.resolve();
268
+ expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
269
+ });
270
+
271
+ it("issues SEPARATE requests for different params — AC2", async () => {
272
+ const deferred = deferredFetch();
273
+ const search = _makeQuery({ path: "/q/search", kind: "query" });
274
+
275
+ renderHook(() => useQuery(search, { q: "a" }));
276
+ renderHook(() => useQuery(search, { q: "b" }));
277
+
278
+ await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(2));
279
+ });
280
+
281
+ it("does NOT dedup across distinct references with the same params — AC2 (keys off the reference)", async () => {
282
+ const deferred = deferredFetch();
283
+ const categories = _makeQuery({ path: "/q/categories", kind: "query" });
284
+ const search = _makeQuery({ path: "/q/search", kind: "query" });
285
+
286
+ renderHook(() => useQuery(categories, { q: "x" }));
287
+ renderHook(() => useQuery(search, { q: "x" }));
288
+
289
+ await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(2));
290
+ });
291
+
292
+ it("does NOT share an invalid-shape params call onto an in-flight no-param request (surfaces the error) — AC2", async () => {
293
+ const deferred = deferredFetch();
294
+ const categories = _makeQuery({ path: "/q/categories", kind: "query" });
295
+
296
+ // A no-param request is in flight (key "").
297
+ const noParams = renderHook(() => useQuery(categories));
298
+ await waitFor(() => expect(deferred.fetchMock).toHaveBeenCalledTimes(1));
299
+
300
+ // An invalid array-shaped params call must NOT join the "" request — it must
301
+ // invoke the reference, whose buildQueryUrl throws a TypeError.
302
+ const invalid = renderHook(() => useQuery(categories, [1, 2]));
303
+ await waitFor(() => expect(invalid.result.current.loading).toBe(false));
304
+
305
+ expect(invalid.result.current.error).toBeInstanceOf(TypeError);
306
+ expect(invalid.result.current.data).toBeUndefined();
307
+ // No new network call: buildQueryUrl threw before fetch, and it never shared.
308
+ expect(deferred.fetchMock).toHaveBeenCalledTimes(1);
309
+ expect(noParams.result.current.loading).toBe(true); // still in flight
310
+ });
311
+
312
+ it("issues a FRESH request on a mount AFTER the previous settled (in-flight only, no cache) — AC3", async () => {
313
+ mockFetchOk([{ value: 1 }]);
314
+ const categories = _makeQuery({ path: "/q/categories", kind: "query" });
315
+
316
+ const first = renderHook(() => useQuery(categories, { q: "bo" }));
317
+ await waitFor(() => expect(first.result.current.loading).toBe(false));
318
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
319
+ first.unmount();
320
+
321
+ // The previous request settled, so its registry entry is gone — a new mount
322
+ // with identical reference + params must refetch (no TTL / no SWR).
323
+ const second = renderHook(() => useQuery(categories, { q: "bo" }));
324
+ await waitFor(() => expect(second.result.current.loading).toBe(false));
325
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
326
+ });
327
+ });
328
+
329
+ describe("Story 9.6 — canonicalParamsKey (dedup key derivation)", () => {
330
+ const { canonicalParamsKey } = __internals;
331
+
332
+ it("is order-independent ({a,b} === {b,a})", () => {
333
+ expect(canonicalParamsKey({ a: 1, b: 2 })).toBe(canonicalParamsKey({ b: 2, a: 1 }));
334
+ });
335
+
336
+ it("distinguishes different values", () => {
337
+ expect(canonicalParamsKey({ q: "a" })).not.toBe(canonicalParamsKey({ q: "b" }));
338
+ });
339
+
340
+ it("treats null / undefined / empty object as the same empty key", () => {
341
+ expect(canonicalParamsKey(null)).toBe("");
342
+ expect(canonicalParamsKey(undefined)).toBe("");
343
+ expect(canonicalParamsKey({})).toBe("");
344
+ });
345
+
346
+ it("skips undefined-valued keys (mirrors buildQueryUrl's wire omission)", () => {
347
+ expect(canonicalParamsKey({ q: "a", extra: undefined })).toBe(canonicalParamsKey({ q: "a" }));
348
+ });
349
+
350
+ it("distinguishes null from non-finite numbers (NaN / Infinity), which the wire sends differently", () => {
351
+ const asNull = canonicalParamsKey({ q: null });
352
+ const asNaN = canonicalParamsKey({ q: NaN });
353
+ const asInfinity = canonicalParamsKey({ q: Infinity });
354
+ expect(asNull).not.toBe(asNaN);
355
+ expect(asNaN).not.toBe(asInfinity);
356
+ expect(asNull).not.toBe(asInfinity);
357
+ });
358
+
359
+ it("distinguishes a null value (bare key) from the empty string (key=)", () => {
360
+ expect(canonicalParamsKey({ q: null })).not.toBe(canonicalParamsKey({ q: "" }));
361
+ });
362
+
363
+ it("returns null (non-shareable) for shapes buildQueryUrl rejects — array top level or array/object value", () => {
364
+ expect(canonicalParamsKey([1, 2])).toBe(null);
365
+ expect(canonicalParamsKey({ q: [1, 2] })).toBe(null);
366
+ expect(canonicalParamsKey({ q: { deep: 1 } })).toBe(null);
367
+ });
368
+ });
@@ -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
  });
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
@@ -84,6 +84,7 @@ files:
84
84
  - lib/ruact/server_functions/name_bridge.rb
85
85
  - lib/ruact/server_functions/query_context.rb
86
86
  - lib/ruact/server_functions/query_dispatch.rb
87
+ - lib/ruact/server_functions/query_source.rb
87
88
  - lib/ruact/server_functions/registry.rb
88
89
  - lib/ruact/server_functions/registry_entry.rb
89
90
  - lib/ruact/server_functions/route_source.rb
@@ -154,6 +155,7 @@ files:
154
155
  - spec/ruact/server_functions/error_suggestion_spec.rb
155
156
  - spec/ruact/server_functions/name_bridge_spec.rb
156
157
  - spec/ruact/server_functions/query_context_spec.rb
158
+ - spec/ruact/server_functions/query_source_spec.rb
157
159
  - spec/ruact/server_functions/railtie_integration_spec.rb
158
160
  - spec/ruact/server_functions/rake_spec.rb
159
161
  - spec/ruact/server_functions/registry_spec.rb
@@ -178,6 +180,7 @@ files:
178
180
  - vendor/javascript/ruact-server-functions-runtime/index.js
179
181
  - vendor/javascript/ruact-server-functions-runtime/index.test.mjs
180
182
  - vendor/javascript/ruact-server-functions-runtime/package.json
183
+ - vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs
181
184
  - vendor/javascript/vite-plugin-ruact/index.js
182
185
  - vendor/javascript/vite-plugin-ruact/package-lock.json
183
186
  - vendor/javascript/vite-plugin-ruact/package.json