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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Server-functions runtime (route-driven, v2).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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).
|
|
22
|
-
//
|
|
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
|
-
//
|
|
32
|
-
//
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
-
*
|
|
206
|
-
*
|
|
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.
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
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
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
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).
|
|
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 &&
|