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,26 +1,27 @@
1
- // Story 8.1 — real server-functions runtime.
1
+ // Server-functions runtime (route-driven, v2).
2
2
  //
3
- // Replaces the Story 8.0a placeholder. Each export of the generated module
4
- // `app/javascript/.ruact/server-functions.ts` calls `_makeRef("<symbol>")`
5
- // and gets back a function that, when invoked, POSTs to
6
- // `/__ruact/fn/<symbol>` with the args serialized as JSON or FormData.
3
+ // Each export of the generated module `app/javascript/.ruact/server-functions.ts`
4
+ // is built by `_makeServerFunction({ method, path, segments })` (mutations) or
5
+ // `_makeQuery({ path, kind: "query" })` (reads), targeting the REAL Rails route
6
+ // + verb. Story 9.9 demolished the v1 `_makeRef` accessor + the synthetic
7
+ // endpoint it POSTed to.
7
8
  //
8
- // Wire contract (locked by Story 8.0 ADR Decision-log clarification of
9
- // 2026-05-13, items 2–3): POST for everything (actions AND queries),
10
- // request body carries the args, response is JSON. CSRF symmetric — the
11
- // runtime forwards the `<meta name="csrf-token">` value as `X-CSRF-Token`
12
- // if the meta tag is present in the document (the gem does not impose its
13
- // own CSRF; the host's `protect_from_forgery` is what enforces).
9
+ // Wire contract: mutations send the args as JSON or FormData over the route's
10
+ // real verb; reads issue a GET with params serialized into the query string;
11
+ // responses are JSON. CSRF (mutations only) — the runtime forwards the
12
+ // `<meta name="csrf-token">` value as `X-CSRF-Token` if the meta tag is present
13
+ // (the gem does not impose its own CSRF; the host's `protect_from_forgery`
14
+ // enforces). Reads are CSRF-free (GET semantics).
14
15
  //
15
- // The `_makeRef` export surface and the `"ruact/server-functions-runtime"`
16
- // import path are part of the locked API — do NOT change without coordinated
17
- // codegen + Vite-plugin updates.
16
+ // The export surface (`_makeServerFunction`, `_makeQuery`, `useQuery`,
17
+ // `revalidate`, `configureRuactRuntime`, `RuactActionError`) and the
18
+ // `"ruact/server-functions-runtime"` import path are part of the locked API —
19
+ // do NOT change without coordinated codegen + Vite-plugin updates.
18
20
 
19
21
  // Story 9.5 — `useQuery` is a React hook, so the runtime now depends on React.
20
22
  // React is declared as a `peerDependency` (every host already has it; the
21
- // runtime never bundles its own copy). This is the runtime's first React
22
- // import — the mutation path (`_makeRef` / `_makeServerFunction`) stays
23
- // React-free.
23
+ // runtime never bundles its own copy). The mutation path
24
+ // (`_makeServerFunction`) stays React-free.
24
25
  import { useState, useEffect } from "react";
25
26
 
26
27
  const RUNTIME_VERSION = 1;
@@ -28,8 +29,8 @@ const RUNTIME_VERSION = 1;
28
29
  // Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
29
30
  // in API mode (no session cookie / no CSRF meta tag) need a way to
30
31
  // inject auth headers (`Authorization: Bearer …`) on every call.
31
- // `_makeRef`'s signature is locked by the codegen so we don't widen
32
- // it; instead, hosts call `configureRuactRuntime` once at app boot
32
+ // the accessor signatures are locked by the codegen so we don't widen
33
+ // them; instead, hosts call `configureRuactRuntime` once at app boot
33
34
  // to register a headers-producing function. The function runs on
34
35
  // every fetch so dynamic tokens (refreshed at runtime) are picked up.
35
36
  const runtimeOptions = {
@@ -73,59 +74,22 @@ export class RuactActionError extends Error {
73
74
  }
74
75
 
75
76
  /**
76
- * Returns a callable accessor for a server function registered with the
77
- * given Ruby symbol name. The accessor, when invoked, POSTs the args to
78
- * `/__ruact/fn/${name}` and resolves with the response (JSON-decoded for
79
- * application/json responses, text for everything else).
80
- *
81
- * Story 8.2 — the returned function accepts up to TWO positional
82
- * arguments to support React 19's `useActionState` shape:
83
- *
84
- * useActionState(action, initialState)
77
+ * Story 9.3 the route-driven (v2) accessor. The codegen emits
78
+ * `_makeServerFunction({ method, path, segments })` for every non-GET routed
79
+ * action on a `Ruact::Server` controller. The returned callable targets the
80
+ * REAL Rails route + verb (e.g. `POST /posts`, `PUT /posts/:id`).
85
81
  *
86
- * calls `action(prevState, formData)` on every submit. `_makeRef` picks
87
- * the FormData-typed candidate from the call and discards the prevState
88
- * argument silently prev-state is a client-only concern, never
82
+ * Story 8.2 the returned function accepts up to TWO positional arguments to
83
+ * support React 19's `useActionState` shape: `useActionState(action,
84
+ * initialState)` calls `action(prevState, formData)` on every submit. The
85
+ * accessor picks the FormData-typed candidate from the call and discards the
86
+ * prevState argument silently — prev-state is a client-only concern, never
89
87
  * transmitted to the server. The single-arg shape (`fn(args)` from event
90
88
  * handlers; `<form action={fn}>` passing FormData directly) is preserved.
91
89
  *
92
- * Argument shape selection rules (first match wins):
93
- * - 0 args → JSON body, `{}`
94
- * - 1 arg, FormData → multipart
95
- * - 1 arg, plain object / null / undefined → JSON body
96
- * - 2 args, FormData in either slot → multipart (FormData wins;
97
- * the other arg is discarded)
98
- * - 2 args, neither FormData → JSON body of the SECOND arg
99
- * (the `useActionState` payload
100
- * slot); first arg discarded
101
- * as prev-state
102
- * - 3+ args → TypeError
103
- *
104
- * @param {string} name
105
- * @returns {(arg1?: Record<string, unknown> | FormData, arg2?: FormData | Record<string, unknown>) => Promise<unknown>}
106
- */
107
- export function _makeRef(name) {
108
- return function ruactServerFunctionCall(...callArgs) {
109
- if (callArgs.length > 2) {
110
- throw new TypeError(
111
- `ruact action :${name} called with ${callArgs.length} arguments — ` +
112
- "expected 0, 1, or 2 (the useActionState shape)",
113
- );
114
- }
115
- return ruactPost(name, pickWirePayload(callArgs));
116
- };
117
- }
118
-
119
- /**
120
- * Story 9.3 — the route-driven (v2) accessor. The codegen emits
121
- * `_makeServerFunction({ method, path, segments })` for every non-GET routed
122
- * action on a `Ruact::Server` controller (instead of v1's `_makeRef("<sym>")`).
123
- * The returned callable targets the REAL Rails route + verb (e.g. `POST /posts`,
124
- * `PUT /posts/:id`) rather than the v1 synthetic `POST /__ruact/fn/:name`.
125
- *
126
- * It shares the exact fetch core (`ruactInvoke`) with `_makeRef` — FormData
127
- * branching, CSRF meta injection, text-first parsing, `RuactActionError`,
128
- * `redirect: "error"` — so all the salvaged 8.1/8.2 behaviors are preserved.
90
+ * It uses the shared fetch core (`ruactInvoke`) FormData branching, CSRF
91
+ * meta injection, text-first parsing, `RuactActionError`, `redirect: "error"`
92
+ * — so all the salvaged 8.1/8.2 behaviors are preserved.
129
93
  * Two additions over v1:
130
94
  * - **Path-param interpolation (D7):** dynamic `:id`-style segments are read
131
95
  * BY NAME from the single call argument (FormData.get / object property) and
@@ -163,9 +127,9 @@ export function _makeServerFunction(descriptor) {
163
127
  * named query route (`GET /q/<jsId>`), serializing `params` into the query
164
128
  * string.
165
129
  *
166
- * Reads are CSRF-free (NFR27 / the 2026-06-02 ADR addendum voids the old
167
- * `POST /__ruact/fn/:id` query mechanism and restores HTTP GET semantics):
168
- * no request body, no `X-CSRF-Token`. It shares `parseResponse` /
130
+ * Reads are CSRF-free (NFR27 / the 2026-06-02 ADR addendum restored HTTP GET
131
+ * semantics for queries): no request body, no `X-CSRF-Token`. It shares
132
+ * `parseResponse` /
169
133
  * `RuactActionError` / `redirect: "error"` with the mutation path so the
170
134
  * success + failure shapes are identical.
171
135
  *
@@ -187,6 +151,96 @@ export function _makeQuery(descriptor) {
187
151
  };
188
152
  }
189
153
 
154
+ // Story 9.6 — in-flight request de-duplication for `useQuery`. Identical
155
+ // concurrent calls (same query reference + same params) share ONE network
156
+ // request instead of each firing its own GET. The registry maps a query
157
+ // reference (the codegen-emitted `_makeQuery` accessor — stable module identity,
158
+ // so the same import is the same key) to a `Map` of canonical-params-key →
159
+ // in-flight Promise. Each promise is removed the instant it settles, so dedup is
160
+ // strictly IN-FLIGHT-ONLY: no TTL, no stale-while-revalidate (Story 9.7 documents
161
+ // that scope). A mount AFTER the previous request settled always refetches.
162
+ //
163
+ // Keyed by the reference FUNCTION via a WeakMap so dynamically-created references
164
+ // can be GC'd; the inner `Map` self-empties as requests settle. `let` (not
165
+ // `const`) so the test-only `__resetQueryDedup` can swap a fresh registry in
166
+ // between cases — `dedupedQuery` closes over the binding, so the swap is seen.
167
+ let inflightQueries = new WeakMap();
168
+
169
+ // Order-independent dedup key, used BOTH as the registry key and as the
170
+ // `useEffect` dependency. It mirrors the WIRE output of `buildQueryUrl` exactly
171
+ // (the per-param tokens, sorted so order doesn't matter), so two param objects
172
+ // share a request IFF they produce the SAME query string — the true "same
173
+ // network request" equivalence. Building from the wire (not `JSON.stringify`)
174
+ // is deliberate: `JSON.stringify` maps `NaN`/`Infinity`/`-Infinity` to `null`,
175
+ // which would collide those distinct wire values (`?q=NaN` vs bare `?q`) onto
176
+ // one key. Mirrors `buildQueryUrl`'s rules: `undefined` skipped, `null` → bare
177
+ // key, string/number/boolean → `key=String(value)`.
178
+ //
179
+ // Returns `null` to signal a NON-SHAREABLE shape (a non-object/array top level,
180
+ // or an array/object VALUE) — exactly the inputs `buildQueryUrl` rejects with a
181
+ // `TypeError`. `dedupedQuery` bypasses the registry for those so the reference
182
+ // is still invoked and the error surfaces (Story 9.5 behavior), instead of an
183
+ // invalid call wrongly joining an in-flight no-param request. `null`/no params
184
+ // both serialize to "".
185
+ function canonicalParamsKey(params) {
186
+ if (params == null) return "";
187
+ if (typeof params !== "object" || Array.isArray(params)) return null;
188
+ const tokens = [];
189
+ for (const key of Object.keys(params)) {
190
+ const value = params[key];
191
+ if (value === undefined) continue;
192
+ if (value === null) {
193
+ tokens.push(encodeURIComponent(key)); // bare key — mirrors buildQueryUrl
194
+ } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
195
+ tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
196
+ } else {
197
+ return null; // array/object value — buildQueryUrl throws; non-shareable
198
+ }
199
+ }
200
+ tokens.sort();
201
+ return tokens.join("&");
202
+ }
203
+
204
+ // Share the in-flight request for (reference, canonical params). The first
205
+ // caller creates the promise and registers it; concurrent callers with the same
206
+ // key get the SAME promise back — one fetch, shared resolution, shared error.
207
+ // The settle handler removes the entry, guarded by promise identity so a newer
208
+ // in-flight request for the same key (started after this one settled but before
209
+ // its handler ran) is never clobbered. A rejected promise is removed the same
210
+ // way, so a failed shared request does not poison the key — the next mount
211
+ // retries fresh.
212
+ function dedupedQuery(reference, params) {
213
+ const key = canonicalParamsKey(params);
214
+ // A `null` key marks a non-shareable shape (the inputs `buildQueryUrl`
215
+ // rejects). Bypass the registry entirely so the reference is invoked and its
216
+ // `TypeError` surfaces on the error path — never share onto another request.
217
+ if (key === null) {
218
+ return Promise.resolve().then(() => reference(params));
219
+ }
220
+ let perRef = inflightQueries.get(reference);
221
+ if (perRef) {
222
+ const existing = perRef.get(key);
223
+ if (existing) return existing;
224
+ } else {
225
+ perRef = new Map();
226
+ inflightQueries.set(reference, perRef);
227
+ }
228
+ // Wrap in Promise.resolve so a SYNCHRONOUS throw inside `reference` (e.g. a
229
+ // non-primitive param rejected by `buildQueryUrl`) becomes a rejected promise
230
+ // on the error path, preserving the Story 9.5 "sync throw surfaces as error"
231
+ // behavior.
232
+ const promise = Promise.resolve().then(() => reference(params));
233
+ perRef.set(key, promise);
234
+ const forget = () => {
235
+ if (perRef.get(key) === promise) perRef.delete(key);
236
+ };
237
+ // `.then(forget, forget)` (not `.finally`) so the cleanup branch swallows a
238
+ // rejection rather than spawning a derived promise that could surface as an
239
+ // unhandled rejection. The original `promise` is what callers handle.
240
+ promise.then(forget, forget);
241
+ return promise;
242
+ }
243
+
190
244
  /**
191
245
  * Story 9.5 — React hook for reading a server query. Pass a query reference
192
246
  * (the codegen-emitted `_makeQuery` accessor) and optional params; the hook
@@ -202,8 +256,10 @@ export function _makeQuery(descriptor) {
202
256
  * A superseded in-flight response (params changed, or the component
203
257
  * unmounted) is dropped — the hook never sets state for a stale request.
204
258
  *
205
- * Request de-duplication across components is Story 9.6; this hook fetches
206
- * once per mount.
259
+ * Story 9.6 identical CONCURRENT calls (same reference + same params,
260
+ * order-independent) share ONE in-flight request. Dedup is in-flight only:
261
+ * once the request settles the shared entry is dropped, so a fresh mount
262
+ * refetches — there is no TTL cache and no stale-while-revalidate.
207
263
  *
208
264
  * @param {(params?: Record<string, unknown>) => Promise<unknown>} reference
209
265
  * @param {Record<string, unknown>} [params]
@@ -214,17 +270,16 @@ export function useQuery(reference, params) {
214
270
 
215
271
  // Re-run the effect by VALUE, not identity: an inline `{ q: input }` literal
216
272
  // is a fresh object every render, which would refetch on every render if used
217
- // directly as a dependency. Serializing collapses equal params to one key.
218
- const paramsKey = params == null ? "" : JSON.stringify(params);
273
+ // directly as a dependency. The canonical (sorted-key) serialization collapses
274
+ // equal params including reordered keys to one string, and is the SAME key
275
+ // the dedup registry uses, so the effect dependency and the shared-request
276
+ // identity stay in lockstep.
277
+ const paramsKey = canonicalParamsKey(params);
219
278
 
220
279
  useEffect(() => {
221
280
  let active = true;
222
281
  setState((prev) => ({ data: prev.data, loading: true, error: null }));
223
- // Wrap in Promise.resolve so a synchronous throw inside `reference`
224
- // (e.g. a non-primitive param rejected by `buildQueryUrl`) lands on the
225
- // error branch instead of escaping the effect.
226
- Promise.resolve()
227
- .then(() => reference(params))
282
+ dedupedQuery(reference, params)
228
283
  .then((data) => {
229
284
  if (active) setState({ data, loading: false, error: null });
230
285
  })
@@ -242,8 +297,8 @@ export function useQuery(reference, params) {
242
297
  }
243
298
 
244
299
  // Story 8.2 — picks the argument the wire request should serialize from
245
- // `_makeRef`'s call-args, following the rules documented in the JSDoc
246
- // above. Exported through `__internals` for the vitest suite (AC10) — it
300
+ // `_makeServerFunction`'s call-args, following the useActionState rules
301
+ // documented above. Exported through `__internals` for the vitest suite (AC10) — it
247
302
  // is intentionally NOT part of the public runtime surface.
248
303
  function pickWirePayload(callArgs) {
249
304
  const isFD = (v) => typeof FormData !== "undefined" && v instanceof FormData;
@@ -312,31 +367,21 @@ export const __internals = {
312
367
  followRedirectIfPresent,
313
368
  buildQueryUrl,
314
369
  buildQueryFetchInit,
370
+ // Story 9.6 — exposed for the dedup vitest suite. `canonicalParamsKey` is the
371
+ // order-independent dedup/effect key; `__resetQueryDedup` swaps a fresh
372
+ // in-flight registry so a test asserting mid-flight state starts from a clean
373
+ // slate (the registry self-empties on settle, so this only matters for tests
374
+ // that deliberately leave a request pending).
375
+ canonicalParamsKey,
376
+ __resetQueryDedup: () => {
377
+ inflightQueries = new WeakMap();
378
+ },
315
379
  };
316
380
 
317
- // v1 (Story 8.1)POST to the synthetic endpoint. A thin wrapper over the
318
- // shared `ruactInvoke` core; the URL, verb, and error label are exactly what
319
- // 8.1 used so the v1 path stays byte-behavior-identical (Story 9.3 AC6).
320
- //
321
- // Re-run-3 (2026-05-15) — `encodeURIComponent(name)` so a stray `/`, `?`, or
322
- // `#` in a name (only reachable through direct/buggy `_makeRef` calls — the
323
- // gem-side route constraint and the codegen validator both refuse
324
- // non-identifier characters) cannot rewrite the path or hijack the
325
- // query/fragment of the request URL.
326
- function ruactPost(name, args) {
327
- return ruactInvoke({
328
- method: "POST",
329
- url: `/__ruact/fn/${encodeURIComponent(name)}`,
330
- args,
331
- label: name,
332
- });
333
- }
334
-
335
- // Story 9.3 — the shared fetch core for BOTH v1 (`_makeRef`) and v2
336
- // (`_makeServerFunction`). Extracted verbatim from the original `ruactPost` so
337
- // neither path drifts: same FormData branching, CSRF injection, `redirect:
338
- // "error"`, text-first parsing, and structured `RuactActionError`. The only
339
- // parameters are the verb + URL + the wire payload + a human label for errors.
381
+ // Story 9.3 — the shared fetch core for the route-driven (`_makeServerFunction`)
382
+ // mutation path: FormData branching, CSRF injection, `redirect: "error"`,
383
+ // text-first parsing, and structured `RuactActionError`. The only parameters
384
+ // are the verb + URL + the wire payload + a human label for errors.
340
385
  async function ruactInvoke({ method, url, args, label }) {
341
386
  const init = buildFetchInit(args, method);
342
387
  let response;
@@ -472,8 +517,7 @@ function readSegment(args, seg) {
472
517
  // Story 9.3 (AC8 / D2) — the client half of the `$redirect` contract 9.2
473
518
  // deferred. A Bucket-2 mutation that `redirect_to`s returns
474
519
  // `{ "$redirect": "<path>" }`; follow it via the router handoff and resolve
475
- // `null` (consistent with the 204→null contract). Only applied on the v2 path
476
- // — the v1 endpoint never emits `$redirect`, so `_makeRef` is unaffected (AC6).
520
+ // `null` (consistent with the 204→null contract).
477
521
  function followRedirectIfPresent(parsed) {
478
522
  if (
479
523
  parsed &&