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 +4 -4
- data/CHANGELOG.md +2 -0
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +4 -2
- data/vendor/javascript/ruact-server-functions-runtime/index.js +109 -9
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f8e264e2e404e5257d0fe7457cef545f4104288dc16db01e1c8e5762d361f99
|
|
4
|
+
data.tar.gz: 4a2ed748edb281b7d8bb0c20ce5e404db6ce7872f82c8db1545349ab709677d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
-
*
|
|
117
|
-
*
|
|
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
|
-
*
|
|
206
|
-
*
|
|
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.
|
|
218
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|