@tsquid/routes
v0.3.0
Published
Typed routes for React Router and Relay entrypoints: URI builders, route context, and preloading links.
Maintainers
Readme
@tsquid/routes
Runtime for routes generated by @tsquid/codegen:
typed URIs, route context, Relay entrypoint loading, preloading links.
Peers: react 19, react-router 8, react-relay / relay-runtime 21.
Config
routes.json declares the URL shape. The entrypoint module declares behaviour
(getRouteType, getPreloadProps).
{
"runtime": "@tsquid/routes",
"routes": [{
"name": "ArticleSearch",
"entryPoint": "src/components/article/Article.entrypoint.ts",
"path": "/articles/:section",
"fields": {
"section": { "kind": "string" },
"page": { "kind": "int", "optional": true },
"tags": { "kind": "string[]", "optional": true },
"drafts": { "kind": "bool", "optional": true }
}
}]
}tsquid-codegen routes.json src/routes/__generated__/routes.ts [--check]- Kinds:
string,int(safe integer),bool, and[]arrays of each. - Path fields are required scalars. Every other field is a query parameter.
- Arrays are repeated keys: order and duplicates are preserved. An empty optional array is omitted; a required array must be non-empty.
- Invalid values, duplicate scalar keys, missing required fields and bad path encoding throw. Nothing is coerced.
- An optional path segment is modelled as a separate route.
entryPointis a normalized path relative to the config, named<Name>.entrypoint.tsor<Name>.entrypoint.tsx. Basenames must be unique, including across.tsand.tsx.ssr(defaulttrue): whether servers may inline initial data for the route.query(optional): the Relay operation a server runs for the route. See Initial data.persistedQueries: Relay's persisted-queries file, relative to the config. Required when any route has aquery.
Generated API
For each route: <Route>InputType, <Route>URI and <Route>RouteContext.
For each entrypoint module: use<Name>RouteContext(). Across the catalogue: the
RouteName enum.
ArticleSearchURI.getURI({ section: 'a/b', tags: ['x', 'y'] }); // '/articles/a%2Fb?tags=x&tags=y'
ArticleSearchURI.parseURI('/articles/news?drafts=false'); // { section: 'news', drafts: false }
const route = useArticleRouteContext(); // union of the module's routes
if (route.currentRoute === RouteName.ArticleSearch) {
navigate(route.updateURI({ page: 2 })); // narrowed to ArticleSearch fields
}getURIbuilds a URL from explicit input and works from anywhere.updateURI(patch)exists only on the active route context. It merges the patch into the current location, removes keys set toundefined, and keeps query keys the route doesn't own, plus the hash. It returns a string; it doesn't navigate.use<Name>RouteContext()throws outside a provider, or when another entrypoint is active.<Route>RouteContext.useRoute()requires that exact route.- One context per catalogue, so the nearest route provider wins.
Entrypoints
export function ArticleEntryPoint({ queries }: EntryPointPropsOf<typeof ARTICLE_ENTRY_POINT>) {
return createElement(ArticlePage, { queries });
}
export const ARTICLE_ENTRY_POINT: RouteEntryPoint<ArticleEntryPointParams, Queries> = {
root: resource('ArticlePage', ArticleEntryPoint),
getPreloadProps: route => {
switch (route.currentRoute) {
case RouteName.ArticleSearch:
return { queries: { search: { parameters: ArticleSearchQuery, variables: { section: route.input.section } } } };
case RouteName.ArticleDetail:
return { queries: { detail: { parameters: ArticleDetailQuery, variables: { id: route.input.id } } } };
}
},
};
export const ARTICLE_ROUTES = {
search: defineRoute({ uri: ArticleSearchURI, context: ArticleSearchRouteContext, entryPoint: ARTICLE_ENTRY_POINT }),
};- Unused slots default to the exported
Empty(Record<string, never>):EntryPointProps<Q, N = Empty, P = Empty, E = Empty>andRouteEntryPoint<R, Q, N = Empty, P = Empty, E = Empty>. EntryPointPropsOf<typeof entrypoint>extracts the root component's props.
For derived route types, provide getRouteType and type the entrypoint with that
result instead:
getRouteType: (input, currentRoute) => ({ currentRoute, ...input, tags: new Set(input.tags ?? []) })RouteEntryPointtypes Relay'sroot+getPreloadPropscontract. Relay's ownEntryPointtypes are circular and fail under TS7; runtime behaviour is Relay's.getPreloadPropsreturnsqueries,entryPoints(nested:entryPoint+entryPointParams) and optionalextraProps. Make keys optional in their type to omit them conditionally.EntryPointProps<Queries, Nested, RuntimeProps, ExtraProps>types the root component's props. Render nested references withRouteEntryPointContainer.resource(id, Component)wraps an eager component.lazyResource(id, () => import('./Page').then(m => m.Page))shares one load between preload and navigation, exposes the loaded component synchronously, and retries after failure. Speculative query/module failures release their preload retains and subscriptions.- Without
getRouteType, params are{ currentRoute, input }. The generated<Entry>EntryPointParamsunion narrows input bycurrentRouteingetPreloadProps. getRouteType(input, currentRoute)optionally maps URL input to another type (sets, enums, dates).- Components read route state with
use<Entry>RouteContext(); avoid copying it into entrypointextraProps. useRouteRefresh()returns the active route's refresh callback. After mutations, call it to reload every query, including nested entrypoints, withnetwork-only. React transitions keep previous content visible while loading; Relay supplies fresh query fetch keys and disposes replaced references. Normal preloads retain their configured/default fetch policy.defineRoute(...).Rootis the router component; mount it underRelayEnvironmentProvider. It acceptsfallback. It loads withuseEntryPointLoaderin a layout effect (disposed on replace and unmount), so data already in the store or answered synchronously renders without the fallback. PasserrorFallback: ({ error, retry }) => ReactNodeto render route loading/rendering errors with a network-only retry. Errors reset on navigation; without a fallback they reach the router's error boundary.useRouteType()reads the derived route type.
Mutations
useMutationAction<Mutation>(artifact) from @tsquid/routes/mutation returns
[commit, isPending]. await commit({ variables, updater?, ... }) resolves with
the artifact's response type or rejects with network/GraphQL errors. Completion
callbacks are replaced by the promise; after unmount promises remain unsettled.
Store invalidation remains app code; use useRouteRefresh() after saving when
the route needs fresh server data.
Links
const AppLink = createLink([ARTICLE_ROUTES.search, ...]);- A React Router
Linkthat preloads the matching route's entrypoint graph on hover and focus. Routes are matched in order, so put static paths before dynamic ones that conflict. - Accepts
toor anchorhrefprops and forwardsref, so it can be used as Astryx'sLinkProvidercomponent.tokeeps router navigation; same-origin matchedhreflinks route and preload. Unmatchedhref, external, download, non-_selfandreloadDocumentlinks use<a>. hrefis router-relative under a basename:/profilebecomes/app/profilewhen the router basename is/app, matching generatedgetURI()paths.- Preloading uses
fetchQueryplusretain, per Relay's retention contract. It is released on failure, destination or environment change, unmount, or after 30 s. Failed speculative loads allow the next hover to retry. Duplicate requests are deduplicated by Relay. - Skipped for external links, downloads,
reloadDocument, non-_selftargets andpreventDefault. Preload errors are swallowed; navigation retries. - Requires concrete query artifacts (not preloadable IDs) and a single Relay environment. A cyclic entrypoint graph throws and releases what it acquired.
Network
createFetchGraphQL({ url, fetch?, headers?, credentials? }) from
@tsquid/routes/network posts { query, operationName, variables } and returns a
Relay observable. Persisted-only operations send { id, operationName, variables }.
Unsubscribing aborts the request; credentials default to
same-origin. headers(request) may be asynchronous (for mutation CSRF tokens).
HTTP failures and responses without data throw; partial data plus errors passes
through to Relay.
For sign-out cancellation, supply a fetch wrapper that combines init.signal
with an app-owned signal via AbortSignal.any; abort the latter on sign-out.
withInitialData(createFetchGraphQL({ url: "/graphql" }), readInitialData()) composes
network fetching with server-inlined responses.
Initial data
tsquid renders on the client. A server, in any language, can run a route's
query and inline the response, so the route renders without waiting on it. The
server side is specified in the
tsquid-server-integration skill,
with conformance vectors.
{ "persistedQueries": "persisted_queries.json",
"routes": [{ "name": "Posts", "path": "/posts", "query": "PostsQuery", "fields": {
"order_by": { "kind": "string", "optional": true }, "preview_id": { "kind": "int", "optional": true } } }] }- A route query's variables come from the URL only: each binds the field with the
same name, or falls back to its GraphQL default, or
null. The generator rejects queries that need anything else: required variables without a field, incompatible kinds,@defer/@stream, provided variables, non-queries. - The entrypoint must preload that query with those variables:
variables: PostsRouteQuery.getVariables(route.input). The generated helper includes every declared variable, defaults and nulls;operationnames the query. - URL string fields bound to GraphQL enums remain strings. Narrow them in
getRouteTypebefore callinggetVariablesto satisfy Relay's enum variable type; the generic helper preserves that narrower input type. The generator has no schema enum catalogue and cannot validate or narrow enum values itself. - Everything else the route loads (other queries, nested entrypoints, conditional
logic) is fetched by the client and suspends where it's read. Read it below its
own
<Suspense>so the rest renders first. Keep the defaultfetchPolicy(store-or-network):store-and-networkrefetches inlined data. tsquid-codegenwrites<output>.manifest.json: every route's path, fields,ssrflag and resolved query (operation, id, text, variables with bindings and defaults).
Relay must persist operations to the file the config names, keeping query text:
"persistConfig": { "file": "./persisted_queries.json", "include_query_text": true }Relay only appends to that file, and skips unchanged operations. Regenerate it
from empty, tsquid-relay persist,
before tsquid-codegen. The generator rejects an operation name with more than
one version. tsquid-relay check verifies persisted ids against compiled artifacts.
The server inlines { version: 1, responses: [{ operation, variables, response }] }
as <script type="application/json" id="tsquid-data">.
import { readInitialData, withInitialData } from '@tsquid/routes/initialData';
new Environment({
network: Network.create(withInitialData(fetchGraphQL, readInitialData(), { warn: import.meta.env.DEV })),
store: new Store(new RecordSource()),
});readInitialData(id = 'tsquid-data')returnsnullwhen the element is absent, and throws when it is malformed.withInitialDataanswers each request matching an entry (operation name + variables) once, synchronously. Anything else goes tofetchFn, as do requests after the first request's task, when unused entries expire. Duplicate entries throw.- Variables match as Relay sends them: declared only, defaults included, top-level
nulls ignored, object key order ignored. warnlogs boot misses and expired entries, with the exact operation and variables, so drift between the server and the entrypoint shows.
