@uniflowed/router
type
PageProps
export type PageProps<
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
TData = void,
> = {|
readonly params: TParams,
readonly searchParams: { readonly [string]: string },
readonly data: TData,
|};
Props a page receives.
type
ErrorProps
export type ErrorProps = {|
readonly error: RouteError,
readonly reset: () => void,
|};
Props an $error.js component receives.
type
LayoutProps
export type LayoutProps<
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
> = {|
readonly params: TParams,
readonly children: React.Node,
|};
Props a layout receives.
A layout on a segment that declares parallel-route slots receives one more prop per slot, named after the directory without its @, and this exact type does not describe those — the names are the project's. Declare them: a layout beside @team and @analytics is
component Dashboard(children: React.Node, team: React.Node, analytics: React.Node)
and the router passes null for a slot the URL addressed by neither a route of its own nor a $default.js, so {team ?? <Empty />} is a thing that can be written and relied on.
type
NavigateOptions
export type NavigateOptions = {|
readonly replace?: boolean,
readonly scroll?: boolean,
/**
* Whether this navigation may animate. Defaults to `true`, which is what
* every navigation does.
*
* `false` is how a caller says this one is a change of state rather than a
* change of place — a tab within a page, a filter written into the query
* string — and should be a cut. `true` does not *force* one: a browser
* without `startViewTransition` and a reader who asked for less motion still
* get the cut, because an application able to override the second would
* eventually override it.
*/
readonly transition?: boolean,
|};
How a navigation is performed.
type
Router
export type Router = {|
readonly push: (to: string, options?: NavigateOptions) => Promise<void>,
readonly replace: (to: string) => Promise<void>,
readonly prefetch: (to: string) => Promise<void>,
readonly refresh: () => Promise<void>,
readonly back: () => void,
readonly forward: () => void,
|};
What useRouter() returns.
type
RouteInfo
export type RouteInfo = {|
readonly path: string,
readonly pathname: string,
readonly params: RouteParams,
readonly searchParams: SearchParams,
readonly data: mixed,
readonly pending: boolean,
|};
What useRoute() returns.
type
AppProps
export type AppProps = {|
readonly url: string,
readonly initial?: ResolvedRoute,
readonly flight?: Promise<FlightRoot>,
|};
Props the app root receives from the client and server entries.
One of flight and initial. A document React Server Components rendered hands the root its payload, on the server and again in the browser, so both sides render the same tree from the same bytes. A single-page application — and a project that turned app.rsc off — hands it a route resolved from its modules instead. See ubugeeei-prod/uf#519.
component
RouterProvider
export component RouterProvider(
url: string,
initial?: ResolvedRoute,
flight?: Promise<FlightRoot>,
children: React.Node,
) { ... }
Provides the current route to the tree and performs navigation.
On the server the route is fixed for the request. In the browser the provider listens to history and to Link clicks; a navigation fetches the next route's payload — or, for a route resolved from its modules, loads its chunks and runs its loader — *before* committing, inside a transition, so the previous page stays interactive meanwhile.
Which of the two it does is decided by what it was started with: a Flight payload is [FlightRouter], and a resolved route is [ModuleRouter].
# Unless the application asked the browser to do it
Under app.rendering.navigation: "document" every one of those sentences stops being true, and the provider is still here: the tree below it still reads useRoute, still renders <RouteView>, and still hydrates whatever "use client" boundary made the document interactive. What it does not do is take the link over. navigate hands the URL to the browser, no popstate listener is installed, and prefetch — which exists to load the chunks of a route this page will render — has no page to load them for.
That is one branch rather than a second provider because the two differ in what happens on a click and in nothing else. A second implementation would have had to keep resolved, pending, the context and every hook that reads it in step with this one, which is four things to keep in step for one that actually differs.
hook
useRoute
export hook useRoute(): RouteInfo { ... }
The current route.
hook
useRouter
export hook useRouter(): Router { ... }
Navigation.
hook
useLoaderData
export hook useLoaderData(): mixed { ... }
The current page's loader data.
mixed, so the page that reads it says what it is and the checker watches it do so. This was useLoaderData<T>(): T, which looks like inference and is a cast a caller writes at a distance: useLoaderData<Post>() asserted that a loader three files away returned a Post and nothing anywhere checked it, so a loader that changed shape produced a Post-shaped undefined at the first property read rather than an error where the shape was decided.
Narrowing is a line at the top of the page — if (typeof data !== "object" || data == null) { … }, or the page's own validator schema, which is what @uniflowed/validator is for at exactly this boundary.
The type that would need no narrowing is a *generated* one: the route table already produces RoutePath and RouteParams from the app/ directory (crates/uf_router/src/lib.rs), and a loader's return type belongs in the same file, keyed by route. Until it is there, this says what is true.
component
RouteView
export component RouteView() { ... }
Renders the matched page inside its layouts, innermost last, with the document metadata as hoistable head elements.
The walk itself is composeRoute in ./compose.js, which has the whole argument for where each boundary goes. What is left here is the half that reads the router: which route, the page element that carries the loader's answer and the payload the browser hydrates it from, and — under uf dev — the boundary marks and the report that watches them. See ubugeeei-prod/uf#520.
hook
useSeo
export hook useSeo(seo: Metadata): React.Node { ... }
Head elements a component contributes while it is rendering.
metadata and generateMetadata are how a *route* says what it is, and both are resolved before anything renders — which is what makes them work for a crawler that runs no JavaScript. They are also declarations by the route module, and part of what a page has to say is decided further in: a paginated list knows its prev and next in the component that draws the pager, and a breadcrumb knows the trail it has just walked.
So this returns elements rather than writing to the head. Writing would have to happen in an effect, an effect does not run on a server, and the result would be a page whose tags are right in a browser and missing from the crawler — packages/web/head.js is that escape hatch and says so at the top of the file. Rendering is what puts a tag in a server-rendered head, so the caller renders what comes back:
export component Pager(page: number, of: number) { const seo = useSeo({ pagination: { prev: page > 1 ? /posts?page=${page - 1} : undefined, next: page < of ? /posts?page=${page + 1} : undefined, }, }); return <nav className="pager">{seo}…</nav>; }
The argument is a Metadata — the same type a route exports — because there is one vocabulary for what a page says about itself, and a second one would be a second place for it to be wrong. What this adds over rendering the tags by hand is the thing a component three levels down cannot know: metadataBase, which the root layout declared, and against which the relative URLs written here are resolved.
type
LinkPrefetch
export type LinkPrefetch = "off" | "intent" | "render";
When a Link loads the route it points at.
hook
useLinkStatus
export hook useLinkStatus(): {| readonly pending: boolean |} { ... }
Whether the containing Link is waiting for the navigation it started.
component
Link
export component Link(
to: string,
prefetch?: LinkPrefetch = "intent",
replace?: boolean = false,
transition?: boolean = true,
children?: React.Node,
className?: string,
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
...rest: { readonly [string]: mixed }
) { ... }
A client-side navigation.
Renders a real anchor, so the link works before hydration and for a right click, and takes over only a plain left click. prefetch="intent" (the default) loads the destination's chunks on hover or focus, and transition={false} makes this one navigation a cut — most navigations are a link, so the opt-out in [NavigateOptions] has to be reachable from one.
# Under app.rendering.navigation: "document" it is only the anchor
No click handler of uf's, no preventDefault, no prefetch listeners: the element the browser gets is the one it would have got from <a href> in the source. That is the whole of what changing the mode does to a component, which is the point — a project moving between the two rewrites its uf.config.js and none of its pages, and a component library built on Link works in both without knowing which it is in.
It matters that the handler is *absent* rather than a handler that calls location.assign. The two look the same for a left click and are not the same link: preventDefault and a scripted navigation lose download, lose a target, and change what the browser does with a middle click and with a gesture uf has not heard of. An ordinary link is not an approximation of an ordinary link.
function
routerView
export function routerView(root: string): React.ComponentType<AppProps> { ... }
The application root app.js exports: export default routerView("./app").
The argument documents where the routes live; the table itself is generated from that directory at build time and installed by the entry that starts the app, so the component only has to render it.
# Why the render anchor is here
RenderProvider fixes the render's instant, time zone and random seed once, writes them into the markup and reads them back on the client, which is what makes useRenderedAt and useRandom agree across hydration. An application that did not render one got no error — it got the old behaviour, which is a silent hydration mismatch in every page with a clock or a shuffle on it. A guarantee that depends on remembering to opt in is not one, so the router provides it and an application that wants different values *replaces* it by rendering its own inside this one. See ubugeeei-prod/uf#559.
Above RouterProvider rather than below it, because the route's own modules — layouts as much as pages — are things that read a clock, and a masthead showing the time is the first component anybody writes that does.
It is safe above a root layout that renders <html> only because the envelope's carrier is a <meta>: React hoists one into the head of a document it rendered, and to the front of a tree that is not one, where uf's shell lifts it into the head it wrote itself. packages/hooks/render.js has the argument, and it is the reason the carrier is no longer a <script>.
hook
useIsServer
export hook useIsServer(): boolean { ... }
Whether the app is being rendered on the server.
Read through useSyncExternalStore so a component that branches on it hydrates consistently: the server snapshot is true, the client one false.
Without a doc comment
@uniflowed/router/action
type
ServerActionFunction
export type ServerActionFunction = (...args: Array<ActionArgument>) => Promise<ActionValue | void>;
A function that can be a server action.
Both halves of the signature are the wire grammar, and they are not the same half: an action is called with values that can cross *or* the one form a submit produces, and answers with a value that can cross. void is a result and not an argument, because JSON has no undefined and an action declared to take one would be taking something the caller cannot send; a FormData is an argument and not a result, because a form is something a browser submits and not something a server answers with.
type
ActionArguments
export type ActionArguments<TArgs extends $ReadOnlyArray<ActionArgument>> = TArgs;
An action's arguments, held against what a wire can carry.
A bound on a tuple rather than a bound on the function, and the difference is the whole reason this is two types instead of one. A function type puts its parameters in a contravariant position: F extends (…args: Array<ActionValue>) => … asks whether F accepts *every* ActionValue, which createUser(name: string) does not and should not. Parameters<F> is the same list read covariantly, where the question is the one worth asking — is each argument something that can cross?
uf prepare writes one instantiation over the whole project:
export type ServerActionArgsFitTheWire = ActionArguments<ServerActionArgs<ServerActionName>>;
ServerActionName is every action's name, so ServerActionArgs of it is every action's argument list, and one line checks all of them.
The bound is [ActionArgument] and not [ActionValue], which is the whole of what makes <form action={fn}> type-check: a form action's parameter is a FormData, and a FormData crosses as an argument and only as one.
# What this does not catch, and why
The bound is element-wise, so it holds each argument against the grammar and says nothing about the list as a whole. The wire has one rule that is about the list: a call carries at most one form, because the envelope names the form's position once. So (a: FormData, b: FormData) type-checks here and throws ActionValueError at encodeActionArguments — a rule enforced at run time that the types ought to have caught.
Saying it in the type needs a walk over the tuple, and the walk is blocked by ubugeeei-prod/uf#300: a spread in a conditional type's tuple pattern binds infer as unknown and the rest as the whole array widened. The obvious recursion is not merely rejected, it quietly answers wrongly —
type NoForm<T> = T extends [] ? true : T extends [infer H, ...infer R] ? (H extends FormData ? false : NoForm<R>) : true;
— gives true for [string, FormData], because the spread pattern never matches and every tuple falls through to the last branch. A constraint built on that would be worse than none: it would report every signature as fine.
tests/type-tests/server-actions.js pins the gap, so that whoever fixes #300 is told this is waiting on it. The run-time guard and its test are in internal/action-wire.js and tests/library/server-actions.test.js.
type
ActionResult
export type ActionResult<TResult extends Promise<ActionValue | void>> = TResult;
An action's result, held against the same grammar.
A promise, because a server action is always async — the RSC graph rejects one that is not — and void is allowed because an action that returns nothing is ordinary. Promise is covariant in Flow, so the bound reaches the resolved type without any of the contortion the arguments needed.
class
ServerActionError
export class ServerActionError extends Error { ... }
A server action that did not answer.
Carries the status and the action's build-time name and nothing else, because nothing else came back: the endpoint answers every refusal with a fixed body, so there is no message from the server to relay. What went wrong is in the server's log, which is where an application's internals belong.
function
createServerReference
export function createServerReference(id: string, name: string): ServerActionFunction { ... }
The reference the client bundle holds in place of one server action.
Generated, never written by hand: @uniflowed/vite emits one call per callable export of a "use server" module, with the id crates/uf_rsc/src/action.rs derived for it and the module#export name that only ever appears in an error.
The returned function is async and refuses before it sends: an argument outside the wire grammar throws an ActionValueError naming the argument's position, at the call site, rather than becoming a 400 with nothing in it.
It carries $$FORM_ACTION, so a form bound to it is a real form before the page hydrates; see the header and ./internal/form-action.js.
function
registerServerAction
export function registerServerAction<T>(fn: T, id: string): T { ... }
Give a server action, on the server, what its reference has in the browser.
@uniflowed/vite calls this on every callable export of a "use server" module in the server graphs, with the id the build derived for it, so that a client component rendered to HTML writes a form that posts without JavaScript. It changes nothing about calling the function: the properties it defines are ones only React reads, and bind still binds. Anything that is not a function is returned untouched, because the RSC graph has already refused a "use server" export that is not one and this is not the place to say it twice.
variable
export const ACTION_HEADER: string = "uf-action";
The request header carrying the action id.
variable
ACTION_CONTENT_TYPE
export const ACTION_CONTENT_TYPE: string = "application/json";
The only content type an action call may be sent with.
Not decoration. application/json is not one of the three types a form can produce, so a cross-origin <form> — which is sent with the visitor's cookies and no preflight — cannot reach the decoder at all. It is the second of the three independent things standing between this endpoint and a CSRF, the others being the Origin check and ACTION_HEADER itself, which is a header no simple request may carry.
variable
MAX_ACTION_BODY_BYTES
export const MAX_ACTION_BODY_BYTES: number = 1024 * 1024;
Largest request body the endpoint will read, in bytes.
variable
MAX_ACTION_ARGUMENTS
export const MAX_ACTION_ARGUMENTS: number = 16;
Most positional arguments an action may be called with.
variable
MAX_ACTION_DEPTH
export const MAX_ACTION_DEPTH: number = 24;
Deepest nesting a payload may have.
variable
MAX_ACTION_VALUES
export const MAX_ACTION_VALUES: number = 10000;
Most values, of any kind, one payload may hold.
variable
export const MAX_FORM_ENTRIES: number = 256;
Most fields one submitted form may carry.
A ceiling on the *count* rather than on the bytes, because the bytes already have one: the whole body is bounded by MAX_ACTION_BODY_BYTES before a character of it is parsed. What this bounds is the number of append calls a sender can make the decoder do, and the size of the multimap they build. A form with more than 256 controls is a form that wants a different shape.
variable
export const MAX_FORM_NAME_LENGTH: number = 128;
Longest field name one form entry may have.
A name is an HTML name attribute — email, items[3][quantity] — so this is generous by two orders of magnitude for anything a document declares, and it stops a body's whole byte budget being spent on one key.
type
ActionValue
export type ActionValue =
| null
| boolean
| number
| string
| $ReadOnlyArray<ActionValue>
| { readonly [string]: ActionValue };
Everything that may cross the wire.
Recursive on purpose, and closed on purpose: this type is what ServerActionBoundary in ../action.js holds every action's parameters and return value against, so an action that takes a Map is a uf check error rather than a request that arrives with an empty object in it.
type
ActionArgument
export type ActionArgument = ActionValue | FormData;
Everything an *argument* may be: a value, or the one form.
Wider than [ActionValue] in exactly one place and deliberately not recursive: a FormData is something a call passes, never something inside something a call passes. ActionArguments in ../action.js holds every action's parameter list against this, and ActionResult still holds every return type against ActionValue — an action receives a form and does not answer with one.
class
ActionValueError
export class ActionValueError extends Error { ... }
A value that is outside the grammar, and where in the payload it was.
Thrown on the browser side, where the path is the argument the caller passed. On the server side it is caught and becomes a 400 with none of this in it: the sender does not get told which part of what they sent was the part that was refused.
@uniflowed/router/rsc
type
FlightOptions
export type FlightOptions = {|
/**
* Whether a loader may be left running into a `$loading.js` boundary.
*
* On by default: a payload streams, so a slow loader is a fallback now and
* its answer later. A prerender turns it off, because a file has no "later".
*/
readonly defer?: boolean,
/**
* Every exception the render recovered from, including the late ones.
*
* What it returns becomes the exception's digest. React writes the digest
* into the row that stands in for the part of the tree that failed, and sets
* it on the error its Flight client rebuilds from that row — which is how
* `./server.js` recognises, in the HTML renderer, the copy of an exception
* this renderer has already reported.
*/
readonly onError?: (error: mixed) => ?string,
/**
* Render the error boundary for this exception instead of resolving the URL.
*
* What the HTML renderer asks for when the shell it was streaming from the
* payload threw before its first byte: the route resolved, and rendering it
* did not.
*/
readonly failure?: {| readonly error: mixed |},
/**
* The page a browser was showing when it asked for this payload.
*
* A document request never sets it. A client navigation may, because only the
* browser knows the page it is navigating from, while only this renderer can
* render the intercepted tree.
*/
readonly interceptedFrom?: string,
/** Stops the render, for a reader that went away. */
readonly signal?: AbortSignal,
|};
What a host may tell the renderer about one render.
type
FlightRender
export type FlightRender =
| {| readonly kind: "redirect", readonly status: 307 | 308, readonly location: string |}
| {|
readonly kind: "route",
readonly status: 200 | 401 | 403 | 404 | 500,
/** The payload, as React writes it. Read exactly once. */
readonly stream: ReadableStream<Uint8Array>,
/**
* The exception this route resolved to its error boundary for, when it did.
*
* The same field `./server.js` has always carried as `error`: `uf build`
* fails a route that set it and `uf dev` reports it. `forbidden()` and
* `unauthorized()` do not set it.
*/
readonly failure: mixed,
|};
A render: a redirect to answer with, or a route and its payload.
type
FlightRenderer
export type FlightRenderer = (url: string, options?: FlightOptions) => Promise<FlightRender>;
Render one URL.
function
createFlightRenderer
export function createFlightRenderer(options: {|
readonly routes: RouteTable["routes"],
readonly notFound: RouteTable["notFound"],
readonly errors: RouteTable["errors"],
/**
* The build's deployment id, written into every payload's root so that a
* page on another build can tell — including from a payload that was
* prerendered into a file. `null` under `uf dev`. See
* `./internal/deployment.js`.
*/
readonly deployment?: string | null,
|}): FlightRenderer { ... }
The renderer for one route table.
The table is the server's whole one — every page, layout and boundary module — and it is virtual:uf/routes as this graph generates it.
type
RouteState
export type RouteState = {|
readonly pathname: string,
readonly search: string,
readonly path: string,
readonly params: RouteParams,
readonly searchParams: SearchParams,
/** What the loader returned, once it has. `undefined` while `deferred` is set. */
readonly data: mixed,
/** The loader still running, as a promise the browser can `use`. */
readonly deferred: ?Promise<mixed>,
readonly metadata: Metadata,
readonly viewTransition: ?string,
readonly status: 200 | 401 | 403 | 404 | 500,
readonly error: ?RouteError,
readonly interception?: ?FlightInterception,
|};
What a route resolved to, as the browser holds it.
Everything a hook reads, and nothing that is a module. A resolved route carries the page, the layouts and the boundaries it loaded; none of those crosses, because what they rendered is already in the payload beside this value, and a component the browser does need crosses inside that tree as a client reference. So useRoute() and useLoaderData() read this, and RouteView renders the tree.
data and deferred cross as Flight values. That is a narrower contract than the JSON the loader's answer used to be embedded as: Flight carries a Date, a Map, a Set and a promise, and it refuses a class instance by name rather than turning it into {} the way JSON.stringify did. A thrown error in error crosses the way React sends any error value — with its message in development and replaced by React's own sentence in a build.
type
FlightRoot
export type FlightRoot = {|
readonly route: RouteState,
readonly tree: Node,
/**
* The build that rendered it, when the build has an id.
*
* In the payload rather than only in a response header, because the payload
* of a prerendered route is a file and a host that serves files runs nothing
* that could set one. A page on another build loads the document instead of
* rendering this; see `./deployment.js`.
*/
readonly deployment?: string | null,
|};
Row 0 of a route's payload: the route, and the tree it rendered.
function
installRouting
export function installRouting(settings: RoutingSettings): void { ... }
Say where this application is served and how its paths are spelled. Called once, by the entry that starts it.
@uniflowed/router/server
type
RenderAssets
export type RenderAssets = {|
readonly scripts: $ReadOnlyArray<string>,
readonly styles: $ReadOnlyArray<string>,
readonly preloads: $ReadOnlyArray<string>,
/**
* The build the document belongs to, written into its head as
* `<meta name="uf:deployment">`. Absent under `uf dev`, where there is no
* other build to be skewed against. See `./internal/deployment.js`.
*/
readonly deployment?: string,
|};
Asset URLs to reference from the document.
type
RenderResult
export type RenderResult = {|
readonly status: number,
readonly headers?: { readonly [string]: string },
/** Write the document into a Node response. */
readonly pipe: (destination: WritableLike) => Promise<void>,
/** The document as a web stream, for `new Response(…)`. */
readonly stream: () => ReadableStream,
/** The whole document, once it has finished streaming. */
readonly text: () => Promise<string>,
/**
* The exception this render fell back to its error boundary for.
*
* The document is still a document — the boundary rendered — and this is how
* the caller learns that it is an error page rather than the page it asked
* for. `uf build` fails the route it names; `uf dev` reports it in the
* terminal. Without it, containment would mean a build that quietly wrote a
* directory of error pages and exited 0.
*
* `forbidden()` and `unauthorized()` do not set it: those are answers an
* application chose, and a build that prerendered one has not failed.
*
* Only the failures known before the first byte: a loader that threw, or a
* shell that did. An exception inside a `<Suspense>` boundary happens after
* this has been read, so it is reported through `render`'s `onError` instead
* — a streaming renderer cannot put a late failure in a value the caller
* already has.
*/
readonly error?: mixed,
|};
A document that has begun.
status and headers are known once the shell is ready, which is the moment this resolves and is why a streaming renderer can still answer with a status line. The body arrives afterwards, through exactly one of pipe, stream and text — they are three views of one pass over the same chunks, not three copies of the document.
type
PrerenderResult
export type PrerenderResult = {|
readonly status: number,
readonly html: string,
readonly headers?: { readonly [string]: string },
/** The exception this render fell back to its error boundary for; see [`RenderResult`]. */
readonly error?: mixed,
/**
* The Flight payload the document was rendered from, for a document React
* Server Components rendered.
*
* `uf build` writes it beside the document as the route's payload file, so a
* browser that navigates to a prerendered route fetches a file rather than
* asking a server — which is what makes a static host able to serve client
* navigation at all. Absent for a document rendered from its modules.
*/
readonly payload?: Uint8Array,
/**
* The page's static shell, when the prerender was partial and the page read
* the request inside a `<Suspense>` boundary.
*
* `html` is then the shell's markup and not a document to write at the
* page's URL: a server answers the page by sending the shell and rendering
* the holes per request, through [`Renderer`]'s `resume`. No payload comes
* with it, because the browser hydrates from the request's. Only a renderer
* for React Server Components writes one.
*/
readonly shell?: PrerenderedShell,
|};
A document that is finished: every boundary resolved, nothing left to wait for.
type
FlightResponse
export type FlightResponse = {|
readonly status: number,
readonly headers: { readonly [string]: string },
readonly stream: ReadableStream<Uint8Array> | null,
/** The exception the route resolved to its error boundary for; see [`RenderResult`]. */
readonly error?: mixed,
|};
A route's payload, as a browser navigating to it is answered.
stream is null for a redirect, whose location is already the target's payload URL when the target is on this origin: fetch follows it and lands on a payload.
type
RenderOptions
export type RenderOptions = {|
/**
* Every exception React recovered from, including the ones it answered by
* streaming a boundary's fallback after the response had begun.
*
* A callback rather than a field on the result, because that is the shape of
* the truth: by the time one of these happens the caller is already writing
* bytes. `uf dev` reports them in the terminal; a production host logs them.
*/
readonly onError?: (error: mixed) => void,
/**
* Rewrite the document opening — the head and, when present, the body start
* tag — before it goes out.
*
* For `uf dev` and nothing else. Vite's `transformIndexHtml` injects
* `/@vite/client` and the refresh preamble and rewrites asset URLs, and it
* is a *whole document* hook, so the development server used to collect the
* page and transform it at the end. That made the one place a developer
* would notice streaming the one place it did not happen: a slow page showed
* nothing until it was finished, and `$loading.js` looked broken.
* See ubugeeei-prod/uf#374.
*
* A production host passes nothing here and streams as it always did.
*
* # What a plugin that injects into the body gets
*
* `transformIndexHtml` is a whole-document hook and this hands it only the
* parseable opening of the document. Measured against Vite 8.2.2, injecting
* all four positions into a whole document and into the streamed opening:
*
* | `injectTo` | whole document | streamed |
* | -------------- | ------------------- | -------- |
* | `head-prepend` | after `<head>` | same |
* | `head` | before `</head>` | same |
* | `body-prepend` | after `<body>` | same |
* | `body` | before `</body>` | after `<body>` |
*
* Nothing is dropped — every tag still reaches the document — but a `body`
* tag lands at the top of the body rather than after the content, because
* the content is deliberately not passed to the hook. That keeps Vite's
* parser away from chunk boundaries that may sit inside an attribute.
*
* uf's own injections are `head` and `head-prepend`, and Vite's client is
* head-injected, so this is about a third-party plugin.
* `packages/vite/dev-head-transform.test.js` pins the table above, so the day
* it changes is a failing test rather than a surprise.
*/
readonly transformHead?: (html: string) => Promise<string>,
/**
* React's `formState` for a page rendered in answer to a form posted before
* hydration; see `internal/form-action.js`. Only a host's `postback` passes
* one.
*/
readonly formState?: FormState,
/**
* Told, in words, when a document streamed differently than it did last time.
*
* For `uf dev` and nothing else, like `transformHead` above. It answers the
* half of ubugeeei-prod/uf#520 that is about the wire — what arrived, in what
* order, and which part of the tree each chunk built — for the stream uf has
* today, which is a document whose Suspense boundaries resolve independently.
* `internal/inspector.js` is what it is and what it deliberately is not.
*
* A host that passes nothing here records nothing: no recorder is
* constructed, and the chunks a production stream yields are untouched.
*
* It is handed a message and its detail lines rather than the record they
* came from, because the caller is `@uniflowed/vite` — plain JavaScript, run
* by Vite before any Flow transform exists, which is why `DEVTOOLS_HOOK` and
* `DIAGNOSTIC_ENDPOINT` are spelled twice rather than imported. The
* vocabulary of the report belongs on this side of that line.
*/
readonly onStream?: (diagnostic: StreamDiagnostic) => void,
/**
* For `prerender` only: whether a read of the request may be left for the
* request rather than fail the page.
*
* `uf build` passes it when `app.rendering.modes` allows `ppr` and the build
* leaves a server behind. A page that reads `cookies()`, `headers()` or
* `draftMode()` inside a `<Suspense>` boundary is then written as a static
* shell with that boundary as a hole — [`PrerenderResult`]'s `shell` — and a
* read outside every boundary still fails the page, naming what it read.
* The renderer for React Server Components honours it; a route rendered from
* its modules (`app.rsc: false`) is prerendered whole or not at all.
*/
readonly partial?: boolean,
|};
What a host may tell the renderer about one request.
type
Renderer
export type Renderer = {|
readonly render: (
url: string,
assets: RenderAssets,
options?: RenderOptions,
) => Promise<RenderResult>,
readonly prerender: (
url: string,
assets: RenderAssets,
options?: RenderOptions,
) => Promise<PrerenderResult>,
/**
* A route's payload, for a browser that is navigating rather than loading a
* document. Only a renderer for React Server Components has one.
*/
readonly flight?: (
url: string,
options?: {|
readonly onError?: (error: mixed) => void,
readonly interceptedFrom?: string,
|},
) => Promise<FlightResponse>,
/**
* A page `prerender` wrote as a static shell: the shell first, then its holes
* as this request renders them. Only a renderer for React Server Components
* has one, because only it writes a shell.
*/
readonly resume?: (
url: string,
assets: RenderAssets,
shell: PrerenderedShell,
options?: RenderOptions,
) => Promise<RenderResult>,
|};
The two ways one app answers for a URL.
function
shellDocument
export function shellDocument(assets: RenderAssets): string { ... }
The document a single-page build writes, and the only one it writes.
app.rendering.modes: ["csr"] renders no route at build time: the client router resolves and renders every one of them in the browser, so what the build has to leave behind is the *chrome* — the stylesheets, the module script, and the empty root the client renders into. That is exactly [shellFor]'s three strings with nothing between them, which is why this is three concatenations rather than a fourth shape of document to keep in step with the other three.
No React runs. There is nothing to render: no URL has been asked for, and whatever this document is served for is decided by the host rather than by this build.
# What it costs, said here because it is not visible from the file
The document has no <title>, no <meta name="description"> and no content. A crawler that runs no JavaScript sees an empty page for **every** URL, and a reader sees nothing until the bundle has loaded and the route has resolved. That is what a single-page application is, and it is why modes: ["csr"] is a declaration a project makes rather than something a build falls back to. A project that wants a document per route has ssg, and one that wants a document per request has ssr.
type
PrerenderedShell
export type PrerenderedShell = {|
/**
* The document as far as a server sends it before it renders anything: the
* head, uf's tags in it, and every byte React finished. It stops where the
* request's part begins, which is before the closing tags.
*/
readonly html: string,
/**
* What follows the request's part: the closing tags of a document uf wraps
* around the application. Empty for a document the application renders
* itself, because React's `resume` writes `</body></html>` for that one.
*/
readonly close: string,
/** See [`Layout`]'s field of the same name. */
readonly rootDepth: number,
/**
* React's postponed state: which boundaries are holes and how to find them
* again. Plain JSON, which is what lets a build write it to a file and a
* server read it back. `null` when the prerender left nothing for the
* request, and `html` is then a whole document.
*/
readonly postponed: mixed,
|};
A page's static shell, and what React needs to finish it per request.
What uf build writes for a page it prerenders partially (ppr): the whole document React could render without a request, with each <Suspense> boundary that waited on one written as its fallback, and React's record of where those holes are.
variable
ROOT_ID
export const ROOT_ID = "uf-root";
The element the client hydrates when the app does not render <html>.
An app whose root layout renders the whole document owns it and hydrates document instead; this is the wrapper for the ones that render content.
variable
DATA_ID
export const DATA_ID = "__uf_data";
The script element the server's resolved route data is written into.
type
TrailingSlash
export type TrailingSlash = "never" | "always" | "ignore";
Which spelling of a path is the page; see app.router.trailingSlash.
type
RoutingSettings
export type RoutingSettings = {|
readonly basePath?: string,
readonly trailingSlash?: TrailingSlash,
|};
What an entry installs.
function
installRouting
export function installRouting(settings: RoutingSettings): void { ... }
Say where this application is served and how its paths are spelled. Called once, by the entry that starts it.
function
basePath
export function basePath(): string { ... }
The path this application is served under: "" at the root, "/docs" otherwise.
For code that builds an absolute address the router did not build for it — a middleware's Response.redirect(new URL(${basePath()}/sign-in, request.url)) — because a route handler and a middleware are handed the application path, and an address built from /sign-in alone leaves the base behind.
type
ActionModule
export type ActionModule = { readonly [name: string]: mixed };
A module holding server actions, as the generated table loads it.
type
ActionRecord
export type ActionRecord = {|
/** The keyed id, 64 lowercase hexadecimal characters. */
readonly id: string,
/** Declaring module, relative to the project root. */
readonly module: string,
/** The exported binding, or `default`. */
readonly export: string,
/** Import the declaring module. */
readonly load: () => Promise<ActionModule>,
|};
One callable action, as virtual:uf/actions writes it.
module and export are here for the error a broken build produces, never for dispatch: dispatch is id against id. Nothing a request carries is ever joined onto a path or used to name an export.
function
createActionDispatcher
export function createActionDispatcher(options: {|
readonly actions: $ReadOnlyArray<ActionRecord>,
|}): (request: Request, settings?: ActionDispatchOptions) => Promise<Response | null> { ... }
Match a request against the action table and run what it names.
Returns null when the request carries no action id, which is the caller's signal to carry on: everything that is not an action call is a page, a handler or a 404, and this declines all of them. Every other outcome — including every refusal — is a Response, because a request that named an action and did not get to call one must not fall through to something else that might answer it.
Without a doc comment
createRendererfunctionserver.js:350RequestLifecyclefrom @uniflowed/server/hostbeginRequestfrom @uniflowed/server/hostcreateInstrumentationfrom @uniflowed/server/instrumentationinstrumentRenderfrom @uniflowed/server/instrumentationtraceRequestPhasefrom @uniflowed/server/instrumentation
@uniflowed/router/routing
type
RouteParamSpec
export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
One parameter a route path captures.
type
RouteParams
export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
The parameters captured from a URL. A catch-all captures the rest as a list.
type
SearchParams
export type SearchParams = { readonly [string]: string };
The query string, as a read-only map.
type
RouteModule
export type RouteModule<TModule = mixed> = () => Promise<TModule>;
A lazy route module entry from the generated route table.
type
TemplateRecord
export type TemplateRecord<TTemplate = mixed> = {|
readonly above: number,
readonly module: RouteModule<TTemplate>,
|};
One $template.js, as the route table carries it.
type
LoadingRecord
export type LoadingRecord<TLoading = mixed> = {|
readonly above: number,
readonly module: RouteModule<TLoading>,
|};
One $loading.js, as the route table carries it.
type
SlotRecord
export type SlotRecord<
TPage = mixed,
TLayout = mixed,
TTemplate = mixed,
TLoading = mixed,
TError = mixed,
> = {|
readonly name: string,
readonly above: number,
readonly defaultPage: ?RouteModule<TPage>,
readonly defaultFile?: string,
readonly defaultMdx?: boolean,
readonly defaultErrorBoundary?: ?SlotErrorBoundaryRecord<TError>,
readonly routes: $ReadOnlyArray<SlotRouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
/**
* The routes this slot renders when a client navigation *intercepts* the URL
* each one names — `app/feed/@modal/(.)photo/[id]/$page.js` is the one for
* `/feed/photo/:id` — keyed by that URL.
*
* A second list rather than a flag on `routes`, because the two are read by
* different callers at different times and neither may see the other's.
* `routes` is matched against every URL the segment renders, by the server and
* by the browser alike. These are matched only by a navigation that starts on
* a page this slot is already rendered on, and nothing on a server reads them
* — which is the whole of why a document request for an intercepted URL
* renders the ordinary page. `resolveInterception`, beside the rest of the
* rendering, is the one reader.
*
* Absent on a slot that intercepts nothing, which is every slot a table
* written before interception existed.
*/
readonly intercepts?: $ReadOnlyArray<
SlotRouteRecord<TPage, TLayout, TTemplate, TLoading, TError>,
>,
|};
One parallel-route slot, as the route table carries it.
type
SlotRouteRecord
export type SlotRouteRecord<
TPage = mixed,
TLayout = mixed,
TTemplate = mixed,
TLoading = mixed,
TError = mixed,
> = {|
readonly path: string,
readonly params: $ReadOnlyArray<RouteParamSpec>,
readonly mdx: boolean,
readonly file: string,
readonly page: RouteModule<TPage>,
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
readonly errorBoundary?: ?SlotErrorBoundaryRecord<TError>,
readonly slots: $ReadOnlyArray<SlotRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
|};
One page inside a slot.
type
RouteRecord
export type RouteRecord<
TPage = mixed,
TLayout = mixed,
TTemplate = mixed,
TLoading = mixed,
TError = mixed,
> = {|
readonly path: string,
readonly params: $ReadOnlyArray<RouteParamSpec>,
readonly mdx: boolean,
readonly file: string,
readonly page?: RouteModule<TPage>,
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
readonly slots?: $ReadOnlyArray<SlotRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
|};
One entry of the generated route table.
type
NotFoundBoundary
export type NotFoundBoundary<TPage = mixed, TLayout = mixed> = {|
readonly path: string,
readonly mdx: boolean,
readonly file: string,
readonly page: ?RouteModule<TPage>,
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|};
One not-found boundary: the page for a path under path that matched nothing.
type
ErrorBoundary
export type ErrorBoundary<TError = mixed, TLayout = mixed> = {|
readonly path: string,
readonly file: string,
readonly module: ?RouteModule<TError>,
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|};
One error boundary: what renders in place of a subtree that threw.
type
RouteTable
export type RouteTable<
TPage = mixed,
TLayout = mixed,
TTemplate = mixed,
TLoading = mixed,
TError = mixed,
> = {|
readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
readonly nativeLinks?: {|
readonly origins: $ReadOnlyArray<string>,
readonly routes: $ReadOnlyArray<string>,
|},
readonly notFound: $ReadOnlyArray<NotFoundBoundary<TPage, TLayout>>,
readonly errors: $ReadOnlyArray<ErrorBoundary<TError, TLayout>>,
|};
A route table plus the boundaries declared under it.
type
RouteMatch
export type RouteMatch<TRoute: { +path: string, ... } = UnknownRouteRecord> = {|
readonly route: TRoute,
readonly params: RouteParams,
|};
A URL matched against a table.
type
RouteError
export type RouteError =
| {| readonly kind: "thrown", readonly error: mixed |}
| {| readonly kind: "unauthorized" |}
| {| readonly kind: "forbidden" |};
Why the router is rendering an error boundary instead of a page.
One union rather than one file convention per status. forbidden() and unauthorized() are not different *kinds* of file to write; they are different sentences an error page says.
function
routeErrorStatus
export function routeErrorStatus(error: RouteError): 401 | 403 | 500 { ... }
The status a RouteError answers with.
class
NotFoundError
export class NotFoundError extends Error { ... }
Thrown by notFound(); the renderer answers with the not-found page.
class
UnauthorizedError
export class UnauthorizedError extends Error { ... }
Thrown by unauthorized(); the renderer answers with the error boundary.
class
ForbiddenError
export class ForbiddenError extends Error { ... }
Thrown by forbidden(); the renderer answers with the error boundary.
class
RedirectError
export class RedirectError extends Error { ... }
Thrown by redirect(); the renderer answers with a redirect.
function
buildRoute
export function buildRoute(routePath: string, params?: RouteParams): string { ... }
The URL for a route pattern and the parameters it takes.
The inverse of [matchSegments], and deliberately built out of the same [compile]: a builder that parsed patterns its own way would drift from the matcher, and the drift would show up as a link that 404s rather than as a failure anybody could see.
function
hasClientPage
export function hasClientPage(route: { +page?: mixed, ... }): boolean { ... }
Whether this table can render the route in the browser.
function
matchRoute
export function matchRoute<TRoute: { +path: string, ... }>(
routes: $ReadOnlyArray<TRoute>,
pathname: string,
): ?RouteMatch<TRoute> { ... }
Match a pathname against the table, preferring the most specific route.
function
splitUrl
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} { ... }
Split a URL into its pathname and search string.
function
parseSearch
export function parseSearch(search: string): SearchParams { ... }
Parse a search string into a flat map; a repeated key keeps its last value.
function
notFound
export function notFound(): empty { ... }
Stop rendering the current page and show the not-found page instead.
function
unauthorized
export function unauthorized(): empty { ... }
Stop rendering the current page and show the error boundary, as a 401.
function
forbidden
export function forbidden(): empty { ... }
Stop rendering the current page and show the error boundary, as a 403.
function
redirect
export function redirect(to: string): empty { ... }
Stop rendering the current page and send the visitor elsewhere.
function
permanentRedirect
export function permanentRedirect(to: string): empty { ... }
redirect, with a permanent status.
function
summarizeResolvedRoute
export function summarizeResolvedRoute(
route: RouteLike,
errorSource?: ?string,
): ResolvedRouteSummary { ... }
The part of a ResolvedRoute that can cross to a payload or diagnostic.
A resolved route carries loaded modules so RouteView can render today. The element payload in ubugeeei-prod/uf#519 needs the other half: route identity, data, metadata, status, and boundary names, with page/layout/ fallback modules left behind in the renderer graph.
Without a doc comment
@uniflowed/router/handler
type
HandlerContext
export type HandlerContext = {|
/** The `[param]` and `[...rest]` segments of the matched path. */
readonly params: RouteParams,
/** The parsed query string, for the common case of reading one value. */
readonly searchParams: URLSearchParams,
|};
What a handler is given besides the request.
type
Handler
export type Handler = (request: Request, context: HandlerContext) => Response | Promise<Response>;
One exported method of a handler module.
type
HandlerModule
export type HandlerModule = { readonly [method: string]: mixed };
A handler module, as the generated table loads it.
type
HandlerRecord
export type HandlerRecord = {|
readonly path: string,
readonly params: $ReadOnlyArray<{| readonly name: string, readonly catchAll: boolean |}>,
readonly file: string,
readonly load: () => Promise<HandlerModule>,
|};
One entry of the generated handler table.
variable
HANDLER_METHODS
export const HANDLER_METHODS: $ReadOnlyArray<string> = Object.freeze([
"GET",
"HEAD",
"QUERY",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
]);
The methods a handler may export.
A closed list, because the alternative is treating every export as a method — and a module that exports a helper would then answer requests with it. HEAD falls back to GET with the body dropped, which is what a client asking for headers expects and what nobody remembers to write.
It was closed in name only until QUERY was added. pick looked the method up on the module and this list decided nothing but the order of the Allow header, so a module exporting PURGE answered PURGE — the exact behaviour the paragraph above says is refused. Adding a verb was the moment to make the sentence true, because the alternative was adding one to a list nothing read.
QUERY is here and CONNECT and TRACE are not, and the difference is not taste: the fetch specification forbids the last two outright, so a handler exporting either could never be reached by a browser.
function
createDispatcher
export function createDispatcher(options: {|
readonly handlers: $ReadOnlyArray<HandlerRecord>,
|}): (request: Request) => Promise<Response | null> { ... }
Match a request against the handler table and run it.
Returns null when no path matches, which is the caller's signal to carry on — a request for /about is a page, and the dispatcher declining is how it says so.
@uniflowed/router/middleware
type
MiddlewareContext
export type MiddlewareContext = {|
/** The `[param]` segments of the *directory the middleware guards*. */
readonly params: RouteParams,
/** The parsed query string, for the common case of reading one value. */
readonly searchParams: URLSearchParams,
|};
What a middleware is given besides the request.
class
Rewrite
export class Rewrite { ... }
What a middleware returns to serve another route at the requested address.
Built by [rewrite] and read by the runner; a class so that the runner can tell it from a Response without trusting the shape of an object.
function
rewrite
export function rewrite(destination: string | URL): Rewrite { ... }
Serve destination — a path of this application — in place of the path the request named.
Relative to the request, so "/beta/pricing" and "../pricing" both work. A destination that names no query keeps the request's; one that names a query replaces it. Another origin is refused when the middleware returns it: sending a visitor elsewhere is Response.redirect, and proxying to another server is a route handler that fetches.
type
Middleware
export type Middleware = (
request: Request,
context: MiddlewareContext,
) => Response | Rewrite | void | Promise<Response | Rewrite | void>;
One middleware function.
type
MiddlewareModule
export type MiddlewareModule = { readonly [name: string]: mixed };
A middleware module, as the generated table loads it.
type
MiddlewareRecord
export type MiddlewareRecord = {|
/** The route path of the directory this middleware guards, `/` at the root. */
readonly path: string,
readonly file: string,
readonly load: () => Promise<MiddlewareModule>,
|};
One entry of the generated middleware table.
function
createMiddlewareRunner
export function createMiddlewareRunner(options: {|
readonly middleware: $ReadOnlyArray<MiddlewareRecord>,
|}): (request: Request) => Promise<Response | Request | null> { ... }
Build the middleware runner for one application.
Returns null when every middleware on the path declined, which is the caller's signal to carry on to the handler or the page. Returns a Request when one of them rewrote: the same request at the destination, which the caller carries on with instead — and which has already been past the destination's middleware.
The runner is called once per request, above both the dispatcher and the renderer, rather than from inside each of them. Putting the call inside createDispatcher and again inside createRenderer was the first shape and it is wrong twice over: a request that matches neither — /dashboard/typo, which is a 404 — would have run no middleware at all, and a path that is both a page and a handler would have run it twice. Middleware is a property of the request, so it belongs where the request arrives.