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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/docs/internal/decisions/server-functions-api.md +99 -0
- data/lib/ruact/errors.rb +11 -0
- data/lib/ruact/server_functions/codegen.rb +14 -0
- data/lib/ruact/server_functions/codegen_v2.rb +46 -10
- data/lib/ruact/server_functions/error_rendering.rb +2 -0
- data/lib/ruact/server_functions/name_bridge.rb +11 -6
- data/lib/ruact/server_functions/query_dispatch.rb +73 -8
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions.rb +38 -2
- data/lib/ruact/version.rb +1 -1
- data/spec/ruact/query_request_spec.rb +154 -2
- data/spec/ruact/server_functions/codegen_spec.rb +82 -3
- data/spec/ruact/server_functions/name_bridge_spec.rb +24 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +67 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +36 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +276 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +9 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +368 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +56 -13
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +95 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +7 -1
- metadata +4 -1
|
@@ -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:
|
|
53
|
-
//
|
|
54
|
-
// declared
|
|
55
|
-
//
|
|
56
|
-
// `
|
|
57
|
-
const RESERVED_BY_RUACT = new Set([
|
|
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
|
|
321
|
+
`kind ${JSON.stringify(fn.kind)} (v2 entries are "action" or "query").`,
|
|
312
322
|
);
|
|
313
323
|
}
|
|
314
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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.
|
|
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
|