ADR 0021: Front query targets and the React target
Status: accepted
Context
Section titled “Context”@seampact/codegen-fetch generates, per api, a typed client: one method per operation that sends the request, fills the idempotency and deadline headers, reads the declared success, its page facts and the replay flag, and returns every declared error and unexpected answer as a result, or throws it as a ContractError under errorStyle: throw. @seampact/codegen-react generates, over that client, the queries and mutations a React app reads its server data through, for TanStack Query 5 (query: tanstack), SWR 2 (query: swr) or RTK Query 2 (query: rtk). codegen-vue and the state option of codegen-angular follow it, and the MSW target reuses its test harness, so this ADR also fixes what the front targets share.
A query library owns a cache keyed by a value the app computes, runs the request through a function it is given, retries failures, cancels requests a component no longer needs, and refreshes cached queries when a write makes them stale. Each of those is a place where generated code would impose a shape on the app. Seampact imposes none: every behaviour that shapes the app’s cache or data can be turned off, except the contract’s own wire guarantees (A36).
Three properties of the libraries shape the design. TanStack Query and SWR run in the browser and, for prefetch, on a server whose cache crosses into the browser as serialised data; RTK Query keeps its cache in a Redux store whose default middleware warns on any value that is not plain data. A library hands its request function an abort signal for queries (TanStack, RTK) or none (SWR). And TanStack retries a failed mutation by calling the mutation function again with the same variables.
Options
Section titled “Options”- Where the contract’s types come from: a
typeslink tocodegen-ts, or the linked client’s method signatures. Choice: the client; the hooks call that client, so its request, success and error types are the ones the hooks pass on, and no second link can disagree with it. - API form: hooks bound to a client held in a React context, or a factory bound to a client the app passes. Choice: the factory; an app that wants a provider wraps the factory’s result in its own, and a server prefetch builds the same queries over a server client with no React tree.
- Primitive: one hook per operation, or an options factory per operation with the hook as a thin wrapper. Choice: the options factory, the TanStack Query 5 idiom;
useSuspenseQuery,useQueries,prefetchQueryandensureQueryDatatake it as is, so no hook per variant is generated. - Declared errors: returned as query data, as the fetch client does by default, or thrown into the library’s error state. Choice: the error state by default; the libraries drive retries, error boundaries and Suspense from it.
errors: datareturns the whole result as data. - Refresh after a write: none, every query of the api, or the queries of the written resource. Choice: the written resource’s, on by default, since a stale list after a create is the first bug a query library user meets;
invalidate: offdrops it. - Freshness from the contract’s
cache: applied by default, or opt-in. Choice: opt-in (staleTime: contract); the browser’s HTTP cache already appliesCache-Control, and a second layer that keeps data fresh without a request surprises. - Default library: one, or none. Choice: none;
queryis required, and its refusal lists the three. - Where the shared logic lives: in each front target, or a framework-neutral query plan in codegen-core with a small runtime copied into each front target’s output. Choice: shared; Vue renders the same plan, and Angular’s state option reads its keys and resource tags.
- End-to-end proof: hooks rendered in jsdom against a mocked
fetch, or a real browser against a real seampact server. Choice: the browser and the server; the replay flag, the page facts read from headers and the abort on unmount only hold if a real browser reads a real server’s answer. - E2e server origin: cross-origin with CORS exposing the contract’s response headers, or same-origin through Vite’s
server.proxy. Choice: same-origin; a cross-origin browser cannot readLink,X-Total-Count,Retry-Afteror the replay header unless the server lists them inAccess-Control-Expose-Headers, and the spike shows the proxy works in Vitest browser mode.
Decisions
Section titled “Decisions”The client link
Section titled “The client link”codegen-react needs a client link of style fetch, which codegen-fetch provides (ADR 0009 “The provider seam”). The client descriptor holds the module and the exports of createClient, Client, ContractError and CallOptions, the client’s errorStyle, and the method name of each operation; the renderer reads the client’s errorStyle to know whether it converts results. A react entry without a client link, or linked to another kind, fails generate with link.missing or link.kind.mismatch, whose fix names @seampact/codegen-fetch. A react entry that keeps an operation the client entry leaves out is refused by the CLI’s existing link.operations.missing, whose fix is to keep the operation in the client entry’s document and operations.
The generated code reads every type through the client: the request is Parameters<Client["<op>"]>[0], the success data is the success arm of the method’s result, and the error is the client’s ContractError for the operation, carrying its ErrorResult or UnexpectedResult, or the rejection Error of a network failure, a timeout or an abort. No generated code has an any or a cast.
The shared query plan and front runtime
Section titled “The shared query plan and front runtime”@seampact/codegen-core gains a framework-neutral query plan, computed once per api from the IR and the client descriptor, that every front renderer reads:
- per operation, its form: a query for a
safeoperation, plus an infinite query when its paging style allows one (below), or a mutation for any other operation; - the names for each export: the operation’s base name from ADR 0007, plus the affixes this ADR adds (
Options,InfiniteOptions,MutationOptions,use), as inlistTasksOptions,useListTasks,listTasksInfiniteOptions,createTaskMutationOptions; two exports of one module with the same name are refused by name (names.collision), with the operation ids; - the key segments, the resource type, the next-page rule and the call options the operation needs (a required timeout);
- the request header member of the idempotency key, when the operation has one.
A front runtime is copied into each front target’s output with the generated header, as the server runtime is (ADR 0009 “What generated code depends on at run time”). It normalises a request into a key, builds the next page’s request from the page facts, holds one idempotency key per mutation attempt chain, turns a result-style client’s error result into the ContractError the client would have thrown (toContractError, which an RTK user also calls on the plain error to get the typed class back), and turns success headers into a plain record (below). It imports no Node built-in and no framework.
The generated API
Section titled “The generated API”Per api, two modules, re-exported by the api’s index.ts (ADR 0007 “Amendment 2026-10-19: generated modules”):
queries.ts, server-safe:create<Api>Queries(client, settings?)returnskeysand, persafeoperation,<op>Options(request, options?)(queryOptions), plus<op>InfiniteOptions(request, options?)(infiniteQueryOptions) for a paged one, and per other operation<op>MutationOptions(options?)(mutationOptions). It holds no"use client"directive and imports no React hook, so a server component prefetches with it.hooks.ts, starting with"use client":create<Api>Hooks(client, settings?)returns the same members plususe<Op>(request, options?)overuseQuery,use<Op>InfiniteoveruseInfiniteQuery, anduse<Op>(options?)overuseMutation.
const Tasks = createTasksHooks(client);
const tasks = Tasks.useListTasks({ query: { limit: 20 } });const create = Tasks.useCreateTask();const first = useSuspenseQuery(Tasks.listTasksOptions({ query: { limit: 20 } }));The caller’s options are spread after the generated ones, so every library option applies (enabled, select, retry, staleTime, placeholderData); only queryFn and mutationFn stay generated, and a caller’s queryKey wins. onSuccess on a mutation is the one composed option: the generated invalidation runs first, then the caller’s onSuccess, so a spread never drops the invalidation. options.call passes the client’s call options (timeoutMs); it is required, with timeoutMs, for an operation whose client method requires a timeout. settings.idempotencyKey replaces crypto.randomUUID as the key generator, as the client config’s own does; a platform without either is refused when the factory is called, with that fix. The docs bind the result to a PascalCase name (Tasks), which the React hooks lint rule reads as a namespace of hooks.
A query’s key is [<api>, { resource: <type> }, "<op>", request], or [<api>, "<op>", request] for an operation without a resource; the request is the caller’s, without the headers the client fills (idempotency, deadline). An infinite query has its own key, [<api>, { resource: <type> }, "<op>", "infinite", request] (the "infinite" segment follows the operation), since TanStack stores a plain and an infinite read of one key in one cache entry, whose data would then have the wrong shape. keys.all is [<api>], keys.resource(type) is [<api>, { resource: type }], keys.<op>(request) is the operation’s key and keys.<op>Infinite(request) its infinite key; both sit under keys.resource(type), so invalidation reaches them. The key holds no client, so a query prefetched over a server client and read over a browser client is one cache entry. The target option keys.root renames the first segment (A36); RTK Query has no keys, so keys.root with query: rtk is refused by name (options.keys.root.unsupported), whose fix is to drop it (a mapping to reducerPath waits for a consumer).
Data and errors
Section titled “Data and errors”The data of a query is the client’s success result with its headers turned from a Headers object into a plain record of lowercase names. A Headers object is not serialisable: React refuses it in the dehydrated state a server component passes to the browser, query persisters write it as {}, and RTK’s serialisability check warns on it in the store (spike). The other members of the result, the page facts and the replay flag included, are kept as the client returns them.
A declared error or an unexpected answer reaches the library’s error state as the client’s typed ContractError (with query: rtk, a plain value: see RTK Query): passed through from a throw-style client, built by the front runtime from a result-style client’s error result. A network failure, a timeout or an abort is the client’s rejection. Under errors: data the data is the client’s whole result, success or error, headers turned into a record as above, and only a rejection reaches the error state; it is refused by name with a throw-style client (options.errors.unsupported), whose fix is to link a result-style client or drop errors: data. Each queries.ts exports isContractError(error), which narrows a query or mutation error to the operation’s ContractError with its failure type; error instanceof ContractError would widen it to ContractError<any, any>.
Invalidation and freshness
Section titled “Invalidation and freshness”A mutation’s success invalidates keys.resource(<its resource type>) before it runs the caller’s onSuccess; an operation without a resource invalidates nothing. In TanStack Query the generated onSuccess reads the QueryClient from the mutation callbacks’ context, so the factory takes none; it returns the invalidateQueries promise, so isPending lasts until the refetch ends, as TanStack recommends. invalidate: off drops it.
Under staleTime: contract, a query’s staleTime is the operation’s cache.directives.maxAgeMs, and 0 for an operation with noStore, noCache or no directives; without it the library’s own default applies.
Paged lists
Section titled “Paged lists”The infinite query starts from the request’s own position, and its next page’s request sets the paging style’s position role (cursor, after, page or offset, in the query or a header, grammar section 14) from the page facts the client read: nextCursor; the page, or the offset plus the limit, when more is true, when the page is below totalPages, when total with the limit says records follow, or when a next link is present; or the position parameter of the next link itself. No next fact ends the list. The pages are not flattened, since the shape is the app’s; the opt-in artefact itemsOf exports itemsOf(pages).
A since style, and a style whose facts give no next position, get no infinite query: generate warns by name (paging.infinite.skipped), with the operation and the reason, and the plain query stays. infinite: off generates no infinite query at all.
Abort, timeouts and idempotency
Section titled “Abort, timeouts and idempotency”The library’s abort signal reaches the client’s CallOptions.signal, so with TanStack Query an unmounted query cancels its request. SWR passes no signal, TanStack Query none to a mutation, and RTK Query’s hooks only unsubscribe on unmount and never abort (its signal is aborted only by initiate(...).abort(), spike); the module comment of each says so.
A mutation of an idempotent operation sends one idempotency key per mutate, kept across the library’s retries, so a retried write is answered from the server’s idempotency store (replayed: true) instead of running twice. TanStack Query builds one MutationFunctionContext per mutate and passes the same object to every retry of it (5.90, spike): the front runtime draws the key on the first attempt and keeps it in a WeakMap keyed by that context, so a request-less mutate() works, two in-flight mutate calls with one variables object get two keys, and the next mutate draws a new one with nothing to drop. SWR’s useSWRMutation and an RTK queryFn mutation have no library retry, so each trigger draws a new key and an app that calls it again is a new write, not a retry. A key the caller passes in the request’s headers wins. This is the contract’s wire guarantee and has no switch. A mutation restored by a persister after a reload is a new object and draws a new key.
query: swr renders the same plan in SWR’s idiom: use<Op>(request | null, config?) over useSWR, with null for a conditional fetch; use<Op>Infinite over useSWRInfinite, its keys built by the next-page rule above; use<Op> over useSWRMutation for the other operations. <op>Options(request) returns { key, fetcher } for preload and an SWRConfig fallback. <op>InfiniteOptions(request) returns { getKey, fetcher, hasNextPage }: each page’s key holds the request it reads, and since SWR hands getKey the previous page only, the module keeps the request each page was read with by the page’s data, at module scope so every factory over one cache finds it; hasNextPage(pages) answers from the same next-page rule. A page the module did not read (a fallback or a restored cache) has no known next request and fails the infinite query with a named error. The client’s call options are the factories’ last parameter and the hooks’ config.call. Invalidation calls the bound mutate with a filter on the resource segment of the key; since SWR’s filters skip infinite keys (spike), the hooks module records the unstable_serialize key of each mounted infinite query by resource and revalidates those by key. SWR then refetches the first page and, unless the app sets revalidateAll, only the pages whose data changed; after a create with offset paging the later pages can stay shifted until the app sets revalidateAll. staleTime: contract is refused by name with query: swr (options.staleTime.unsupported): no SWR option keeps data fresh for a time, dedupingInterval only merges requests.
RTK Query
Section titled “RTK Query”query: rtk generates create<Api>Api(client, { reducerPath? }) over createApi with fakeBaseQuery, one build.query, build.infiniteQuery (RTK 2.6 and later) or build.mutation per operation, each queryFn calling the client with RTK’s signal; resource types are the tagTypes, queries providesTags and mutations invalidatesTags (none under invalidate: off), and the hooks keep RTK’s own names (useListTasksQuery). The store holds plain data only: a thrown queryFn is serialised by RTK and loses the error’s class and fields (spike), so every queryFn catches and returns its failure as { error }, a plain value. fakeBaseQuery<Failure> types one error for the whole api, so Failure is the union over the operations, discriminated by kind (contract for a declared error or an unexpected answer, rejection for a network failure, a timeout or an abort); a contract value holds the operation and the failure (headers as a record), a rejection the name and message, and toContractError, exported by each api’s api.ts (the runtime imports no client class), turns a contract value back into the typed ContractError. A mutation invalidates its tags on success only: RTK also invalidates on a rejected mutation, and every returned { error } is one, so invalidatesTags is a function of the error. The infinite query of an operation is its <op>Pages endpoint, so RTK names its hook use<Op>PagesInfiniteQuery; an operation whose base name is another’s <op>Pages is refused by name (names.collision). An endpoint whose client method requires a timeout takes { request, call } as its argument, its cache key serialised from the request alone; an optional timeout has no place in RTK’s own hooks and is not passed. RTK’s hooks unsubscribe on unmount and never abort a request, and RTK mutations over fakeBaseQuery have no library retry. staleTime: contract is refused by name with query: rtk (options.staleTime.unsupported), as with SWR: refetchOnMountOrArgChange exists only on createApi, on hook calls and on initiate, not per endpoint, and the hooks stay RTK’s own, so no place carries each operation’s maxAgeMs; keepUnusedDataFor is RTK’s own setting. With the hooks artefact off, createApi comes from @reduxjs/toolkit/query, without React.
Plain fetch
Section titled “Plain fetch”query: fetch uses no query library: the hooks are built on React’s useState and useEffect alone. queries.ts exports the operation types, isContractError and create<Api>Queries(client, settings?), which returns per safe operation <op>Options(request, call?) with a fetcher(signal), and per other operation <op>MutationOptions(call?) with a fetcher(request, attempt); it has no keys and imports no React, so a server component reads its data with it. hooks.ts, with "use client" as in the other modes, adds per safe operation use<Op>(request | null, options?) returning { data, error, isLoading, refetch }: it reads on mount, on a request change (compared by the key request, so a new object with the same members reads nothing) and on refetch, a null request sends nothing, and the effect’s cleanup aborts the read on unmount and on a request change; options.call passes the client’s call options, its signal combined with the hook’s. Per other operation use<Op>(options?) returns { mutate, isPending, error, data }; mutate(request) never rejects: each call resolves to its own { data } or { error }, and the hook’s state shows the latest call. Each mutate reads the fetcher once and draws one idempotency key, kept across the retries options.retry asks for, even when the app builds the hooks again in render (the docs still say to build them once, outside components); the retries are sent at once after a rejection that is neither a declared error nor an abort, and a retry that is not a whole count makes the hook throw, naming it. A second mutate is a new write, and a write is not aborted on unmount: the app’s write runs to its end. Errors are typed as in the other modes, and errors: data returns the whole result as data.
There is no cache, no dedupe and no invalidation across components: a write refreshes nothing, and the app calls refetch. staleTime, invalidate, infinite and keys.root need a library’s cache and are refused by name when set (options.<key>.unsupported, options.keys.root.unsupported), whose fix is to drop them or choose a library. There is no infinite hook: a paged operation gets its plain hook, whose data holds the page facts, with no warning, and the itemsOf artefact is refused (artefacts.itemsOf.unsupported). The client link’s style fetch is a different option: it names the client, query: fetch the hooks over it.
Server components
Section titled “Server components”hooks.ts, and the RTK module when it has hooks, start with "use client", so a Next.js App Router app imports them from client components; useClientDirective: off drops the directive for a toolchain that rejects it. queries.ts stays server-safe: a server component prefetches with queryClient.prefetchQuery(Tasks.listTasksOptions(request)) over a server client and passes dehydrate(queryClient) down, and the browser’s hooks read the same keys. The e2e app is a Vite single-page app; a Next.js example joins the examples after this lane.
@seampact/codegen-vue renders the same plan for Vue 3 with query: tanstack (@tanstack/vue-query 5), query: pinia-colada (@pinia/colada 1) or query: fetch; its artefact composables (default on, needing queries) holds create<Api>Composables(client, settings?), and useClientDirective is not one of its options. A composable takes its request, and with TanStack its options, as a value, a ref or a getter, and reads again when they change; it returns refs. queries.ts is shared with React through codegen-core, over Vue Query’s plain option types, with two differences: Vue Query unwraps the refs of a key type, which a readonly unknown[] key does not survive, so the module declares a local QueryKey of the segments it uses; and the infinite caller options leave out initialData, whose union the infinite overloads refuse. enabled takes a ref only on the plain query composable; the infinite one takes the options object as a ref or getter instead. With query: fetch, a query composable inside a component starts its first read in onMounted, which never runs on the server, so server rendering sends no read; outside a component it reads at once, and stops only when its effect scope is disposed, if it has one. After that dispose, refetch does nothing. The mutation composables, the retry rule and the refusals are those of React’s plain fetch hooks. With query: pinia-colada, queries.ts is the target’s own, built on the shared keys, operation types and next-page rules: its factories return Pinia Colada’s defineQueryOptions, defineInfiniteQueryOptions and defineMutationOptions, so the options’ key read back from the cache keeps its data type; the caller options are Pinia Colada’s, with key in place of queryKey and no select, which Pinia Colada has not. Pinia Colada keys are JSON values, so the key of an operation with no request ends in null. Its mutation context carries no query cache, so a mutation options factory that refreshes a resource takes the app’s QueryCache first, and its composable injects it with useQueryCache(); seampact defines no Pinia store and no state shape, the app installs Pinia and PiniaColada. Pinia Colada retries no write, so each call draws one idempotency key. Its invalidateQueries rejects when a refetch fails or is aborted, so a mutation’s refresh lets each matched query’s refetch settle on its own: the query keeps its error and a write the server accepted still succeeds. The rule that no read leaves the server holds for query: fetch; a query library’s own server reads (Pinia Colada’s onServerPrefetch, Vue Query’s prefetch and hydration) are the library’s behaviour, and hydrating or disabling them there is the app’s choice.
Angular
Section titled “Angular”@seampact/codegen-angular renders the same plan for Angular 22 with query: fetch only; another query value is refused as options.query.invalid, and state (ngrx or ngrx-signals) without the service artefact as artefact.service.needed. queries.ts is the shared plain fetch module. Its artefact service (default on, needing queries) adds http-client-fetch.ts, a fetch that sends through the app’s HttpClient (so its interceptors, XSRF settings and withFetch() apply) and answers with a Response the client reads as the platform fetch’s (it decodes text by the reply’s charset where fetch().text() always reads UTF-8): an HTTP error status is an answer, a request that reached no server a TypeError, an abort an unsubscribe. service.ts holds provide<Api>Api(config), the client config without fetch, and the <Api>Api class it provides, with no decorator so it needs no Angular compiler step: its client, its queries, and per operation a member. A query member takes its request as a function, read in a computed compared by the key request, and returns { data, error, isLoading, refetch } over Angular’s resource(), which aborts the read when its injector is destroyed and on a request change, and after that refetch does nothing; it needs an injection context or an injector option. On the server platform its request stays absent, so no read leaves the server, unless the caller passes a transferId, which reads there and hands the data to the browser through resource()’s transfer id. A mutation member, the retry rule and the refusals are those of Vue’s plain fetch composables, over signals. An operation whose member would be client or queries is refused by name (names.collision). With state: ngrx (NgRx 22, @ngrx/store and @ngrx/effects peers), ngrx.ts adds per resource of the plan one createActionGroup (<Resource>Actions, source <Api>/<resource>, so two apis in one store never share an action type; the operations with no resource in <Api>ApiActions, source <Api>), with per operation the events <op> (request, and call, required when the client method requires a timeout), <op>Success (request, data) and <op>Failure (request, error, the operation’s typed error), named in camel case so the action creators keep the operation’s name; <Api>Effects, a functional effect per operation that sends the call through the <Api>Api service’s fetchers and dispatches its success or failure, an unexpected rejection included, so no error ends the effect; and provide<Api>Effects(). Reads are grouped by their key request (groupBy, each group closed once none of its reads waits, so no group outlives its reads): the next read of the same request aborts the one still waiting, which ends in its failure with the abort error, so every action gets an outcome and a reducer’s loading flag always clears; reads of different requests of one operation run side by side, since an effect serves the whole store, not one reader. A write flattens with mergeMap, so writes run side by side and each resolves on its own, as mutate does, with the action as its attempt, which gives each action one idempotency key; a write action whose operation takes one also accepts call.idempotencyKey, sent instead, so the app retries by dispatching again with the same key and the server runs the write once. There is no platform check: an effect sends wherever the app dispatches, the server included, as the app’s own act (the reader’s server rule does not apply); an app keeps reads off the server by dispatching in the browser. No reducer, selector or state type is generated (A36). Two groups with one name, and two events with one name in a group (an operation named after another’s success or failure), are refused (names.collision). With state: ngrx-signals (NgRx Signals 22, @ngrx/signals peer), ngrx-signals.ts adds per operation a signalStoreFeature, with<Op>(), that the app composes into its own signalStore: its withState adds the slice <op>, the operation’s latest call as OperationSlice (status idle, pending, success or error, its request, and its data or typed error), and its withMethods adds send<Op>(request, call?), which sends through the <Api>Api service’s fetchers and resolves to the slice its call settled, never rejecting. Reads go through the same latestReadPerRequest and outcomeOf flows as the effects: the next read of the same request aborts the one still waiting, which resolves to its failure with the abort error, and reads of different requests run side by side, each resolving on its own while the slice follows the latest call sent. On the server platform a read sends nothing and resolves to the idle slice, as a reader stays idle there, unless the feature is given a transferId, the reader’s own opt-in: it reads there and hands each succeeded read’s data to the browser through TransferState, under the transfer id and the request key, where the first read of that request takes it, writes it to the slice and sends nothing. When the store’s injector is destroyed, every waiting read is aborted and resolves to its failure, and neither a read nor a write changes the slice after that. Writes run side by side, each its own attempt with one idempotency key, or the idempotencyKey its call options give, so the app retries by sending again with the same key and the server runs the write once; a write is not aborted on destroy, as mutate is not. No store, and no state beyond the slices, is generated (A36). An operation whose slice or method takes a name another operation’s feature adds is refused (names.collision), since the app composes the features into one store. The module imports the NgRx Signals and runtime names it uses under aliases, and declares its helpers under names that do not start with with and a capital, so an operation named state, methods or idempotencyKey generates a feature that compiles. A clash with the app’s own store members, or between the features of two apis in one store, cannot be seen at generation; NgRx Signals only warns of it in dev mode, and the page says so. The angular-fetch consumer (on hono and angular, angular on vite) compiles its components in the page with Angular’s JIT compiler, applying @Component as a call and marking each input() field as the Angular CLI’s JIT transform does, and runs tests 1 and 3 to 8 as plain fetch does, with an app interceptor recording the requests, a changed signal input reading again, and the server platform reading nothing. The angular-ngrx consumer (on hono and angular) dispatches the actions through the app’s own provideStore and reducer: a read’s success with its data, a declared error’s failure with the typed error, an unexpected error’s failure with the effect still answering, one idempotency key per write action, a key the action gives sent again on a second dispatch, two different reads of one operation both answered, and a read of the same request aborted by the next with its failure. The angular-ngrx-signals consumer (on hono and angular) composes withListTasks and withCreateTask into the app’s own signalStore beside its own state: a read’s data in its slice, a declared error typed in the slice and shown by a component, two different reads of one operation both settled and the same read twice keeping the latest, a write sent again with the key it gave replayed by the server, no read on the server platform unless the feature is given a transferId, whose data the browser’s first read then takes without sending, and a destroyed store whose waiting read is aborted while its slices stay unchanged.
@seampact/codegen-msw writes Mock Service Worker 2 handlers per api, typed through the types link as the fetch client is: mocks.ts holds createMocks({ baseUrl }), which returns per operation handler, reply(status, body, init?), error(code, params?, init?) and resolve(resolver), and handlers, every default handler with the most specific path first, for setupServer and setupWorker alike; runtime.ts is copied beside it. A handler path is the contract path with its literal text escaped for MSW, so tasks:batch matches only itself; a static segment MSW cannot escape (one holding *, or ending in : and digits) is refused by name (path.segment.literal). A handler lets through a request that a more specific route of the same method matches, so an override that use puts first never answers for a literal sibling such as /tasks/search. The handlers answer what the fetch client reads and nothing it refuses: they reuse the client’s operation plan, so a form the client refuses is refused here, and a success envelope member, page fact or error member the mock cannot fill is refused by name (mock.envelope.member, mock.page.fact, mock.error.member). The paging styles rendered are those whose facts the handler result gives, the cursor styles and their next link; a style whose facts the server computes, such as the Laravel and Spring packs’ numbered pages, stays refused by name. A reply is written as the server writes it: the payload in the format’s envelope, a page’s facts where the client reads them (a cursor in the next link, the request’s own query with the cursor replaced), and an error in the api’s error format, its code’s own members filled at generation, its params merged, with the retry header and no-store the operation derives. A default handler answers the operation’s first success example, as the handler returns it (a page example, the item array, as the page’s items); an operation with none throws MissingMockReply, which MSW answers as a 500 naming the operation and how to give it a reply, so nothing is invented. Checking the request is refused by name (options.validateRequests.unsupported). The front-msw consumer, on front alone, runs the fetch client against the handlers in a node project and in the browser project, with no server: an example answers, a missing one fails until the test replies, a page reply carries its cursor and an error override reaches the client typed.
Options, artefacts and their defaults
Section titled “Options, artefacts and their defaults”| Setting | Values | Default | What it turns off |
|---|---|---|---|
query |
tanstack, swr, rtk, fetch |
required | (no default library) |
errors |
throw, data |
throw |
errors in the library’s error state |
invalidate |
resource, off |
resource |
refresh after a write |
staleTime |
off, contract |
off |
(opt-in freshness; refused with swr and rtk) |
infinite |
on, off |
on |
infinite queries |
useClientDirective |
on, off |
on |
the "use client" line |
keys.root |
string | the api name | (renames the first key segment; refused with query: rtk) |
artefacts.hooks |
boolean | true |
the use* hooks; options factories and keys stay |
artefacts.itemsOf |
boolean | false |
(opt-in itemsOf export) |
An unknown option key or value is refused by name (options.unknown, options.<key>.invalid), as codegen-fetch does; a missing query is options.query.missing. Three shapes have no switch. The idempotency key per mutation is the contract’s wire guarantee. The other two are representation constraints set by the libraries, not features: headers as a plain record (a Headers object is not serialisable, so dehydration, persisters and the Redux store refuse it) and the RTK error as a plain value (RTK serialises a thrown error and loses its class, and its store warns on a non-plain value; the error state stays typed and toContractError restores the class).
Matrix and dependencies
Section titled “Matrix and dependencies”The compatibility matrix gains the client link with its style fetch, provided by codegen-fetch, and for codegen-react React 19, @tanstack/react-query 5 (5.90 or later: the mutation callbacks’ context carries the QueryClient), swr 2, and @reduxjs/toolkit 2 (2.6 or later, for infinite queries) with react-redux 9, each tested by its consumer; React 18 is recorded untested, a warning (ADR 0009). The library chosen by query and React are peer dependencies; the generated code imports the linked client, the front runtime copy, and that library only.
Refused by name or waiting
Section titled “Refused by name or waiting”Refused by name: a missing or mismatched client link; an operation the client leaves out; errors: data with a throw-style client; a throw-style client under any errors (link.client.errorStyle.unsupported), until the client link lists each operation’s error type; staleTime: contract with SWR or RTK; keys.root with RTK; staleTime, invalidate, infinite, keys.root and the itemsOf artefact with query: fetch; two exports with one name; a missing query and any unknown option. Warned by name: a paged operation that gets no infinite query.
Waiting, each refused or absent by name until a consumer needs it: the previous page (getPreviousPageParam); optimistic updates; If-None-Match sent by the hooks, since the browser’s HTTP cache already revalidates ETags; a retry policy read from the contract (no retry on a declared client error, Retry-After as the delay), the library’s default and the app’s retry applying meanwhile; RTK’s injectEndpoints into an app’s own api; prefetch helpers beyond the options factories; a Next.js end-to-end app.
The end-to-end proof and the consumer harness
Section titled “The end-to-end proof and the consumer harness”A consumer is the template plus a chain of overlays of any depth, each naming its bases in order (one or more, an overlay reached twice copied once), checked acyclic: react-tanstack on hono and react, react on vite, vite on front. The front overlay does not depend on a server: it adds a Vitest browser project on Playwright Chromium beside the node project, and when its base overlay provides a server entry (as hono does) its Vitest config starts that server with a top-level await, and the proxy and the provided URL follow; a consumer whose base has no server (the MSW target) gets the browser project only. The config starts the server so its URL is known when Vite reads server.proxy without relying on when global setup runs, proxies the api’s base path to it, so the client’s baseUrl is the page’s origin, and provides the server’s URL to the tests; a script loader drives the server’s control routes. The react overlay is a Vite React 19 app with its own tsconfig.json (jsx: react-jsx); each library overlay holds its app and its test/front/*.browser.test.tsx, rendered with vitest-browser-react. Every react consumer also runs seampact generate then check, a consumer tsc over test/front/typed.check.tsx (an undeclared error code and a missing required timeoutMs are @ts-expect-error), and a vite build.
The browser tests, each through the generated client and hooks against the real server: 1 a list renders; 2 an infinite cursor list loads three pages and ends, and a page style on a Laravel-pack tree; 3 a create refreshes the list, which stays stale under invalidate: off (after the test confirms the create reached the server by its own result); 4 api.rate_limited with its retryAfterMs shown for a throttled token; 5 an unmount aborts the request; 6 a dropped first answer and one retry send one idempotency key, the second answer replayed: true; 7 caller options pass through (enabled: false sends nothing, select applies, a queryKey wins); 8 errors: data. TanStack runs 1 to 8; SWR runs 1 to 4, 7 and 8 (no signal for test 5, no mutation retry for test 6); RTK runs 1 to 4, 7 and 8 (no abort on unmount for test 5, no mutation retry for test 6). Plain fetch runs 1 and 3 to 8, test 2 dropped (no infinite hook): test 3 asserts that the list stays as it was after a create and that refetch reads it again, test 5 also aborts on a request change, test 6 uses retry: 1 with the hooks built in render, and test 7 checks that a null request sends nothing, an equal request reads once and the caller’s call.signal aborts the read. Test 7 reads each library’s own options: SWR has no select and no caller key, so a null request sends nothing, a config onSuccess applies and the cache holds the generated key; RTK has no caller key, so skip sends nothing, selectFromResult applies and the store holds the endpoint’s data. With RTK, test 4 also checks that the failed write refreshes nothing. Where a consumer’s base provides the hono server, its node project still runs that overlay’s http-suite profiles unchanged: the suite is black-box HTTP and the hooks add no wire behaviour, so the rerun adds CI time and no coverage over the Hono lane, accepted for the one shared harness.
Delivery
Section titled “Delivery”- PR 1, the seam the other front lanes wait on: the
clientlink kind and descriptor,codegen-fetchproviding it, the overlay chain of any depth, thefrontoverlay and afront-fetchconsumer that drives the fetch client in a browser against Hono (a list, a create, a declared error, an abort, header page facts), its CI workflow, and the matrix entries. - PR 2: the query plan and the front runtime in codegen-core,
@seampact/codegen-reactwith its options parser and the TanStack renderer, and thereactandreact-tanstackoverlays with tests 1 to 8. - PR 3: the SWR and RTK renderers with the
react-swrandreact-rtkoverlays, split in two PRs if it grows past one deliverable.
Run on Node 24.21 with @tanstack/query-core 5.90.3, @reduxjs/toolkit 2.11.0, swr 2.5.1, React 19.3.1, Vitest 5.0.3 with @vitest/browser-playwright 5.0.3 on Playwright 1.64.1 Chromium headless shell, and vitest-browser-react 2.3.0; not kept.
| Case | Result |
|---|---|
TanStack mutation with retry: 1, first attempt failing |
the mutation function ran twice with one MutationFunctionContext; a key kept in a WeakMap by it was the same on both attempts and new on the next mutate(undefined); a WeakMap keyed by undefined variables throws |
TanStack mutation onSuccess fourth argument |
{ client, meta, mutationKey }, client the QueryClient |
TanStack fetchQuery then fetchInfiniteQuery on one key |
one cache entry, the infinite read returned the plain data with no pages |
| RTK 2.11 query hooks | unsubscribe on unmount and arg change, never abort; refetchOnMountOrArgChange only on createApi, hooks and initiate |
| TanStack infinite query over cursor facts | three pages, hasNextPage false after the last; the signal reached the query function |
TanStack invalidateQueries on [api, { resource }] |
the list key below it invalidated; a request member undefined hashed as absent |
RTK fakeBaseQuery query, then a mutation invalidating its tag |
the query refetched; queryFn received an AbortSignal |
RTK queryFn that throws |
the error stored as a serialised { name, message, stack }, logged as unhandled |
RTK data holding Headers, error holding an Error subclass |
serialisability warnings on the action and the state |
RTK build.infiniteQuery |
two pages, hasNextPage false |
Vitest browser mode, server.proxy to a Node server started in the config |
same-origin fetch('/api/...') reached the server with its path; X-Total-Count, Link and Retry-After readable; the URL provided to the test |
| the same server started by global setup, the proxy target a getter over the URL it set | passed: Vite read the target after global setup ran, an ordering Vitest does not document |
SWR filter mutate on the resource segment |
the plain list refetched; the infinite pages did not |
SWR mutate(unstable_serialize(getKey)) |
the infinite query refetched its first page |
Consequences
Section titled “Consequences”- An app keeps its own
QueryClient, SWR config or Redux store, its providers and its library defaults; the generated code adds queries, keys and an invalidation it can turn off, and every library option passes through. - Query data differs from the fetch client’s result in one place:
headersis a plain record. An RTK error is a plain value, not aContractErrorinstance; both are library constraints. - RTK Query neither aborts on unmount nor retries mutations, SWR neither aborts a query nor retries mutations, and both refuse
staleTime: contract; each limit is named in its section. - A retried mutation of an idempotent operation is not run twice by the server; no setting changes that.
- A paged list whose style gives no next position has a plain query and a warning, not an infinite query.
- The front targets share the query plan, the front runtime and the browser harness, so Vue renders the same keys, invalidation and paging, and the MSW target’s tests run in the same browser project.
- Each react consumer adds Playwright Chromium to its CI row.
Traceability
Section titled “Traceability”- FRs: FR-14 (a case refused by name), FR-15 (the typed client underneath), FR-19 (front-end clients), FR-39 (the provider seam); amendments A6, A30, A31, A36, A37, A47.
- ADRs: 0002 grammar 14 (paging roles), 0004 (the operation’s
safe,resource,paging,idempotency,cache), 0007 (names, generated modules), 0009 (links, artefacts, the matrix), 0011 (unpublished installs for test consumers), 0015 (pinned formatting), 0020 (the Hono server the e2e runs against). - Proof in the development phase: the review of this ADR, the spike above, and the end-to-end gate of each react consumer.