ruact 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,13 @@
16
16
  // import path are part of the locked API — do NOT change without coordinated
17
17
  // codegen + Vite-plugin updates.
18
18
 
19
+ // Story 9.5 — `useQuery` is a React hook, so the runtime now depends on React.
20
+ // 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.
24
+ import { useState, useEffect } from "react";
25
+
19
26
  const RUNTIME_VERSION = 1;
20
27
 
21
28
  // Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
@@ -149,6 +156,182 @@ export function _makeServerFunction(descriptor) {
149
156
  };
150
157
  }
151
158
 
159
+ /**
160
+ * Story 9.5 — the read-side (query) accessor. The codegen emits
161
+ * `_makeQuery({ path, kind: "query" })` for every method on a mounted
162
+ * `Ruact::Query` subclass. The returned callable issues a **GET** to the
163
+ * named query route (`GET /q/<jsId>`), serializing `params` into the query
164
+ * string.
165
+ *
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` /
169
+ * `RuactActionError` / `redirect: "error"` with the mutation path so the
170
+ * success + failure shapes are identical.
171
+ *
172
+ * Usually consumed through {useQuery}, but the returned function is a plain
173
+ * `(params?) => Promise<unknown>` so it also works in imperative code.
174
+ *
175
+ * FR88 wire format: only primitives (string / number / boolean / null) are
176
+ * encoded into the query string; arrays and objects throw a `TypeError`
177
+ * client-side too — though the server-side sanitization is authoritative.
178
+ *
179
+ * @param {{ path: string, kind?: string }} descriptor
180
+ * @returns {(params?: Record<string, unknown>) => Promise<unknown>}
181
+ */
182
+ export function _makeQuery(descriptor) {
183
+ const { path } = descriptor || {};
184
+ return function ruactQueryCall(params) {
185
+ const url = buildQueryUrl(path, params);
186
+ return ruactQueryGet(url, path);
187
+ };
188
+ }
189
+
190
+ // Story 9.6 — in-flight request de-duplication for `useQuery`. Identical
191
+ // concurrent calls (same query reference + same params) share ONE network
192
+ // request instead of each firing its own GET. The registry maps a query
193
+ // reference (the codegen-emitted `_makeQuery` accessor — stable module identity,
194
+ // so the same import is the same key) to a `Map` of canonical-params-key →
195
+ // in-flight Promise. Each promise is removed the instant it settles, so dedup is
196
+ // strictly IN-FLIGHT-ONLY: no TTL, no stale-while-revalidate (Story 9.7 documents
197
+ // that scope). A mount AFTER the previous request settled always refetches.
198
+ //
199
+ // Keyed by the reference FUNCTION via a WeakMap so dynamically-created references
200
+ // can be GC'd; the inner `Map` self-empties as requests settle. `let` (not
201
+ // `const`) so the test-only `__resetQueryDedup` can swap a fresh registry in
202
+ // between cases — `dedupedQuery` closes over the binding, so the swap is seen.
203
+ let inflightQueries = new WeakMap();
204
+
205
+ // Order-independent dedup key, used BOTH as the registry key and as the
206
+ // `useEffect` dependency. It mirrors the WIRE output of `buildQueryUrl` exactly
207
+ // (the per-param tokens, sorted so order doesn't matter), so two param objects
208
+ // share a request IFF they produce the SAME query string — the true "same
209
+ // network request" equivalence. Building from the wire (not `JSON.stringify`)
210
+ // is deliberate: `JSON.stringify` maps `NaN`/`Infinity`/`-Infinity` to `null`,
211
+ // which would collide those distinct wire values (`?q=NaN` vs bare `?q`) onto
212
+ // one key. Mirrors `buildQueryUrl`'s rules: `undefined` skipped, `null` → bare
213
+ // key, string/number/boolean → `key=String(value)`.
214
+ //
215
+ // Returns `null` to signal a NON-SHAREABLE shape (a non-object/array top level,
216
+ // or an array/object VALUE) — exactly the inputs `buildQueryUrl` rejects with a
217
+ // `TypeError`. `dedupedQuery` bypasses the registry for those so the reference
218
+ // is still invoked and the error surfaces (Story 9.5 behavior), instead of an
219
+ // invalid call wrongly joining an in-flight no-param request. `null`/no params
220
+ // both serialize to "".
221
+ function canonicalParamsKey(params) {
222
+ if (params == null) return "";
223
+ if (typeof params !== "object" || Array.isArray(params)) return null;
224
+ const tokens = [];
225
+ for (const key of Object.keys(params)) {
226
+ const value = params[key];
227
+ if (value === undefined) continue;
228
+ if (value === null) {
229
+ tokens.push(encodeURIComponent(key)); // bare key — mirrors buildQueryUrl
230
+ } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
231
+ tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
232
+ } else {
233
+ return null; // array/object value — buildQueryUrl throws; non-shareable
234
+ }
235
+ }
236
+ tokens.sort();
237
+ return tokens.join("&");
238
+ }
239
+
240
+ // Share the in-flight request for (reference, canonical params). The first
241
+ // caller creates the promise and registers it; concurrent callers with the same
242
+ // key get the SAME promise back — one fetch, shared resolution, shared error.
243
+ // The settle handler removes the entry, guarded by promise identity so a newer
244
+ // in-flight request for the same key (started after this one settled but before
245
+ // its handler ran) is never clobbered. A rejected promise is removed the same
246
+ // way, so a failed shared request does not poison the key — the next mount
247
+ // retries fresh.
248
+ function dedupedQuery(reference, params) {
249
+ const key = canonicalParamsKey(params);
250
+ // A `null` key marks a non-shareable shape (the inputs `buildQueryUrl`
251
+ // rejects). Bypass the registry entirely so the reference is invoked and its
252
+ // `TypeError` surfaces on the error path — never share onto another request.
253
+ if (key === null) {
254
+ return Promise.resolve().then(() => reference(params));
255
+ }
256
+ let perRef = inflightQueries.get(reference);
257
+ if (perRef) {
258
+ const existing = perRef.get(key);
259
+ if (existing) return existing;
260
+ } else {
261
+ perRef = new Map();
262
+ inflightQueries.set(reference, perRef);
263
+ }
264
+ // Wrap in Promise.resolve so a SYNCHRONOUS throw inside `reference` (e.g. a
265
+ // non-primitive param rejected by `buildQueryUrl`) becomes a rejected promise
266
+ // on the error path, preserving the Story 9.5 "sync throw surfaces as error"
267
+ // behavior.
268
+ const promise = Promise.resolve().then(() => reference(params));
269
+ perRef.set(key, promise);
270
+ const forget = () => {
271
+ if (perRef.get(key) === promise) perRef.delete(key);
272
+ };
273
+ // `.then(forget, forget)` (not `.finally`) so the cleanup branch swallows a
274
+ // rejection rather than spawning a derived promise that could surface as an
275
+ // unhandled rejection. The original `promise` is what callers handle.
276
+ promise.then(forget, forget);
277
+ return promise;
278
+ }
279
+
280
+ /**
281
+ * Story 9.5 — React hook for reading a server query. Pass a query reference
282
+ * (the codegen-emitted `_makeQuery` accessor) and optional params; the hook
283
+ * issues `GET /q/<jsId>` on mount (and whenever the serialized params change)
284
+ * and returns `{ data, loading, error }`:
285
+ *
286
+ * - `loading` is true until the first resolution;
287
+ * - `data` carries the JSON-decoded response on success (and the last
288
+ * successful value while a subsequent refetch is in flight);
289
+ * - `error` carries the structured `RuactActionError` (or a transport
290
+ * `Error`) on failure, and is reset to `null` on a successful refetch.
291
+ *
292
+ * A superseded in-flight response (params changed, or the component
293
+ * unmounted) is dropped — the hook never sets state for a stale request.
294
+ *
295
+ * Story 9.6 — identical CONCURRENT calls (same reference + same params,
296
+ * order-independent) share ONE in-flight request. Dedup is in-flight only:
297
+ * once the request settles the shared entry is dropped, so a fresh mount
298
+ * refetches — there is no TTL cache and no stale-while-revalidate.
299
+ *
300
+ * @param {(params?: Record<string, unknown>) => Promise<unknown>} reference
301
+ * @param {Record<string, unknown>} [params]
302
+ * @returns {{ data: unknown, loading: boolean, error: unknown }}
303
+ */
304
+ export function useQuery(reference, params) {
305
+ const [state, setState] = useState({ data: undefined, loading: true, error: null });
306
+
307
+ // Re-run the effect by VALUE, not identity: an inline `{ q: input }` literal
308
+ // is a fresh object every render, which would refetch on every render if used
309
+ // directly as a dependency. The canonical (sorted-key) serialization collapses
310
+ // equal params — including reordered keys — to one string, and is the SAME key
311
+ // the dedup registry uses, so the effect dependency and the shared-request
312
+ // identity stay in lockstep.
313
+ const paramsKey = canonicalParamsKey(params);
314
+
315
+ useEffect(() => {
316
+ let active = true;
317
+ setState((prev) => ({ data: prev.data, loading: true, error: null }));
318
+ dedupedQuery(reference, params)
319
+ .then((data) => {
320
+ if (active) setState({ data, loading: false, error: null });
321
+ })
322
+ .catch((error) => {
323
+ if (active) setState((prev) => ({ data: prev.data, loading: false, error }));
324
+ });
325
+ return () => {
326
+ active = false;
327
+ };
328
+ // `params` is intentionally tracked via the serialized `paramsKey`.
329
+ // eslint-disable-next-line react-hooks/exhaustive-deps
330
+ }, [reference, paramsKey]);
331
+
332
+ return state;
333
+ }
334
+
152
335
  // Story 8.2 — picks the argument the wire request should serialize from
153
336
  // `_makeRef`'s call-args, following the rules documented in the JSDoc
154
337
  // above. Exported through `__internals` for the vitest suite (AC10) — it
@@ -218,6 +401,17 @@ export const __internals = {
218
401
  pickWirePayload,
219
402
  interpolatePath,
220
403
  followRedirectIfPresent,
404
+ buildQueryUrl,
405
+ buildQueryFetchInit,
406
+ // Story 9.6 — exposed for the dedup vitest suite. `canonicalParamsKey` is the
407
+ // order-independent dedup/effect key; `__resetQueryDedup` swaps a fresh
408
+ // in-flight registry so a test asserting mid-flight state starts from a clean
409
+ // slate (the registry self-empties on settle, so this only matters for tests
410
+ // that deliberately leave a request pending).
411
+ canonicalParamsKey,
412
+ __resetQueryDedup: () => {
413
+ inflightQueries = new WeakMap();
414
+ },
221
415
  };
222
416
 
223
417
  // v1 (Story 8.1) — POST to the synthetic endpoint. A thin wrapper over the
@@ -265,6 +459,88 @@ async function ruactInvoke({ method, url, args, label }) {
265
459
  return parseResponse(response);
266
460
  }
267
461
 
462
+ // Story 9.5 — the GET fetch core for queries. Mirrors `ruactInvoke`'s
463
+ // structure (loud transport error, structured `RuactActionError` on !ok,
464
+ // shared `parseResponse`) but for a read: GET, no body, no CSRF.
465
+ async function ruactQueryGet(url, label) {
466
+ let response;
467
+ try {
468
+ response = await fetch(url, buildQueryFetchInit());
469
+ } catch (err) {
470
+ throw new Error(
471
+ `ruact query ${label} request failed: ${err?.message ?? err}`,
472
+ );
473
+ }
474
+ if (!response.ok) {
475
+ const body = await safeParseBody(response);
476
+ throw new RuactActionError({ name: label, status: response.status, body, response });
477
+ }
478
+ return parseResponse(response);
479
+ }
480
+
481
+ // Story 9.5 — serialize the params object into the GET query string. FR88
482
+ // wire format: only primitives (string / number / boolean / null) are
483
+ // encoded. Arrays and objects throw a `TypeError` client-side for immediate
484
+ // local feedback; the server-side sanitization in `query_dispatch.rb` is the
485
+ // authoritative gate. `null` is encoded as an empty value (`key=`).
486
+ function buildQueryUrl(path, params) {
487
+ if (params == null) return path;
488
+ if (typeof params !== "object" || Array.isArray(params)) {
489
+ throw new TypeError(
490
+ `ruact useQuery for ${path}: params must be a plain object of ` +
491
+ "string / number / boolean / null values",
492
+ );
493
+ }
494
+ const search = new URLSearchParams();
495
+ // `null` is sent as a BARE key (`?q`, no `=`) — Rack parses that as `nil`,
496
+ // whereas `?q=` parses as `""`. This keeps `null` distinguishable from the
497
+ // empty string at the Ruby query-method boundary (the server allowlist
498
+ // accepts both `nil` and `String`). Bare keys are collected separately
499
+ // because `URLSearchParams` always emits `key=`; their order relative to
500
+ // the `=`-valued params is irrelevant server-side.
501
+ const bareKeys = [];
502
+ for (const [key, value] of Object.entries(params)) {
503
+ if (value === undefined) continue;
504
+ if (value === null) {
505
+ bareKeys.push(encodeURIComponent(key));
506
+ } else if (
507
+ typeof value === "string" ||
508
+ typeof value === "number" ||
509
+ typeof value === "boolean"
510
+ ) {
511
+ search.append(key, String(value));
512
+ } else {
513
+ throw new TypeError(
514
+ `ruact useQuery for ${path}: param "${key}" must be a string, number, ` +
515
+ "boolean, or null — arrays and objects are rejected",
516
+ );
517
+ }
518
+ }
519
+ const qs = [search.toString(), ...bareKeys].filter((s) => s.length > 0).join("&");
520
+ return qs.length === 0 ? path : `${path}?${qs}`;
521
+ }
522
+
523
+ // Story 9.5 — fetch init for a query GET. No body, no `X-CSRF-Token` (reads
524
+ // are CSRF-free). `defaultHeaders` still apply (e.g. an API-mode
525
+ // `Authorization: Bearer …`), with the gem's own `Accept` winning. Keeps
526
+ // `redirect: "error"` so an auth `redirect_to "/login"` surfaces as a loud
527
+ // failure rather than resolving with the login page HTML.
528
+ function buildQueryFetchInit() {
529
+ const RESERVED = new Set(["accept"]);
530
+ const extra =
531
+ typeof runtimeOptions.defaultHeaders === "function"
532
+ ? runtimeOptions.defaultHeaders()
533
+ : runtimeOptions.defaultHeaders;
534
+ const headers = {};
535
+ if (extra && typeof extra === "object") {
536
+ for (const [key, value] of Object.entries(extra)) {
537
+ if (!RESERVED.has(key.toLowerCase())) headers[key] = value;
538
+ }
539
+ }
540
+ headers.Accept = "application/json";
541
+ return { method: "GET", credentials: "same-origin", redirect: "error", headers };
542
+ }
543
+
268
544
  // Story 9.3 (D7) — substitute dynamic path segments (`:id`, …) from the single
269
545
  // call argument. Values are read BY NAME (FormData.get / object property); the
270
546
  // argument is still sent as the body. A missing required segment fails loudly
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruact-server-functions-runtime",
3
- "version": "0.2.0",
4
- "description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2). Provides `_makeRef(name)` (POSTs to `/__ruact/fn/:name` with CSRF + JSON / FormData support, including the useActionState two-arg shape) and `revalidate(path?)` (Flight refetch via the installed ruact-router).",
3
+ "version": "0.4.0",
4
+ "description": "Server-functions runtime for ruact gem (Stories 8.1 + 8.2 + 9.5 + 9.6). 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 }; identical concurrent calls share one in-flight request).",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "./index.d.ts",
@@ -16,7 +16,14 @@
16
16
  "scripts": {
17
17
  "test": "vitest run"
18
18
  },
19
+ "peerDependencies": {
20
+ "react": ">=18"
21
+ },
19
22
  "devDependencies": {
23
+ "@testing-library/react": "^16.1.0",
24
+ "jsdom": "^25.0.1",
25
+ "react": "^19.0.0",
26
+ "react-dom": "^19.0.0",
20
27
  "vitest": "^2.1.9"
21
28
  }
22
29
  }