ruact 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ae4dcaf2cb6bc327dabdfcfb4d29b809e4c76e272b3e25238c972c644f37129
4
- data.tar.gz: 7b438642cfd87f31404cfd056bb80f97265f0524f3b2b3d6bdf00a1717983e0d
3
+ metadata.gz: 2f8e264e2e404e5257d0fe7457cef545f4104288dc16db01e1c8e5762d361f99
4
+ data.tar.gz: 4a2ed748edb281b7d8bb0c20ce5e404db6ce7872f82c8db1545349ab709677d3
5
5
  SHA512:
6
- metadata.gz: 100c14dc7e8976ef488f62888fcbce9f2d560038fb75616c26952df54688f8c5fc77968456d81443c19f640505257d1f244288e2b84f8c0fdaac218d4abcd038
7
- data.tar.gz: '028ce642e81327af34b50a5d7cc8ba67599ad419f67517df3965eae0d61f284053bfdc0ec742e5dafb7236cb59637d7fdbb745c90f6dfa7e1a90ad9b14604e0d'
6
+ metadata.gz: 36eb5942d50d3761874bd912a9cfae5ad3538500b8e08a79b196310db8db66bc86fa8a6fc78262c8a2e73ba659feaa1dd821c344fe89ecbddf247c2e05924153
7
+ data.tar.gz: f8902075c57bb47e42f404d43a864829719bddc4f497371097f0c1576ef1565312b45e628f1d7a3e9a3811db497e1aa8325f8e935da4e7b4941ae2bdc468d72e
data/CHANGELOG.md CHANGED
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`useQuery` request de-duplication** ([Story 9.6](../_bmad-output/implementation-artifacts/9-6-automatic-request-deduplication-for-usequery.md)). Identical concurrent `useQuery` calls now share ONE network request: when three components mount `useQuery(categories)` with the same params while a request is in flight, exactly one `GET /q/categories` is issued — all of them receive the same resolved `data`, and an error propagates to every sharer. The dedup key derives from the **query reference identity + the serialized params**, with order-independent param serialization (`{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` share a request; different params do not). Scope is **in-flight only** — there is no TTL cache and no stale-while-revalidate: once a request settles its shared entry is dropped, so a fresh mount refetches. Runtime-only change (`useQuery` hook); the `_makeQuery` GET accessor, codegen, and the Ruby query dispatch are unchanged. Runtime package version `0.3.0` → `0.4.0`; coverage in `usequery.test.mjs`.
13
+
12
14
  - **Queries in codegen + `useQuery` hook + FR88 kwargs sanitization** ([Story 9.5](../_bmad-output/implementation-artifacts/9-5-queries-in-codegen-usequery-re-pointed-kwargs-params.md)). The read-side of the route-driven redesign is complete: `import { categories, useQuery } from "@/.ruact/server-functions"` and `useQuery(categories)` / `useQuery(searchUsers, { q: input })` now work against `Ruact::Query` classes — same mental model as mutations, zero new concepts. **Codegen.** A new `Ruact::ServerFunctions::QuerySource` derives v2 query entries from the **drawn route table** (the GET routes the `ruact_queries` macro mounted under the generated query-dispatch namespace) — route-truth-consistent with dispatch, so only classes actually mounted in `routes.rb` are exposed (no over-exposure, and no `app/queries` force-load needed). Query entries join the **same merged JS namespace** as mutations; collisions fail loudly at boot naming both origins (route×query is caught at the codegen combine point; `query×query` by the source; the `ruact_function_name` rename macro on the mutation controller — or renaming the query method — is the escape hatch). The emitted module binds each query to `_makeQuery({ path, kind: "query" })` with a per-query TS signature (`() => Promise<unknown>` when the method declares no kwargs, `(params: Record<string, unknown>) => Promise<unknown>` when it does) and re-exports `useQuery` from the runtime (only when ≥1 query exists, so action-only/empty v2 modules stay byte-identical to Story 9.3). The Ruby↔JS byte-equality parity test covers query entries. **Runtime.** New `useQuery(reference, params?)` React hook → `{ data, loading, error }` (loading until first resolution; structured `RuactActionError` into `error`; superseded in-flight responses dropped; refetch on value-changed params); new `_makeQuery` GET helper issues `GET /q/<jsId>` with params in the query string — **no body, no CSRF** (reads are CSRF-free; the stale `POST /__ruact/fn/:id` query mechanism is voided). The runtime gains `react` as a **peerDependency** (its first React import; the mutation path stays React-free); package version `0.2.0` → `0.3.0`. **FR88 sanitization.** `query_dispatch.rb#__ruact_query_kwargs` now enforces the authoritative kwargs allowlist on `request.query_parameters`: only `string | number | boolean | null` are accepted — arrays (`?q[]=`) and objects (`?q[k]=`) are **rejected with a descriptive error naming the key and the allowlist**; a **missing required kwarg → 400** naming it; an **unknown param → 400** (rejected, not silently dropped). All raise the new `Ruact::BadRequestError` → HTTP 400 via the Story 8.4 structured payload. 9.5 writes ONLY the parallel `.next` codegen target (ownership flip is 9.8); `useQuery` request de-duplication is 9.6; the v1 substrate is untouched (demolition is 9.9). New files `lib/ruact/server_functions/query_source.rb`; new specs `query_source_spec.rb`, `:story_9_5` cases in `codegen_spec.rb` / `railtie_integration_spec.rb` / `query_request_spec.rb`; new runtime vitest `usequery.test.mjs` + query parity cases. ADR addendum (2026-06-10).
13
15
 
14
16
  - **`Ruact::Query` base class + `ruact_queries` route macro** ([Story 9.4](../_bmad-output/implementation-artifacts/9-4-ruact-query-base-class-and-ruact-queries-route-macro.md)). Server QUERIES are now plain classes under `app/queries/` (`class CatalogQuery < ApplicationQuery`, `ApplicationQuery < Ruact::Query`) — each public method defined directly on the subclass is one query — mounted with one line in `routes.rb`: `ruact_queries CatalogQuery` draws one **named GET route per public method** (`def search_users` → `GET /q/searchUsers`, named `ruact_query_searchUsers`), all visible in `rails routes` (no hidden endpoint; the route table stays the single source of truth). The prefix is configurable via the new `Ruact.config.query_route_prefix` (default `"/q"`). Dispatch goes through an internal gem controller — one generated subclass per query class — inheriting the new `Ruact.config.query_parent_controller` (default `"ApplicationController"`, constantized lazily at route-draw), so the host's REAL callback chain (`authenticate_user!`, tenant scoping, Pundit) runs **before** the query class is instantiated. The query instance is fresh per request and receives its context via the constructor: `current_user` / `params` / `request` / `session` delegate to the dispatching controller (so `current_user` IS the host's own method), and `CatalogQuery.new(fake_context).categories` is unit-testable with no Rails boot. Per-query callback opt-out via the new `ruact_skip_before_action` class macro (mirrors Rails' `skip_before_action` signature incl. `only:`/`except:`/`raise: false`; scoped to the declaring query class by construction). Queries are GET — no CSRF; the method's return value is serialized through the same `ruact_props` / `Ruact::Serializable` / `strict_serialization` policy as Bucket 2 (new `BucketTwoPayload.serialize_value` extraction; `nil` → JSON `null` with 200), and a query raise renders the salvaged structured-error payload with the same 422/403/413/500 mapping (the GET gate is overridden for query dispatch — `Ruact::ConfigurationError` still re-raises loudly). Keyword arguments are passed best-effort from GET query params by name (the strict FR88 sanitization wire contract ships with `useQuery` in Story 9.5; codegen of query entries is also 9.5). New files `lib/ruact/query.rb`, `lib/ruact/routing.rb`, `lib/ruact/server_functions/query_dispatch.rb`, `lib/ruact/server_functions/query_context.rb`. New specs `query_spec.rb`, `query_context_spec.rb`, `query_request_spec.rb` + extensions to `configuration_spec.rb` / `bucket_two_payload_spec.rb`, all tagged `:story_9_4`.
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.4"
4
+ VERSION = "0.0.5"
5
5
  end
@@ -113,8 +113,10 @@ export function _makeQuery(descriptor: {
113
113
  * resolution; `error` carries the structured {@link RuactActionError} on
114
114
  * failure. A superseded in-flight response is dropped.
115
115
  *
116
- * Request de-duplication across components is Story 9.6; this hook fetches
117
- * once per mount.
116
+ * Story 9.6 identical concurrent calls (same reference + same params,
117
+ * order-independent) share ONE in-flight request. Dedup is in-flight only:
118
+ * once a request settles the shared entry is dropped, so a fresh mount
119
+ * refetches (no TTL cache, no stale-while-revalidate).
118
120
  */
119
121
  export function useQuery<T = unknown>(
120
122
  reference: (params?: Record<string, unknown>) => Promise<unknown>,
@@ -187,6 +187,96 @@ export function _makeQuery(descriptor) {
187
187
  };
188
188
  }
189
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
+
190
280
  /**
191
281
  * Story 9.5 — React hook for reading a server query. Pass a query reference
192
282
  * (the codegen-emitted `_makeQuery` accessor) and optional params; the hook
@@ -202,8 +292,10 @@ export function _makeQuery(descriptor) {
202
292
  * A superseded in-flight response (params changed, or the component
203
293
  * unmounted) is dropped — the hook never sets state for a stale request.
204
294
  *
205
- * Request de-duplication across components is Story 9.6; this hook fetches
206
- * once per mount.
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.
207
299
  *
208
300
  * @param {(params?: Record<string, unknown>) => Promise<unknown>} reference
209
301
  * @param {Record<string, unknown>} [params]
@@ -214,17 +306,16 @@ export function useQuery(reference, params) {
214
306
 
215
307
  // Re-run the effect by VALUE, not identity: an inline `{ q: input }` literal
216
308
  // 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);
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);
219
314
 
220
315
  useEffect(() => {
221
316
  let active = true;
222
317
  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))
318
+ dedupedQuery(reference, params)
228
319
  .then((data) => {
229
320
  if (active) setState({ data, loading: false, error: null });
230
321
  })
@@ -312,6 +403,15 @@ export const __internals = {
312
403
  followRedirectIfPresent,
313
404
  buildQueryUrl,
314
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
+ },
315
415
  };
316
416
 
317
417
  // v1 (Story 8.1) — POST to the synthetic endpoint. A thin wrapper over the
@@ -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": "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",
@@ -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
+ });
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia