ruact 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -1,12 +1,14 @@
1
- // Story 8.1 — vitest suite for the real server-functions runtime.
1
+ // Vitest suite for the route-driven server-functions runtime.
2
2
  //
3
- // Covers AC10 of Story 8.1: argument-shape branching (JSON vs FormData),
4
- // CSRF meta-tag injection, success vs. failure response handling, and
5
- // error wrapping. Uses `vi.fn()` to stub `fetch` no real network.
3
+ // Covers the shared mutation fetch core via `_makeServerFunction`: argument-
4
+ // shape branching (JSON vs FormData), CSRF meta-tag injection, success vs.
5
+ // failure response handling, error wrapping, and the useActionState two-arg
6
+ // shape. Uses `vi.fn()` to stub `fetch` — no real network. Story 9.9 demolished
7
+ // the v1 `_makeRef` accessor; these tests now exercise the same core through the
8
+ // route-driven accessor (which `_makeRef` used to share verbatim).
6
9
 
7
10
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
8
11
  import {
9
- _makeRef,
10
12
  _makeServerFunction,
11
13
  __RUNTIME_VERSION__,
12
14
  __internals,
@@ -15,6 +17,13 @@ import {
15
17
  revalidate,
16
18
  } from "./index.js";
17
19
 
20
+ // Helper — a route-driven accessor targeting `POST /posts` (the canonical
21
+ // mutation shape). The shared fetch core is identical regardless of verb/path,
22
+ // so this stands in for the demolished `_makeRef("name")` in the core tests.
23
+ function makePostFn() {
24
+ return _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
25
+ }
26
+
18
27
  let originalFetch;
19
28
  let originalDocument;
20
29
 
@@ -67,27 +76,27 @@ function mockMetaTag(token) {
67
76
  };
68
77
  }
69
78
 
70
- describe("Story 8.1 _makeRef", () => {
79
+ describe("runtimeaccessor basics", () => {
71
80
  it("exports the runtime-version sentinel (placeholder __PLACEHOLDER__ is gone)", () => {
72
81
  expect(__RUNTIME_VERSION__).toBe(1);
73
82
  });
74
83
 
75
84
  it("returns a callable accessor", () => {
76
- const ref = _makeRef("create_post");
85
+ const ref = makePostFn();
77
86
  expect(typeof ref).toBe("function");
78
87
  });
79
88
  });
80
89
 
81
- describe("Story 8.1 — JSON body branch", () => {
82
- it("POSTs JSON.stringify(args) with Content-Type: application/json", async () => {
90
+ describe("mutation core — JSON body branch", () => {
91
+ it("sends JSON.stringify(args) with Content-Type: application/json over the real path+verb", async () => {
83
92
  mockFetchOk({ ok: true });
84
93
  mockMetaTag(null);
85
94
 
86
- await _makeRef("create_post")({ title: "Hi" });
95
+ await makePostFn()({ title: "Hi" });
87
96
 
88
97
  expect(globalThis.fetch).toHaveBeenCalledTimes(1);
89
98
  const [url, init] = globalThis.fetch.mock.calls[0];
90
- expect(url).toBe("/__ruact/fn/create_post");
99
+ expect(url).toBe("/posts");
91
100
  expect(init.method).toBe("POST");
92
101
  expect(init.credentials).toBe("same-origin");
93
102
  expect(init.headers["Content-Type"]).toBe("application/json");
@@ -97,7 +106,7 @@ describe("Story 8.1 — JSON body branch", () => {
97
106
  it("treats undefined args as an empty JSON object {}", async () => {
98
107
  mockFetchOk({});
99
108
  mockMetaTag(null);
100
- await _makeRef("categories")();
109
+ await makePostFn()();
101
110
 
102
111
  const [, init] = globalThis.fetch.mock.calls[0];
103
112
  expect(init.body).toBe("{}");
@@ -106,7 +115,7 @@ describe("Story 8.1 — JSON body branch", () => {
106
115
  it("treats null args as an empty JSON object {}", async () => {
107
116
  mockFetchOk({});
108
117
  mockMetaTag(null);
109
- await _makeRef("categories")(null);
118
+ await makePostFn()(null);
110
119
 
111
120
  const [, init] = globalThis.fetch.mock.calls[0];
112
121
  expect(init.body).toBe("{}");
@@ -115,14 +124,14 @@ describe("Story 8.1 — JSON body branch", () => {
115
124
  it("resolves with parsed JSON for application/json responses", async () => {
116
125
  mockFetchOk({ id: 7 });
117
126
  mockMetaTag(null);
118
- const result = await _makeRef("create_post")({ title: "x" });
127
+ const result = await makePostFn()({ title: "x" });
119
128
  expect(result).toEqual({ id: 7 });
120
129
  });
121
130
 
122
131
  it("attaches Accept: application/json header (re-run-2 #8 — host respond_to branching)", async () => {
123
132
  mockFetchOk({});
124
133
  mockMetaTag(null);
125
- await _makeRef("create_post")({});
134
+ await makePostFn()({});
126
135
  const [, init] = globalThis.fetch.mock.calls[0];
127
136
  expect(init.headers.Accept).toBe("application/json");
128
137
  });
@@ -138,7 +147,7 @@ describe("Story 8.1 — JSON body branch", () => {
138
147
  globalThis.fetch = vi.fn().mockResolvedValue(r);
139
148
  mockMetaTag(null);
140
149
 
141
- const result = await _makeRef("ping")({});
150
+ const result = await makePostFn()({});
142
151
  expect(result).toBe("hello");
143
152
  expect(r.text).toHaveBeenCalled();
144
153
  expect(r.json).not.toHaveBeenCalled();
@@ -156,7 +165,7 @@ describe("Story 8.1 — JSON body branch", () => {
156
165
  globalThis.fetch = vi.fn().mockResolvedValue(r);
157
166
  mockMetaTag(null);
158
167
 
159
- const result = await _makeRef("noop")({});
168
+ const result = await makePostFn()({});
160
169
  expect(result).toBeNull();
161
170
  expect(r.json).not.toHaveBeenCalled();
162
171
  });
@@ -172,7 +181,7 @@ describe("Story 8.1 — JSON body branch", () => {
172
181
  globalThis.fetch = vi.fn().mockResolvedValue(r);
173
182
  mockMetaTag(null);
174
183
 
175
- const result = await _makeRef("noop")({});
184
+ const result = await makePostFn()({});
176
185
  expect(result).toBeNull();
177
186
  });
178
187
 
@@ -190,21 +199,21 @@ describe("Story 8.1 — JSON body branch", () => {
190
199
  globalThis.fetch = vi.fn().mockResolvedValue(r);
191
200
  mockMetaTag(null);
192
201
 
193
- const result = await _makeRef("noop")({});
202
+ const result = await makePostFn()({});
194
203
  expect(result).toBeNull();
195
204
  expect(r.json).not.toHaveBeenCalled();
196
205
  });
197
206
  });
198
207
 
199
- describe("Story 8.1 — FormData branch", () => {
200
- it("POSTs the FormData as-is with NO manual Content-Type header (browser sets boundary)", async () => {
208
+ describe("mutation core — FormData branch", () => {
209
+ it("sends the FormData as-is with NO manual Content-Type header (browser sets boundary)", async () => {
201
210
  mockFetchOk({ ok: true });
202
211
  mockMetaTag(null);
203
212
 
204
213
  const fd = new FormData();
205
214
  fd.append("title", "From form");
206
215
 
207
- await _makeRef("create_post")(fd);
216
+ await makePostFn()(fd);
208
217
 
209
218
  const [, init] = globalThis.fetch.mock.calls[0];
210
219
  expect(init.body).toBe(fd);
@@ -212,12 +221,12 @@ describe("Story 8.1 — FormData branch", () => {
212
221
  });
213
222
  });
214
223
 
215
- describe("Story 8.1 — CSRF header injection", () => {
224
+ describe("mutation core — CSRF header injection", () => {
216
225
  it("attaches X-CSRF-Token header when <meta name=\"csrf-token\"> is present", async () => {
217
226
  mockFetchOk({ ok: true });
218
227
  mockMetaTag("token-abc123");
219
228
 
220
- await _makeRef("create_post")({ title: "x" });
229
+ await makePostFn()({ title: "x" });
221
230
 
222
231
  const [, init] = globalThis.fetch.mock.calls[0];
223
232
  expect(init.headers["X-CSRF-Token"]).toBe("token-abc123");
@@ -227,7 +236,7 @@ describe("Story 8.1 — CSRF header injection", () => {
227
236
  mockFetchOk({ ok: true });
228
237
  mockMetaTag(null);
229
238
 
230
- await _makeRef("create_post")({ title: "x" });
239
+ await makePostFn()({ title: "x" });
231
240
 
232
241
  const [, init] = globalThis.fetch.mock.calls[0];
233
242
  expect(init.headers["X-CSRF-Token"]).toBeUndefined();
@@ -237,17 +246,17 @@ describe("Story 8.1 — CSRF header injection", () => {
237
246
  mockFetchOk({ ok: true });
238
247
  globalThis.document = undefined;
239
248
 
240
- await expect(_makeRef("create_post")({ title: "x" })).resolves.toEqual({ ok: true });
249
+ await expect(makePostFn()({ title: "x" })).resolves.toEqual({ ok: true });
241
250
  });
242
251
  });
243
252
 
244
- describe("Story 8.1 — error responses", () => {
253
+ describe("mutation core — error responses", () => {
245
254
  it("rejects with a structured Error on 4xx responses", async () => {
246
255
  mockFetchError(422, "validation failed");
247
256
  mockMetaTag(null);
248
257
 
249
- await expect(_makeRef("create_post")({ title: "" })).rejects.toThrow(
250
- /ruact action :create_post failed: 422 validation failed/,
258
+ await expect(makePostFn()({ title: "" })).rejects.toThrow(
259
+ /ruact action :\/posts failed: 422 validation failed/,
251
260
  );
252
261
  });
253
262
 
@@ -255,8 +264,8 @@ describe("Story 8.1 — error responses", () => {
255
264
  mockFetchError(500, "boom");
256
265
  mockMetaTag(null);
257
266
 
258
- await expect(_makeRef("create_post")({})).rejects.toThrow(
259
- /ruact action :create_post failed: 500 boom/,
267
+ await expect(makePostFn()({})).rejects.toThrow(
268
+ /ruact action :\/posts failed: 500 boom/,
260
269
  );
261
270
  });
262
271
 
@@ -264,13 +273,13 @@ describe("Story 8.1 — error responses", () => {
264
273
  globalThis.fetch = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
265
274
  mockMetaTag(null);
266
275
 
267
- await expect(_makeRef("create_post")({})).rejects.toThrow(
268
- /ruact action :create_post request failed: Failed to fetch/,
276
+ await expect(makePostFn()({})).rejects.toThrow(
277
+ /ruact action :\/posts request failed: Failed to fetch/,
269
278
  );
270
279
  });
271
280
  });
272
281
 
273
- describe("Story 8.1Re-run-4 — RuactActionError carries status/body (#6)", () => {
282
+ describe("mutation core — RuactActionError carries status/body", () => {
274
283
  it("rejects with a RuactActionError exposing status and parsed JSON body on 422", async () => {
275
284
  const r = {
276
285
  ok: false,
@@ -284,13 +293,13 @@ describe("Story 8.1 — Re-run-4 — RuactActionError carries status/body (#6)",
284
293
 
285
294
  let captured = null;
286
295
  try {
287
- await _makeRef("create_post")({ title: "" });
296
+ await makePostFn()({ title: "" });
288
297
  } catch (err) {
289
298
  captured = err;
290
299
  }
291
300
  expect(captured).toBeInstanceOf(RuactActionError);
292
301
  expect(captured.status).toBe(422);
293
- expect(captured.actionName).toBe("create_post");
302
+ expect(captured.actionName).toBe("/posts");
294
303
  expect(captured.body).toEqual({ errors: { title: ["can't be blank"] } });
295
304
  });
296
305
 
@@ -300,7 +309,7 @@ describe("Story 8.1 — Re-run-4 — RuactActionError carries status/body (#6)",
300
309
 
301
310
  let captured = null;
302
311
  try {
303
- await _makeRef("create_post")({});
312
+ await makePostFn()({});
304
313
  } catch (err) {
305
314
  captured = err;
306
315
  }
@@ -310,7 +319,7 @@ describe("Story 8.1 — Re-run-4 — RuactActionError carries status/body (#6)",
310
319
  });
311
320
  });
312
321
 
313
- describe("Story 8.1Re-run-4 — +json structured-syntax-suffix media types (#7)", () => {
322
+ describe("mutation core — +json structured-syntax-suffix media types", () => {
314
323
  it("parses application/problem+json as JSON (RFC 6838 §4.2.8)", async () => {
315
324
  const r = {
316
325
  ok: true,
@@ -322,7 +331,7 @@ describe("Story 8.1 — Re-run-4 — +json structured-syntax-suffix media types
322
331
  globalThis.fetch = vi.fn().mockResolvedValue(r);
323
332
  mockMetaTag(null);
324
333
 
325
- const result = await _makeRef("noop")({});
334
+ const result = await makePostFn()({});
326
335
  expect(result).toEqual({ type: "about:blank", title: "ok" });
327
336
  });
328
337
 
@@ -337,24 +346,12 @@ describe("Story 8.1 — Re-run-4 — +json structured-syntax-suffix media types
337
346
  globalThis.fetch = vi.fn().mockResolvedValue(r);
338
347
  mockMetaTag(null);
339
348
 
340
- const result = await _makeRef("noop")({});
349
+ const result = await makePostFn()({});
341
350
  expect(result).toEqual({ data: { id: "1" } });
342
351
  });
343
352
  });
344
353
 
345
- describe("Story 8.1Re-run-3 URL encoding of name (#6)", () => {
346
- it("encodeURIComponent's the name so a stray '/' cannot rewrite the path", async () => {
347
- mockFetchOk({ ok: true });
348
- mockMetaTag(null);
349
-
350
- await _makeRef("../foo?x=1")({});
351
-
352
- const [url] = globalThis.fetch.mock.calls[0];
353
- expect(url).toBe("/__ruact/fn/..%2Ffoo%3Fx%3D1");
354
- });
355
- });
356
-
357
- describe("Story 8.1 — Re-run-3 — Content-Type matching is case-insensitive (#5)", () => {
354
+ describe("mutation coreContent-Type matching is case-insensitive", () => {
358
355
  it("parses JSON when Content-Type is `Application/JSON` (RFC 9110 — case-insensitive media type)", async () => {
359
356
  const r = {
360
357
  ok: true,
@@ -366,24 +363,24 @@ describe("Story 8.1 — Re-run-3 — Content-Type matching is case-insensitive (
366
363
  globalThis.fetch = vi.fn().mockResolvedValue(r);
367
364
  mockMetaTag(null);
368
365
 
369
- const result = await _makeRef("noop")({});
366
+ const result = await makePostFn()({});
370
367
  expect(result).toEqual({ id: 42 });
371
368
  });
372
369
  });
373
370
 
374
- describe("Story 8.1Re-run-5 — fetch redirect: 'error' (#5)", () => {
371
+ describe("mutation core — fetch redirect: 'error'", () => {
375
372
  it("sets redirect: 'error' on the fetch init so auth `redirect_to` failures surface as errors", async () => {
376
373
  mockFetchOk({ ok: true });
377
374
  mockMetaTag(null);
378
375
 
379
- await _makeRef("create_post")({});
376
+ await makePostFn()({});
380
377
 
381
378
  const [, init] = globalThis.fetch.mock.calls[0];
382
379
  expect(init.redirect).toBe("error");
383
380
  });
384
381
  });
385
382
 
386
- describe("Story 8.1Re-run-5 — configureRuactRuntime (#6)", () => {
383
+ describe("mutation core — configureRuactRuntime", () => {
387
384
  afterEach(() => {
388
385
  configureRuactRuntime({ defaultHeaders: null });
389
386
  });
@@ -393,7 +390,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
393
390
  mockMetaTag(null);
394
391
  configureRuactRuntime({ defaultHeaders: { Authorization: "Bearer abc" } });
395
392
 
396
- await _makeRef("create_post")({});
393
+ await makePostFn()({});
397
394
 
398
395
  const [, init] = globalThis.fetch.mock.calls[0];
399
396
  expect(init.headers.Authorization).toBe("Bearer abc");
@@ -410,8 +407,8 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
410
407
  },
411
408
  });
412
409
 
413
- await _makeRef("create_post")({});
414
- await _makeRef("create_post")({});
410
+ await makePostFn()({});
411
+ await makePostFn()({});
415
412
 
416
413
  expect(globalThis.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer t1");
417
414
  expect(globalThis.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer t2");
@@ -428,7 +425,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
428
425
  },
429
426
  });
430
427
 
431
- await _makeRef("create_post")({});
428
+ await makePostFn()({});
432
429
 
433
430
  const [, init] = globalThis.fetch.mock.calls[0];
434
431
  expect(init.headers["X-CSRF-Token"]).toBe("real-csrf");
@@ -458,7 +455,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
458
455
  },
459
456
  });
460
457
 
461
- await _makeRef("create_post")({});
458
+ await makePostFn()({});
462
459
 
463
460
  const [, init] = globalThis.fetch.mock.calls[0];
464
461
  expect(init.headers.Accept).toBe("application/json");
@@ -483,7 +480,7 @@ describe("Story 8.1 — Re-run-5 — configureRuactRuntime (#6)", () => {
483
480
 
484
481
  const fd = new FormData();
485
482
  fd.append("title", "Hello");
486
- await _makeRef("upload")(fd);
483
+ await makePostFn()(fd);
487
484
 
488
485
  const [, init] = globalThis.fetch.mock.calls[0];
489
486
  expect(init.headers["Content-Type"]).toBeUndefined();
@@ -508,12 +505,12 @@ describe("Story 8.1 — __internals (test-only surface)", () => {
508
505
  // Story 8.2 — useActionState two-arg invocation
509
506
  // =============================================================================
510
507
 
511
- describe("Story 8.2 — _makeRef call-shape detection", () => {
508
+ describe("Story 8.2 — call-shape detection (useActionState two-arg)", () => {
512
509
  it("fn() — zero args sends an empty JSON body (parity with Story 8.1 fn() shape)", async () => {
513
510
  mockFetchOk({});
514
511
  mockMetaTag(null);
515
512
 
516
- await _makeRef("noop")();
513
+ await makePostFn()();
517
514
 
518
515
  const [, init] = globalThis.fetch.mock.calls[0];
519
516
  expect(init.body).toBe("{}");
@@ -524,7 +521,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
524
521
  mockFetchOk({});
525
522
  mockMetaTag(null);
526
523
 
527
- await _makeRef("create_post")({ title: "Hi" });
524
+ await makePostFn()({ title: "Hi" });
528
525
 
529
526
  const [, init] = globalThis.fetch.mock.calls[0];
530
527
  expect(init.body).toBe(JSON.stringify({ title: "Hi" }));
@@ -536,7 +533,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
536
533
 
537
534
  const fd = new FormData();
538
535
  fd.append("title", "Hi");
539
- await _makeRef("create_post")(fd);
536
+ await makePostFn()(fd);
540
537
 
541
538
  const [, init] = globalThis.fetch.mock.calls[0];
542
539
  expect(init.body).toBe(fd);
@@ -550,7 +547,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
550
547
 
551
548
  const fd = new FormData();
552
549
  fd.append("title", "From form");
553
- await _makeRef("create_post")({ message: "previous state" }, fd);
550
+ await makePostFn()({ message: "previous state" }, fd);
554
551
 
555
552
  const [, init] = globalThis.fetch.mock.calls[0];
556
553
  expect(init.body).toBe(fd);
@@ -562,7 +559,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
562
559
  mockFetchOk({});
563
560
  mockMetaTag(null);
564
561
 
565
- await _makeRef("create_post")({ message: "previous state" }, { title: "Hi" });
562
+ await makePostFn()({ message: "previous state" }, { title: "Hi" });
566
563
 
567
564
  const [, init] = globalThis.fetch.mock.calls[0];
568
565
  expect(init.body).toBe(JSON.stringify({ title: "Hi" }));
@@ -576,16 +573,16 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
576
573
 
577
574
  const fd = new FormData();
578
575
  fd.append("title", "FD");
579
- await _makeRef("create_post")(fd, { title: "obj" });
576
+ await makePostFn()(fd, { title: "obj" });
580
577
 
581
578
  const [, init] = globalThis.fetch.mock.calls[0];
582
579
  expect(init.body).toBe(fd);
583
580
  });
584
581
 
585
- it("fn(a, b, c) — three or more args throws TypeError with a descriptive message", () => {
586
- expect(() => _makeRef("create_post")(1, 2, 3)).toThrow(TypeError);
587
- expect(() => _makeRef("create_post")(1, 2, 3)).toThrow(
588
- /ruact action :create_post called with 3 arguments — expected 0, 1, or 2/,
582
+ it("fn(a, b, c) — three or more args rejects with a descriptive TypeError", async () => {
583
+ await expect(makePostFn()(1, 2, 3)).rejects.toThrow(TypeError);
584
+ await expect(makePostFn()(1, 2, 3)).rejects.toThrow(
585
+ /ruact server function POST \/posts called with 3 arguments — expected 0, 1, or 2/,
589
586
  );
590
587
  });
591
588
 
@@ -601,7 +598,7 @@ describe("Story 8.2 — _makeRef call-shape detection", () => {
601
598
  // Pre-Story-8.2 this would have thrown on JSON.stringify(circular). The
602
599
  // wire path never sees prevState, so circular references are harmless.
603
600
  await expect(
604
- _makeRef("create_post")(circular, fd),
601
+ makePostFn()(circular, fd),
605
602
  ).resolves.not.toThrow();
606
603
 
607
604
  const [, init] = globalThis.fetch.mock.calls[0];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruact-server-functions-runtime",
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 }).",
3
+ "version": "0.4.0",
4
+ "description": "Route-driven server-functions runtime for the ruact gem. Provides `_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 }; identical concurrent calls share one in-flight request).",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "./index.d.ts",
@@ -15,6 +15,10 @@ let originalFetch;
15
15
 
16
16
  beforeEach(() => {
17
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();
18
22
  });
19
23
 
20
24
  afterEach(() => {
@@ -44,6 +48,39 @@ function mockFetchError(status, bodyText) {
44
48
  return response;
45
49
  }
46
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
+
47
84
  describe("Story 9.5 — useQuery hook contract", () => {
48
85
  it("starts loading, then resolves to { data, loading: false, error: null }", async () => {
49
86
  const ref = vi.fn().mockResolvedValue({ items: [1, 2] });
@@ -179,3 +216,153 @@ describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
179
216
  expect(init.redirect).toBe("error");
180
217
  });
181
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
+ });