Skip to content

Instantly share code, notes, and snippets.

@sergiodxa
Last active June 4, 2026 16:54
Show Gist options
  • Select an option

  • Save sergiodxa/0e921c1f47f3bb1af496cc0c142011f6 to your computer and use it in GitHub Desktop.

Select an option

Save sergiodxa/0e921c1f47f3bb1af496cc0c142011f6 to your computer and use it in GitHub Desktop.
Remix v3 SPA-only router

ui-router

Client-side routing for Remix UI components using route contracts from remix/routes.

Overview

ui-router experiments with the remix/router shape on the client. It maps route actions to Remix UI renderers and browser-side submissions while preserving Request, URL, params, and method context.

The package reuses remix/routes as the source of truth for URL patterns and remix/route-pattern for matching. Rendering is delegated to remix/ui through createRoot, so route handlers can return normal Remix UI JSX.

Route actions may be async. This lets a handler load the data needed to render the page before returning Remix UI. Mounted routers use the browser Navigation API when available, with a History API and same-origin link interception fallback for older browsers.

Usage

Basic Example

import type { Handle } from "remix/ui";
import { route } from "remix/routes";

import { createAction, createRouter } from "ui-router";

const routes = route({
	home: "/",
	post: "/posts/:id",
});

function HomePage() {
	return () => <h1>Home</h1>;
}

interface Post {
	id: string;
	title: string;
}

function PostPage(handle: Handle<{ post: Post }>) {
	return () => <article>{handle.props.post.title}</article>;
}

const router = createRouter();

router.map(
	routes.home,
	createAction(routes.home, () => <HomePage />),
);
router.map(
	routes.post,
	createAction(routes.post, async (ctx) => {
		let post = await fetchPost(ctx.params.id);
		return <PostPage post={post} />;
	}),
);

const mounted = router.mount(document.body);

Route Map Example

import { route } from "remix/routes";

import { createController, createRouter } from "ui-router";

const routes = route({
	posts: {
		index: "/posts",
		show: "/posts/:id",
	},
});

const router = createRouter();

router.map(
	routes.posts,
	createController(routes.posts, {
		actions: {
			index() {
				return <h1>Posts</h1>;
			},

			show(ctx) {
				return <h1>Post {ctx.params.id}</h1>;
			},
		},
	}),
);

Mutations and Fetchers

import type { Handle } from "remix/ui";

import { addEventListeners, on } from "remix/ui";
import { form, route } from "remix/routes";

import {
	RouterProvider,
	createContextKey,
	createController,
	createRouter,
} from "ui-router";

const routes = route({
	contact: form("contact"),
});

interface ContactResult {
	ok: boolean;
	message?: string;
}

function ContactPage(handle: Handle) {
	let router = handle.context.get(RouterProvider);
	let fetcher = router.getFetcher<ContactResult>("contact");

	addEventListeners(fetcher, handle.signal, {
		change() {
			handle.update();
		},
	});

	return () => (
		<form method="POST" action={routes.contact.action.href()} mix={fetcher.form()}>
			<textarea name="message" />
			<button disabled={fetcher.state !== "idle"}>Send</button>
			{fetcher.data?.message ? <p>{fetcher.data.message}</p> : null}
		</form>
	);
}

const router = createRouter();

router.map(
	routes.contact,
	createController(routes.contact, {
		actions: {
			index() {
				return <ContactPage />;
			},

			async action(ctx) {
				let formData = await ctx.request.formData();

				return {
					ok: true,
					message: `You said ${formData.get("message")}`,
				} satisfies ContactResult;
			},
		},
	}),
);

Middleware

Middleware can run globally on the router, on every direct action in a controller, or on a single action object. Middleware receives the same mutable context as actions and can either return a result to short-circuit or call next().

import { createAction, createContextKey, createController, createRouter } from "ui-router";

const CurrentUser = createContextKey<{ id: string }>();

const router = createRouter({
	middleware: [
		async (ctx, next) => {
			ctx.set(CurrentUser, { id: "user-1" });
			return next();
		},
	],
});

router.map(
	routes.admin,
	createController(routes.admin, {
		middleware: [
			(ctx, next) => {
				if (!ctx.get(CurrentUser)) return <ForbiddenPage />;
				return next();
			},
		],
		actions: {
			dashboard: createAction(routes.admin.dashboard, {
				middleware: [requireAdmin()],
				handler(ctx) {
					return <DashboardPage user={ctx.get(CurrentUser)!} />;
				},
			}),
		},
	}),
);

Programmatic Navigation

import { on } from "remix/ui";

router.map(routes.post, (ctx) => {
	return <button mix={on("click", () => ctx.navigate(routes.home.href()))}>Go home</button>;
});

router.navigate(routes.post.href({ id: "hello" }));

Router Context

Every rendered route is wrapped in RouterProvider, a Remix UI component that exposes the router and the current route context to descendants.

import type { Handle } from "remix/ui";

import { RouterProvider } from "ui-router";

function BackButton(handle: Handle) {
	let router = handle.context.get(RouterProvider);

	return () => <button mix={on("click", () => router.navigate("/"))}>Go home</button>;
}

The value returned by handle.context.get(RouterProvider) includes every router method plus context, match, url, params, and route for the current render.

Frames

Mounted routers configure Remix UI's <Frame> resolver automatically. A frame src renders through the same route action pipeline as router.render, so middleware, request context, params, and route actions all work inside embedded routes. Components inside frames can call handle.frame.reload() or reload named frames with handle.frames.get(name)?.reload().

import { Frame, on, type Handle } from "remix/ui";

function Dashboard() {
	return () => (
		<section>
			<h1>Dashboard</h1>
			<Frame name="sidebar" src="/sidebar" fallback={<p>Loading sidebar...</p>} />
		</section>
	);
}

function Sidebar(handle: Handle) {
	return () => <button mix={on("click", () => handle.frame.reload())}>Reload sidebar</button>;
}

If rootOptions.frameInit.resolveFrame is provided, the router preserves that custom resolver instead of replacing it.

API

createRouter(options?: RouterOptions): UIRouter

Creates a client-side router that can map Remix route definitions to view handlers.

Parameters:

  • options.baseURL: Base URL used when matching relative URLs outside a browser.
  • options.defaultElement: Optional renderer used when no route matches.
  • options.createRoot: Optional remix/ui root factory, useful for tests.
  • options.rootOptions: Options forwarded to remix/ui createRoot.
  • options.getLocation: Optional location reader for non-browser tests.
  • options.window: Optional browser window adapter.
  • options.interceptLinks: Whether mount should intercept same-origin links. Defaults to true.
  • options.middleware: Global middleware that runs before matched route actions and default renders.

Returns:

  • A UIRouter with map, match, render, navigate, submit, getFetcher, revalidate, form, and mount methods.

Example:

const router = createRouter({
	defaultElement(ctx) {
		return <h1>Not found: {ctx.url.pathname}</h1>;
	},
});

router.map(route, handler): UIRouter

Maps a single route definition to a view handler.

Parameters:

  • route: A Route produced by remix/routes.
  • handler: Function receiving Context and returning an action result. GET render actions should return a RemixNode or Promise<RemixNode>.

Returns:

  • The same router, so calls can be chained.

Example:

router.map(routes.post, async (ctx) => {
	let post = await fetchPost(ctx.params.id);
	return <PostPage post={post} />;
});

router.map(routeMap, controller): UIRouter

Maps the direct route leaves in a route map to view handlers.

Parameters:

  • routeMap: A branch from a remix/routes route map.
  • controller: Object with an actions object for each direct leaf route. A bare action object is also accepted for small apps and existing call sites.
  • controller.middleware: Optional middleware that runs for every direct action in the controller.

Returns:

  • The same router, so calls can be chained.

Example:

router.map(routes.posts, {
	actions: {
		index: () => <PostsPage />,
		show: (ctx) => <PostPage id={ctx.params.id} />,
	},
});

createAction(route, handler): ViewHandler

Defines a route handler or action object while inferring ctx.params from the given route target. It returns the same value, so it is useful when route actions live in separate files before they are passed to router.map.

Parameters:

  • route: A Route produced by remix/routes.
  • handler: Function or action object receiving route-specific Context and returning an action result.

Returns:

  • The same handler function.

Example:

export const renderPost = createAction(routes.post, async (ctx) => {
	let post = await fetchPost(ctx.params.id);
	return <PostPage post={post} />;
});

Action objects can attach middleware to one route action:

export const renderAdmin = createAction(routes.admin, {
	middleware: [requireAdmin()],
	handler(ctx) {
		return <AdminPage />;
	},
});

createController(routeMap, controller): UIController

Defines direct route-map handlers while inferring ctx.params for each direct route leaf. It returns the same controller object.

Parameters:

  • routeMap: A branch from a remix/routes route map.
  • controller: Object with handlers for each direct leaf route.

Returns:

  • The same controller object.

Example:

export const postsController = createController(routes.posts, {
	actions: {
		index: () => <PostsPage />,
		show: (ctx) => <PostPage id={ctx.params.id} />,
	},
});

router.submit(target, options?): Promise<data | undefined>

Submits data through an internal Fetcher, then navigates like a normal form submission. Redirect Response results navigate to their Location; other results navigate to the submitted route URL or revalidate when already on that URL.

router.getFetcher<data = unknown>(name?): Fetcher<data>

Returns a fetcher for background route submissions. Named fetchers are shared by key; unnamed fetchers receive a unique key per call.

router.revalidate(): Promise<void>

Re-runs the current route action for all mounted roots. Fetcher non-GET submissions call this after storing their result unless revalidate: false is passed.

router.form(options?): MixinDescriptor<HTMLFormElement>

Returns a form mixin that intercepts same-page form submissions and submits through router.submit.

fetcher.submit(target, options?): Promise<void>

Runs the matching route action without changing the current URL unless the action returns a redirect Response. The fetcher stores the result in fetcher.data and dispatches change events during state transitions.

fetcher.form(options?): MixinDescriptor<HTMLFormElement>

Returns a form mixin that submits through the fetcher. This is the Remix UI equivalent of React Router's fetcher.Form.

router.match(input?: RouterInput): RouteMatch | null

Finds the most specific mapped route for a URL without rendering it.

Parameters:

  • input: URL string, URL, or object with a url property. Defaults to the current location.

Returns:

  • A route match with url, route, and decoded params, or null.

Example:

const match = router.match("/posts/hello");
match?.params.id; // "hello"

router.render(input?: RouterInput): Promise<RemixNode>

Renders the matched handler for a URL without mounting it into the DOM. The returned node is wrapped in RouterProvider.

Parameters:

  • input: URL string, URL, or object with a url property. Defaults to the current location.

Returns:

  • A promise resolving to the provider-wrapped RemixNode returned by the matching handler, the default element, or null.

Example:

const node = await router.render("/posts/hello");

router.navigate(to: RouterInput, options?: NavigateOptions): Promise<void>

Updates the browser through the Navigation API when available and re-renders mounted roots from intercepted navigation events. Older browsers fall back to history.pushState/replaceState and explicit root refreshes. When options.mask is provided, the router renders to while showing mask in the address bar.

Parameters:

  • to: Destination URL.
  • options.mask: Optional visible URL to store in browser history while rendering to.
  • options.replace: Use history.replaceState instead of history.pushState.
  • options.state: Optional history state.

Example:

await router.navigate(routes.post.href({ id: "hello" }));
await router.navigate(routes.home.href(), { replace: true });
await router.navigate("/album/1?photoId=7", { mask: "/photo/7" });

router.mount(container: HTMLElement): MountedRouter

Mounts the router into a DOM element and renders the current location. Mounted routers listen for same-origin Navigation API events when available; otherwise they listen for popstate and intercept same-origin anchor clicks. Mounted roots also configure rootOptions.frameInit.resolveFrame so Remix UI frames can render and reload route content through this router.

Parameters:

  • container: Element passed to remix/ui createRoot.

Returns:

  • A mounted router controller with render, navigate, flush, and dispose methods.

Example:

const mounted = router.mount(document.body);
await mounted.render(routes.post.href({ id: "hello" }));
mounted.dispose();

Types

RouterInput

Accepts string, URL, or an object with a url property. The router resolves every input against options.baseURL or the current browser location.

Awaitable<value>

Accepts a direct value or a promise for that value. Route handlers and default elements use this so they can be sync or async.

RouteTarget<pattern>

Accepts a Route from remix/routes or a raw route-pattern string. Prefer Route values so params stay tied to the shared route contract.

RoutePatternSource<route>

Extracts the route-pattern string from a RouteTarget. This powers handler param inference.

Context<params, route>

Context passed to mapped route actions and middleware. It includes request, url, method, decoded params, the matched route, an abort signal, router helpers such as navigate, submit, revalidate, and getFetcher, plus get, set, and has for middleware-provided values.

createContextKey(defaultValue?)

Creates a key used by middleware and actions to store request-scoped values on ctx.

Middleware

Function that receives (ctx, next). Return a value to short-circuit the chain, return next() to continue explicitly, or return undefined to continue automatically.

ActionObject<route>

Object form for one route action with optional middleware and a final handler.

NotFoundContext

Context passed to options.defaultElement when no mapped route matches. It includes url, signal, and navigate.

ViewHandler<route>

Function type for route actions. It receives Context with params inferred from the mapped route and can return UI, data, or a Response.

Fetcher<data>

Typed EventTarget used for background submissions. It exposes state, data, formData, request, load, submit, form, and dispose.

SubmitOptions

Options accepted by router and fetcher submissions. Use action, method, encType, submitter, and revalidate to control how a submission request is created.

RouterProviderValue

Value exposed by RouterProvider through Remix UI context. It includes all UIRouter methods plus context, match, url, params, and route for the current render.

RouterProviderProps

Props accepted by RouterProvider. This is mostly internal, but exported so the provider component has a complete public contract.

RouterProvider

Remix UI component that stores RouterProviderValue in component context. Descendants can read it with handle.context.get(RouterProvider).

UIController<routes>

Object type for mapping direct route-map leaves. Each direct leaf route gets a handler with route-specific params.

RouteMatch<route>

Result returned by router.match. It includes url, route, and decoded params.

RouterNavigation

Small browser Navigation API adapter used by mount and navigate. Override it in tests when you need to simulate navigation.navigate and navigate events.

RouterNavigationOptions

Options forwarded to navigation.navigate, including history and state.

RouterNavigationResult

Result returned by navigation.navigate. The router awaits finished so intercepted route rendering completes before router.navigate resolves.

RouterWindow

Small browser window adapter used by mount and navigate. Override it in tests when you need to simulate location, navigation, history, and popstate.

RouterOptions

Options accepted by createRouter for URL resolution, default rendering, root creation, browser integration, and link interception.

NavigateOptions

Options accepted by navigate. Use mask for modal-style routes, replace to replace history, and state to store browser history state.

MountedRouter

Controller returned by mount. It can re-render one mounted root, navigate through the parent router, flush queued updates, and dispose listeners.

UIRouter

Router returned by createRouter. It exposes the public map, match, render, navigate, and mount methods.

Pattern: Route Contracts First

Keep routes in one module and share them between server controllers and the client UI router:

import { route } from "remix/routes";

export const routes = route({
	home: "/",
	post: "/posts/:id",
});

Server code can still use remix/router to return Response objects, while browser-only entry points can use ui-router to render Remix UI components for the same URL patterns.

Related Packages

  • @pkg/remix-helpers - Helpers for server-side Remix router controllers and views.
  • @pkg/ui - Shared UI package used by applications in this monorepo.

Tips

  1. Define routes once - Use remix/routes as the shared contract and avoid hard-coded path strings in view handlers.
  2. Map direct leaves - Route-map controllers only map direct leaf routes; nested route maps should be mapped explicitly.
  3. Keep handlers pure - Prefer returning UI from ctx and move browser effects into component event handlers.
  4. Prefer platform navigation - Mounted routers use window.navigation when available, so browser-driven navigations and programmatic router navigations share the same rendering path.
import { describe, expect, test } from "bun:test";
import type { RemixElement, RemixNode, VirtualRoot, VirtualRootOptions } from "remix/ui";
import { route } from "remix/routes";
import {
createAction,
createContextKey,
createController,
createRouter,
RouterProvider,
type RouterProviderValue,
type UIController,
type ViewHandler,
} from "./index";
interface ProviderRender {
value: RouterProviderValue;
children: RemixNode;
}
function readProviderRender(node: RemixNode): ProviderRender {
expect(isRemixElement(node)).toBe(true);
let element = node as RemixElement;
expect(element.type).toBe(RouterProvider);
return {
value: element.props.value as RouterProviderValue,
children: readSingleChild(element.props.children as RemixNode),
};
}
function isRemixElement(node: RemixNode): node is RemixElement {
return typeof node === "object" && node !== null && "$rmx" in node;
}
function readSingleChild(node: RemixNode): RemixNode {
if (Array.isArray(node) && node.length === 1) return node[0];
return node;
}
describe(createRouter.name, () => {
test("renders the handler for the most specific matching route", async () => {
let routes = route({
post: "/posts/:id",
settings: "/posts/settings",
});
let router = createRouter();
router.map(routes.post, (ctx) => `post:${ctx.params.id}`);
router.map(routes.settings, () => "settings");
expect(readProviderRender(await router.render("/posts/hello")).children).toBe("post:hello");
expect(readProviderRender(await router.render("/posts/settings")).children).toBe("settings");
});
test("returns decoded params from match", () => {
let routes = route({ post: "/posts/:id" });
let router = createRouter();
router.map(routes.post, () => null);
let match = router.match("/posts/hello%20world?draft=true");
expect(match?.url.pathname).toBe("/posts/hello%20world");
expect(match?.url.searchParams.get("draft")).toBe("true");
expect(match?.params.id).toBe("hello world");
});
test("renders async handlers", async () => {
let routes = route({ post: "/posts/:id" });
let router = createRouter();
router.map(routes.post, async (ctx) => {
await Promise.resolve();
return `post:${ctx.params.id}`;
});
let rendered = readProviderRender(await router.render("/posts/async"));
expect(rendered.children).toBe("post:async");
expect(rendered.value.params.id).toBe("async");
expect(rendered.value.match?.params.id).toBe("async");
expect(rendered.value.context.url.pathname).toBe("/posts/async");
expect(rendered.value.navigate).toBe(router.navigate);
});
test("maps direct route-map leaves", async () => {
let routes = route({
posts: {
index: "/posts",
show: "/posts/:id",
},
});
let router = createRouter();
router.map(routes.posts, {
index: () => "index",
show: (ctx) => `show:${ctx.params.id}`,
});
expect(readProviderRender(await router.render("/posts")).children).toBe("index");
expect(readProviderRender(await router.render("/posts/123")).children).toBe("show:123");
});
test("createAction returns the same handler with route params inferred", () => {
let routes = route({ post: "/posts/:id" });
let original = ((ctx) => `post:${ctx.params.id}`) satisfies ViewHandler<typeof routes.post>;
let handler = createAction(routes.post, original);
expect(handler).toBe(original);
});
test("createController returns the same controller object", () => {
let routes = route({
posts: {
index: "/posts",
show: "/posts/:id",
},
});
let controller = {
index: () => "index",
show: (ctx) => `show:${ctx.params.id}`,
} satisfies UIController<typeof routes.posts>;
let wrapped = createController(routes.posts, controller);
expect(wrapped).toBe(controller);
});
test("maps fetch-router-style controller actions", async () => {
let routes = route({
contact: {
index: { method: "GET", pattern: "/contact" },
action: { method: "POST", pattern: "/contact" },
},
});
let router = createRouter();
router.map(
routes.contact,
createController(routes.contact, {
actions: {
index(ctx) {
return `index:${ctx.request.method}:${ctx.method}`;
},
async action(ctx) {
let formData = await ctx.request.formData();
return { message: formData.get("message") };
},
},
}),
);
let fetcher = router.getFetcher<{ message: FormDataEntryValue | null }>("contact");
expect(readProviderRender(await router.render("/contact")).children).toBe("index:GET:GET");
await fetcher.submit(
{ message: "hello" },
{
method: "POST",
action: "/contact",
revalidate: false,
},
);
expect(fetcher.data).toEqual({ message: "hello" });
expect(fetcher.state).toBe("idle");
});
test("fetcher submissions revalidate mounted roots", async () => {
let routes = route({
contact: {
index: { method: "GET", pattern: "/contact" },
action: { method: "POST", pattern: "/contact" },
},
});
let renders = 0;
let rendered: RemixNode[] = [];
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render(node) {
rendered.push(readProviderRender(node).children);
},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return "/contact";
},
createRoot() {
return root;
},
});
router.map(routes.contact, {
actions: {
index() {
renders++;
return `render:${renders}`;
},
action() {
return { ok: true };
},
},
});
let mounted = router.mount({} as HTMLElement);
await mounted.render();
let fetcher = router.getFetcher<{ ok: boolean }>();
let renderCount = rendered.length;
await fetcher.submit(null, { method: "POST", action: "/contact" });
expect(fetcher.data).toEqual({ ok: true });
expect(rendered).toHaveLength(renderCount + 1);
expect(rendered.at(-1)).toBe(`render:${renders}`);
mounted.dispose();
});
test("form submissions ignore default submitter action without an override attribute", async () => {
let routes = route({
album: "/album/:id",
likePhoto: { method: "POST", pattern: "/album/:albumId/photos/:photoId/like" },
});
let originalHTMLFormElement = globalThis.HTMLFormElement;
let originalHTMLButtonElement = globalThis.HTMLButtonElement;
let originalFormData = globalThis.FormData;
class TestFormElement {
action = "http://localhost/album/14/photos/651/like";
method = "post";
enctype = "application/x-www-form-urlencoded";
}
class TestButtonElement {
form = new TestFormElement();
formAction = "http://localhost/album/14";
formMethod = "";
formEnctype = "";
formTarget = "";
hasAttribute() {
return false;
}
}
class TestFormData {
fields: [string, string][] = [["intent", "like"]];
get(name: string) {
return this.fields.find(([fieldName]) => fieldName === name)?.[1] ?? null;
}
*[Symbol.iterator]() {
yield* this.fields;
}
}
try {
globalThis.HTMLFormElement = TestFormElement as never;
globalThis.HTMLButtonElement = TestButtonElement as never;
globalThis.FormData = TestFormData as never;
let router = createRouter();
router.map(routes.album, () => "album");
router.map(routes.likePhoto, (ctx) => `like:${ctx.params.albumId}:${ctx.params.photoId}`);
let submitter = new TestButtonElement();
let fetcher = router.getFetcher<string>();
await fetcher.submit(submitter.form as never, {
submitter: submitter as never,
revalidate: false,
});
expect(fetcher.data).toBe("like:14:651");
} finally {
globalThis.HTMLFormElement = originalHTMLFormElement;
globalThis.HTMLButtonElement = originalHTMLButtonElement;
globalThis.FormData = originalFormData;
}
});
test("fetcher form mixin submits inserted forms", async () => {
let routes = route({
likePhoto: { method: "POST", pattern: "/album/:albumId/photos/:photoId/like" },
});
let originalHTMLFormElement = globalThis.HTMLFormElement;
let originalHTMLButtonElement = globalThis.HTMLButtonElement;
let originalFormData = globalThis.FormData;
class TestFormElement {
action = "http://localhost/album/14/photos/651/like";
method = "post";
enctype = "application/x-www-form-urlencoded";
target = "";
listener: ((event: SubmitEvent) => void) | undefined;
addEventListener(_type: string, listener: (event: SubmitEvent) => void) {
this.listener = listener;
}
removeEventListener(_type: string, listener: (event: SubmitEvent) => void) {
if (this.listener === listener) this.listener = undefined;
}
}
class TestButtonElement {
form: TestFormElement;
formAction = "http://localhost/album/14";
formMethod = "";
formEnctype = "";
formTarget = "";
constructor(form: TestFormElement) {
this.form = form;
}
hasAttribute() {
return false;
}
}
class TestFormData {
fields: [string, string][] = [["intent", "like"]];
get(name: string) {
return this.fields.find(([fieldName]) => fieldName === name)?.[1] ?? null;
}
*[Symbol.iterator]() {
yield* this.fields;
}
}
try {
globalThis.HTMLFormElement = TestFormElement as never;
globalThis.HTMLButtonElement = TestButtonElement as never;
globalThis.FormData = TestFormData as never;
let router = createRouter();
let form = new TestFormElement();
let submitter = new TestButtonElement(form);
let insertListener: ((event: { node: TestFormElement }) => void) | undefined;
let removeListener: (() => void) | undefined;
router.map(routes.likePhoto, (ctx) => `like:${ctx.params.albumId}:${ctx.params.photoId}`);
let fetcher = router.getFetcher<string>();
let descriptor = fetcher.form() as unknown as {
type(handle: unknown): (...args: unknown[]) => unknown;
args: unknown[];
};
let apply = descriptor.type({
element: form,
addEventListener(type: string, listener: unknown) {
if (type === "insert") insertListener = listener as typeof insertListener;
if (type === "remove") removeListener = listener as typeof removeListener;
},
});
let submitted = new Promise<void>((resolve) => {
fetcher.addEventListener("change", () => {
if (fetcher.state === "idle" && fetcher.data) resolve();
});
});
let defaultPrevented = false;
apply(...descriptor.args);
insertListener?.({ node: form });
form.listener?.({
currentTarget: form,
defaultPrevented: false,
submitter,
preventDefault() {
defaultPrevented = true;
},
} as never);
await submitted;
expect(defaultPrevented).toBe(true);
expect(fetcher.data).toBe("like:14:651");
removeListener?.();
expect(form.listener).toBeUndefined();
} finally {
globalThis.HTMLFormElement = originalHTMLFormElement;
globalThis.HTMLButtonElement = originalHTMLButtonElement;
globalThis.FormData = originalFormData;
}
});
test("router submit navigates to redirect responses", async () => {
let routes = route({
save: { method: "POST", pattern: "/save" },
done: { method: "GET", pattern: "/done" },
});
let rendered: RemixNode[] = [];
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render(node) {
rendered.push(readProviderRender(node).children);
},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return "/done";
},
createRoot() {
return root;
},
});
router.map(
routes.save,
() => new Response(null, { status: 302, headers: { Location: "/done" } }),
);
router.map(routes.done, () => "done");
let mounted = router.mount({} as HTMLElement);
await mounted.render();
await router.submit(null, { method: "POST", action: "/save" });
expect(rendered).toEqual(["done", "done"]);
mounted.dispose();
});
test("runs router, controller, and action middleware in order", async () => {
let routes = route({
admin: {
show: "/admin/:id",
},
});
let User = createContextKey<{ id: string }>();
let calls: string[] = [];
let router = createRouter({
middleware: [
async (ctx, next) => {
calls.push("router:before");
ctx.set(User, { id: "root" });
let result = await next();
calls.push("router:after");
return result;
},
],
});
router.map(
routes.admin,
createController(routes.admin, {
middleware: [
(ctx, next) => {
calls.push(`controller:${ctx.get(User)?.id}`);
ctx.set(User, { id: "controller" });
return next();
},
],
actions: {
show: {
middleware: [
(ctx, next) => {
calls.push(`action:${ctx.params.id}`);
return next();
},
],
handler(ctx) {
calls.push("handler");
return `user:${ctx.get(User)?.id}`;
},
},
},
}),
);
expect(readProviderRender(await router.render("/admin/123")).children).toBe("user:controller");
expect(calls).toEqual([
"router:before",
"controller:root",
"action:123",
"handler",
"router:after",
]);
});
test("middleware can short-circuit route actions", async () => {
let routes = route({ secret: "/secret" });
let router = createRouter();
router.map(
routes.secret,
createAction(routes.secret, {
middleware: [() => "blocked"],
handler() {
return "secret";
},
}),
);
expect(readProviderRender(await router.render("/secret")).children).toBe("blocked");
});
test("renders the default element when no route matches", async () => {
let router = createRouter({
async defaultElement(ctx) {
return `not-found:${ctx.url.pathname}`;
},
});
let rendered = readProviderRender(await router.render("/missing"));
expect(router.match("/missing")).toBeNull();
expect(rendered.children).toBe("not-found:/missing");
expect(rendered.value.match).toBeNull();
expect(rendered.value.params).toEqual({});
expect(rendered.value.context.url.pathname).toBe("/missing");
});
test("mount renders current location and responds to navigation", async () => {
let routes = route({
home: "/",
post: "/posts/:id",
});
let rendered: RemixNode[] = [];
let disposed = false;
let flushed = false;
let location = "http://localhost/";
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render(node) {
rendered.push(readProviderRender(node).children);
},
dispose() {
disposed = true;
},
flush() {
flushed = true;
},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return location;
},
createRoot() {
return root;
},
});
router.map(routes.home, () => "home");
router.map(routes.post, (ctx) => `post:${ctx.params.id}`);
let mounted = router.mount({} as HTMLElement);
await mounted.render();
expect(rendered).toEqual(["home"]);
await router.navigate("/posts/abc");
expect(rendered).toEqual(["home", "post:abc"]);
location = "http://localhost/posts/def";
await mounted.render();
expect(rendered).toEqual(["home", "post:abc", "post:def"]);
mounted.flush();
mounted.dispose();
expect(flushed).toBe(true);
expect(disposed).toBe(true);
});
test("aborts the previous mounted render signal", async () => {
let routes = route({ home: "/", post: "/posts/:id" });
let signals: AbortSignal[] = [];
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render() {},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return "/";
},
createRoot() {
return root;
},
});
router.map(routes.home, (ctx) => {
signals.push(ctx.signal);
return "home";
});
router.map(routes.post, (ctx) => {
signals.push(ctx.signal);
return `post:${ctx.params.id}`;
});
let mounted = router.mount({} as HTMLElement);
await Promise.resolve();
await mounted.render("/posts/123");
expect(signals[0]?.aborted).toBe(true);
expect(signals[1]?.aborted).toBe(false);
mounted.dispose();
expect(signals[1]?.aborted).toBe(true);
});
test("does not commit stale async mounted renders", async () => {
let routes = route({ home: "/", post: "/posts/:id" });
let rendered: RemixNode[] = [];
let resolveHome: ((value: RemixNode) => void) | undefined;
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render(node) {
rendered.push(readProviderRender(node).children);
},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return "/";
},
createRoot() {
return root;
},
});
router.map(routes.home, () => {
return new Promise<RemixNode>((resolve) => {
resolveHome = resolve;
});
});
router.map(routes.post, async (ctx) => `post:${ctx.params.id}`);
let mounted = router.mount({} as HTMLElement);
await mounted.render("/posts/123");
expect(rendered).toEqual(["post:123"]);
resolveHome?.("home");
await Promise.resolve();
expect(rendered).toEqual(["post:123"]);
mounted.dispose();
});
test("renders an internal URL while masking the browser URL", async () => {
let routes = route({ album: "/album/:id", photo: "/photo/:id" });
let rendered: RemixNode[] = [];
let historyState: unknown;
let visibleURL = "http://localhost:3000/album/1";
let popstateListener: EventListener | undefined;
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render(node) {
rendered.push(readProviderRender(node).children);
},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return visibleURL;
},
createRoot() {
return root;
},
window: {
location: {
href: visibleURL,
origin: "http://localhost:3000",
} as Location,
history: {
pushState(state, _unused, url) {
historyState = state;
visibleURL = new URL(String(url), visibleURL).href;
},
replaceState() {},
} as History,
addEventListener(type, listener) {
if (type === "popstate") popstateListener = listener;
},
removeEventListener() {},
},
});
router.map(routes.album, (ctx) => {
return `album:${ctx.params.id}:photo:${ctx.url.searchParams.get("photoId")}`;
});
router.map(routes.photo, (ctx) => `photo:${ctx.params.id}`);
let mounted = router.mount({} as HTMLElement);
await mounted.render();
await router.navigate("/album/1?photoId=7", { mask: "/photo/7" });
expect(visibleURL).toBe("http://localhost:3000/photo/7");
expect(rendered).toEqual(["album:1:photo:null", "album:1:photo:7"]);
popstateListener?.({ state: historyState } as PopStateEvent);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(rendered).toEqual(["album:1:photo:null", "album:1:photo:7", "album:1:photo:7"]);
mounted.dispose();
});
test("uses the Navigation API when available", async () => {
let routes = route({ album: "/album/:id" });
let rendered: RemixNode[] = [];
let visibleURL = "http://localhost:3000/album/1";
let navigateListener: EventListener | undefined;
let removedNavigateListener: EventListener | undefined;
let navigationOptions: unknown;
let historyUsed = false;
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render(node) {
rendered.push(readProviderRender(node).children);
},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return visibleURL;
},
createRoot() {
return root;
},
window: {
location: {
href: visibleURL,
origin: "http://localhost:3000",
} as Location,
history: {
pushState() {
historyUsed = true;
},
replaceState() {
historyUsed = true;
},
} as History,
navigation: {
navigate(url, options) {
navigationOptions = options;
visibleURL = url;
let intercepted = Promise.resolve();
let event = {
canIntercept: true,
navigationType: options?.history ?? "push",
destination: {
url,
getState() {
return options?.state;
},
},
intercept(interceptOptions: { handler(): Promise<void> | void }) {
intercepted = Promise.resolve(interceptOptions.handler());
},
} as Event;
navigateListener?.(event);
return {
committed: Promise.resolve(),
finished: intercepted,
};
},
addEventListener(type, listener) {
if (type === "navigate") navigateListener = listener;
},
removeEventListener(type, listener) {
if (type === "navigate") removedNavigateListener = listener;
},
},
addEventListener() {},
removeEventListener() {},
},
});
router.map(routes.album, (ctx) => {
return `album:${ctx.params.id}:photo:${ctx.url.searchParams.get("photoId")}`;
});
let mounted = router.mount({} as HTMLElement);
await mounted.render();
await router.navigate("/album/1?photoId=7", { mask: "/photo/7" });
expect(historyUsed).toBe(false);
expect(visibleURL).toBe("http://localhost:3000/photo/7");
expect(navigationOptions).toEqual(
expect.objectContaining({
history: "push",
}),
);
expect(rendered).toEqual(["album:1:photo:null", "album:1:photo:7"]);
mounted.dispose();
expect(removedNavigateListener).toBe(navigateListener);
});
test("mount configures frames to render router routes", async () => {
let routes = route({ home: "/", sidebar: "/sidebar" });
let rootOptions: VirtualRootOptions | undefined;
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render() {},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
getLocation() {
return "http://localhost/";
},
createRoot(_container, options) {
rootOptions = options;
return root;
},
});
router.map(routes.home, () => "home");
router.map(routes.sidebar, (ctx) => `sidebar:${ctx.request.method}`);
let mounted = router.mount({} as HTMLElement);
let frameNode = await rootOptions?.frameInit?.resolveFrame("/sidebar");
expect(rootOptions?.frameInit?.src).toBe("http://localhost/");
expect(readProviderRender(frameNode as RemixNode).children).toBe("sidebar:GET");
mounted.dispose();
});
test("preserves custom frame resolvers", async () => {
let rootOptions: VirtualRootOptions | undefined;
let root: VirtualRoot = {
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
render() {},
dispose() {},
flush() {},
};
let router = createRouter({
interceptLinks: false,
rootOptions: {
frameInit: {
src: "/custom",
resolveFrame(src) {
return `custom:${src}`;
},
},
},
createRoot(_container, options) {
rootOptions = options;
return root;
},
});
let mounted = router.mount({} as HTMLElement);
expect(await rootOptions?.frameInit?.resolveFrame("/frame")).toBe("custom:/frame");
mounted.dispose();
});
});
import type { Handle, MixinDescriptor, RemixNode, VirtualRoot, VirtualRootOptions } from "remix/ui";
import { createMultiMatcher, type MatchParams } from "remix/route-pattern/match";
import { Route, type RouteMap } from "remix/routes";
import { createElement, createMixin, TypedEventTarget } from "remix/ui";
import { createRoot as createRemixRoot } from "remix/ui";
const DEFAULT_BASE_URL = "http://localhost/";
/**
* URL input accepted by matching, rendering, and navigation methods.
*/
export type RouterInput = string | URL | { url: string | URL };
/**
* A single route value accepted by `router.map`.
*/
export type RouteTarget<pattern extends string = string> = string | Route<any, pattern>;
/**
* Value that may be available immediately or after route data loading completes.
*/
export type Awaitable<value> = value | Promise<value>;
/** Type-safe key for values stored in route action context. */
export interface ContextKey<value> {
/** Value returned by `ctx.get(key)` when no value has been set. */
defaultValue?: value;
}
/** Creates a context key for middleware-provided values. */
export function createContextKey<value>(): ContextKey<value>;
export function createContextKey<value>(defaultValue: value): ContextKey<value> & {
defaultValue: value;
};
export function createContextKey<value>(defaultValue?: value): ContextKey<value> {
return defaultValue === undefined ? {} : { defaultValue };
}
/** Resolves the value type associated with a context key. */
export type ContextValue<key> = key extends ContextKey<infer value> ? value : never;
/**
* Extracts the route-pattern source string from a route target.
*/
export type RoutePatternSource<route> =
route extends Route<any, infer pattern extends string>
? pattern
: route extends string
? route
: never;
/**
* Client-side route handler context passed to mapped view handlers.
*/
export interface Context<
params extends Record<string, string | undefined> = Record<string, string | undefined>,
route extends RouteTarget = RouteTarget,
> {
/** Fetch request represented by this route action. */
request: Request;
/** Fully resolved URL for the current render. */
url: URL;
/** Request method used for the current route action. */
method: string;
/** Decoded params from the matched route pattern. */
params: params;
/** The route target that produced this match. */
route: route;
/** Signal aborted when a mounted render is replaced or disposed. */
signal: AbortSignal;
/** Navigate to another URL and refresh mounted router roots. */
navigate(to: RouterInput, options?: NavigateOptions): Promise<void>;
/** Submit data through a route action, then navigate like a normal form submission. */
submit<data = unknown>(target: SubmitTarget, options?: SubmitOptions): Promise<data | undefined>;
/** Re-run the current route action for mounted roots. */
revalidate(): Promise<void>;
/** Return a shared or unique fetcher for background submissions. */
getFetcher<data = unknown>(name?: string): Fetcher<data>;
/** Read a middleware-provided context value. */
get<key extends ContextKey<unknown>>(key: key): ContextValue<key> | undefined;
/** Check whether a middleware-provided value exists. */
has<key extends ContextKey<unknown>>(key: key): boolean;
/** Store a middleware-provided context value. */
set<key extends ContextKey<unknown>>(key: key, value: ContextValue<key>): void;
}
/**
* Context passed to the default element when no route matches.
*/
export interface NotFoundContext {
/** Fetch request represented by this default render. */
request: Request;
/** Fully resolved URL that failed to match a mapped route. */
url: URL;
/** Request method used for the default render. */
method: string;
/** Signal aborted when a mounted render is replaced or disposed. */
signal: AbortSignal;
/** Navigate to another URL and refresh mounted router roots. */
navigate(to: RouterInput, options?: NavigateOptions): Promise<void>;
/** Submit data through a route action, then navigate like a normal form submission. */
submit<data = unknown>(target: SubmitTarget, options?: SubmitOptions): Promise<data | undefined>;
/** Re-run the current route action for mounted roots. */
revalidate(): Promise<void>;
/** Return a shared or unique fetcher for background submissions. */
getFetcher<data = unknown>(name?: string): Fetcher<data>;
/** Read a middleware-provided context value. */
get<key extends ContextKey<unknown>>(key: key): ContextValue<key> | undefined;
/** Check whether a middleware-provided value exists. */
has<key extends ContextKey<unknown>>(key: key): boolean;
/** Store a middleware-provided context value. */
set<key extends ContextKey<unknown>>(key: key, value: ContextValue<key>): void;
}
/**
* Function that renders UI for a matched route.
*/
export interface ViewHandler<route extends RouteTarget = RouteTarget> {
/**
* Render UI for the matched route.
*
* @param ctx Matched URL, params, route, signal, and navigation helpers.
* @returns Remix UI node to render for the current route.
*/
(ctx: Context<MatchParams<RoutePatternSource<route>>, route>): Awaitable<unknown>;
}
/** Function that invokes the next middleware or action in the chain. */
export interface NextFunction {
/** Continue to the next middleware or final route action. */
(): Promise<unknown>;
}
/** Middleware that can short-circuit or continue a route action. */
export interface Middleware<context extends Context = Context> {
/**
* Handles a route action before the final handler runs.
*
* @param context Mutable route action context shared by the full chain.
* @param next Invokes the next middleware or final action.
* @returns A result to short-circuit, or `undefined` to continue.
*/
(context: context, next: NextFunction): Awaitable<unknown | undefined | void>;
}
/** Route action object with inline middleware. */
export interface ActionObject<route extends RouteTarget = RouteTarget> {
/** Middleware that runs only for this route action. */
middleware?: readonly Middleware[];
/** Final route action handler. */
handler: ViewHandler<route>;
}
/** Route action accepted by `router.map` and route-map controllers. */
export type Action<route extends RouteTarget = RouteTarget> =
| ViewHandler<route>
| ActionObject<route>;
/** State exposed by fetchers while a route action is running or revalidating. */
export type FetcherState = "idle" | "submitting" | "loading";
/** Accepted targets for route submissions. */
export type SubmitTarget =
| HTMLFormElement
| HTMLButtonElement
| HTMLInputElement
| FormData
| URLSearchParams
| Record<string, unknown>
| null
| undefined;
/** Options used to submit data through a route action. */
export interface SubmitOptions {
/** URL submitted to. Defaults to the current location or form action. */
action?: RouterInput;
/** HTTP method used for the request. Defaults to the form method or GET. */
method?: string;
/** Encoding type used for form submissions. */
encType?: string;
/** Submitter button/input whose overrides should be applied. */
submitter?: HTMLElement | null;
/** Re-run the current route after non-GET fetcher submissions. Defaults to true. */
revalidate?: boolean;
}
/** Options passed to form mixins. */
export interface FormMixinOptions {
/** Re-run the current route after non-GET fetcher submissions. */
revalidate?: boolean;
}
/** Events dispatched by fetchers when their public state changes. */
export interface FetcherEvents {
change: Event;
}
/**
* Router and current match data provided through Remix UI context.
*/
export interface RouterProviderValue extends UIRouter {
/** Route context used to render the current UI tree. */
context: Context | NotFoundContext;
/** Current route match, or `null` when rendering the default element. */
match: RouteMatch | null;
/** Fully resolved URL used for the current render. */
url: URL;
/** Current route params, or an empty object for default renders. */
params: Record<string, string | undefined>;
/** Current route target, or `undefined` for default renders. */
route?: RouteTarget;
}
/**
* Props accepted by the internal router provider component.
*/
export interface RouterProviderProps {
/** Router and route context value exposed to descendant components. */
value: RouterProviderValue;
/** Route UI rendered under the provider. */
children?: RemixNode;
}
/**
* Controller object for direct leaf routes in a route map branch.
*/
export type UIControllerActions<routes extends RouteMap> = {
[name in keyof routes as routes[name] extends Route<any, any>
? name
: never]: routes[name] extends Route<any, any> ? Action<routes[name]> : never;
};
/** Controller object for direct leaf routes in a route map branch. */
export interface UIController<routes extends RouteMap> {
/** Middleware that runs for every direct action in this controller. */
middleware?: readonly Middleware[];
/** Route actions for direct leaf routes in the route map. */
actions: UIControllerActions<routes>;
}
/** Bare controller action map retained for small apps and existing call sites. */
export type UIControllerInput<routes extends RouteMap> =
| UIController<routes>
| UIControllerActions<routes>;
/**
* Defines a route view handler with params inferred from a route target.
*
* @param route Route target used only to infer handler context types.
* @param handler View handler for the route target.
* @returns The same handler function.
*/
export function createAction<route extends RouteTarget, handler extends Action<route>>(
_route: route,
handler: handler,
): handler {
return handler;
}
/**
* Defines route-map handlers with params inferred from direct route leaves.
*
* @param routes Route map used only to infer handler context types.
* @param controller Controller handlers for the direct route leaves.
* @returns The same controller object.
*/
export function createController<
routes extends RouteMap,
controller extends UIControllerInput<routes>,
>(_routes: routes, controller: controller): controller {
return controller;
}
/**
* Matched route data returned by `router.match`.
*/
export interface RouteMatch<route extends RouteTarget = RouteTarget> {
/** Fully resolved URL that matched the route. */
url: URL;
/** The route target registered with the router. */
route: route;
/** Decoded params from the matched pattern. */
params: MatchParams<RoutePatternSource<route>>;
}
/**
* Browser window surface used by the mounted router.
*/
export interface RouterWindow {
/** Current browser location. */
readonly location: Location;
/** Browser history used for programmatic navigation. */
readonly history: History;
/** Browser Navigation API used when available. */
readonly navigation?: RouterNavigation;
/** Subscribe to browser events such as `popstate`. */
addEventListener(
type: string,
listener: EventListener,
options?: boolean | AddEventListenerOptions,
): void;
/** Unsubscribe from browser events such as `popstate`. */
removeEventListener(
type: string,
listener: EventListener,
options?: boolean | EventListenerOptions,
): void;
}
/** Browser Navigation API surface used by the router. */
export interface RouterNavigation {
/** Programmatically navigate through the browser Navigation API. */
navigate(url: string, options?: RouterNavigationOptions): RouterNavigationResult;
/** Subscribe to Navigation API events. */
addEventListener(type: string, listener: EventListener): void;
/** Unsubscribe from Navigation API events. */
removeEventListener(type: string, listener: EventListener): void;
}
/** Options accepted by the browser Navigation API. */
export interface RouterNavigationOptions {
/** Browser history behavior for the new navigation entry. */
history?: "push" | "replace";
/** Entry state stored with the navigation. */
state?: unknown;
}
/** Result returned by the browser Navigation API. */
export interface RouterNavigationResult {
/** Resolves after the new entry is committed. */
committed: Promise<unknown>;
/** Resolves after intercepted navigation work finishes. */
finished: Promise<unknown>;
}
/** Navigation API event shape consumed by mounted routers. */
interface RouterNavigationEvent extends Event {
canIntercept?: boolean;
navigationType?: "push" | "replace" | "reload" | "traverse";
destination: {
url: string;
getState(): unknown;
};
intercept(options: { handler(): Awaitable<void> }): void;
}
/**
* Router configuration for matching and mounting.
*/
export interface RouterOptions {
/** Base URL used to resolve relative inputs outside a browser. */
baseURL?: string | URL;
/** Rendered when no mapped route matches the current URL. */
defaultElement?: (ctx: NotFoundContext) => Awaitable<RemixNode>;
/** Root factory override, primarily for tests. */
createRoot?: (container: HTMLElement, options?: VirtualRootOptions) => VirtualRoot;
/** Options forwarded to `remix/ui` `createRoot`. */
rootOptions?: VirtualRootOptions;
/** Current location reader override, primarily for tests. */
getLocation?: () => RouterInput;
/** Browser window adapter override, primarily for tests. */
window?: RouterWindow;
/** Intercept same-origin anchor clicks from mounted containers. Defaults to `true`. */
interceptLinks?: boolean;
/** Middleware that runs before matched route actions and default renders. */
middleware?: readonly Middleware[];
}
/**
* Programmatic navigation options.
*/
export interface NavigateOptions {
/** URL shown in browser history while rendering the `to` URL. */
mask?: RouterInput;
/** Replace the current history entry instead of pushing a new one. */
replace?: boolean;
/** Optional state stored in browser history. */
state?: unknown;
}
/**
* Controller returned from a mounted router root.
*/
export interface MountedRouter {
/** Render a URL into this mounted root. */
render(input?: RouterInput): Promise<RemixNode>;
/** Navigate and refresh all mounted roots for this router. */
navigate(to: RouterInput, options?: NavigateOptions): Promise<void>;
/** Flush queued Remix UI updates synchronously. */
flush(): void;
/** Dispose DOM listeners, abort active render work, and dispose the Remix root. */
dispose(): void;
}
/**
* Client-side router that maps Remix route contracts to Remix UI renderers.
*/
export interface UIRouter {
/** Map one route target to a view handler. */
map<route extends RouteTarget>(route: route, handler: Action<route>): UIRouter;
/** Map the direct leaf routes in a route map to view handlers. */
map<routes extends RouteMap>(routes: routes, controller: UIControllerInput<routes>): UIRouter;
/** Find the most specific registered route for a URL. */
match(input?: RouterInput): RouteMatch | null;
/** Render the matching route handler without mounting into the DOM. */
render(input?: RouterInput): Promise<RemixNode>;
/** Navigate and refresh mounted roots. */
navigate(to: RouterInput, options?: NavigateOptions): Promise<void>;
/** Submit data through a route action, then navigate like a normal form submission. */
submit<data = unknown>(target: SubmitTarget, options?: SubmitOptions): Promise<data | undefined>;
/** Return a shared named fetcher or a unique unnamed fetcher. */
getFetcher<data = unknown>(name?: string): Fetcher<data>;
/** Re-run mounted roots for the latest rendered URL. */
revalidate(): Promise<void>;
/** Mixin that makes a form submit through router navigation submissions. */
form(options?: FormMixinOptions): MixinDescriptor<HTMLFormElement>;
/** Mount the router into a DOM container using Remix UI. */
mount(container: HTMLElement): MountedRouter;
}
/** Stores the route target and handler attached to a matcher entry. */
interface RouteEntry<route extends RouteTarget = RouteTarget> {
route: route;
handler: ViewHandler<route>;
method: string;
middleware: readonly Middleware[];
}
/** Internal contract used by fetchers to execute submissions. */
interface FetcherExecutor {
baseURL: URL;
getLocation(): RouterInput;
run(request: Request, signal: AbortSignal): Promise<unknown>;
navigate(to: RouterInput, options?: NavigateOptions): Promise<void>;
revalidate(): Promise<void>;
}
/** Internal mount record used to refresh all active roots after navigation. */
interface MountedRoot {
render(input?: RouterInput): Promise<RemixNode>;
navigate(to: RouterInput, options?: NavigateOptions): Promise<void>;
flush(): void;
dispose(): void;
}
/** Browser history state shape used to restore masked render URLs on popstate. */
interface RouterHistoryState {
__r3UIRouter?: {
renderURL: string;
visibleURL: string;
};
userState?: unknown;
}
/** Runs route submissions and exposes React Router-style fetcher state. */
export class Fetcher<data = unknown> extends TypedEventTarget<FetcherEvents> {
/** Stable fetcher registry key. */
readonly key: string;
/** Current fetcher lifecycle state. */
state: FetcherState = "idle";
/** Most recent action result. */
data: data | undefined;
/** Most recent submitted form data. */
formData: FormData | undefined;
/** Most recent request sent through this fetcher. */
request: Request | undefined;
#controller: AbortController | undefined;
#executor: FetcherExecutor;
/**
* Creates a fetcher bound to one router executor.
*
* @param key Stable fetcher key used for sharing named fetchers.
* @param executor Router execution hooks used by the fetcher.
*/
constructor(key: string, executor: FetcherExecutor) {
super();
this.key = key;
this.#executor = executor;
}
/** Loads a GET route action without changing the current URL. */
async load(href: RouterInput): Promise<void> {
let request = new Request(resolveURL(href, this.#executor.baseURL), {
method: "GET",
});
await this.#run(request, false, "loading");
}
/** Submits data through the matching route action. */
async submit(target: SubmitTarget, options: SubmitOptions = {}): Promise<void> {
let submission = createSubmission(target, options, this.#executor);
let method = submission.request.method.toUpperCase();
this.formData = submission.formData;
await this.#run(submission.request, options.revalidate ?? method !== "GET", "submitting");
}
/** Returns a mixin that submits forms through this fetcher. */
form(options: FormMixinOptions = {}): MixinDescriptor<HTMLFormElement> {
return fetcherFormMixin(this, options);
}
/** Aborts active work and returns to idle state. */
dispose() {
this.#controller?.abort();
this.#controller = undefined;
this.state = "idle";
this.dispatchEvent(new Event("change"));
}
async #run(request: Request, revalidate: boolean, initialState: FetcherState): Promise<void> {
this.#controller?.abort();
this.#controller = new AbortController();
this.request = request;
this.state = initialState;
this.dispatchEvent(new Event("change"));
let result = await this.#executor.run(
cloneRequestWithSignal(request, this.#controller.signal),
this.#controller.signal,
);
this.data = result as data;
if (isRedirectResponse(result)) {
this.state = "loading";
this.dispatchEvent(new Event("change"));
await this.#executor.navigate(result.headers.get("Location")!);
this.state = "idle";
this.dispatchEvent(new Event("change"));
return;
}
if (revalidate) {
this.state = "loading";
this.dispatchEvent(new Event("change"));
await this.#executor.revalidate();
}
this.state = "idle";
this.dispatchEvent(new Event("change"));
}
}
/**
* Provides the current router and route context to Remix UI descendants.
*
* @param handle Component handle carrying the provider value and children.
* @returns The current route UI after refreshing the context value.
*/
export function RouterProvider(handle: Handle<RouterProviderProps, RouterProviderValue>) {
return () => {
handle.context.set(handle.props.value);
return handle.props.children ?? null;
};
}
/**
* Create a client-side router for Remix UI route rendering.
*
* @param options Matching, rendering, and mounting options.
* @returns A router that maps Remix route definitions to UI handlers.
*/
export function createRouter(options: RouterOptions = {}): UIRouter {
let matcher = createMultiMatcher<RouteEntry>();
let mountedRoots = new Set<MountedRoot>();
let fetchers = new Map<string, Fetcher>();
let baseURL = normalizeBaseURL(options.baseURL, getRouterWindow(options)?.location.href);
let createRoot = options.createRoot ?? createRemixRoot;
let interceptLinks = options.interceptLinks ?? true;
let lastRenderURL: URL | undefined;
let fetcherId = 0;
let executor: FetcherExecutor = {
baseURL,
getLocation() {
return getCurrentLocation(options, baseURL);
},
run(request, signal) {
return runRequest(request, signal);
},
navigate(to, navigationOptions) {
return router.navigate(to, navigationOptions);
},
revalidate() {
return router.revalidate();
},
};
let router: UIRouter = {
map(target: RouteTarget | RouteMap, handler: Action | UIControllerInput<RouteMap>) {
if (isRouteTarget(target)) {
registerRoute(matcher, target, handler as Action);
return router;
}
let actions = getControllerActions(handler);
let controllerMiddleware = getControllerMiddleware(handler);
for (let name in target) {
let route = target[name];
let routeHandler = actions[name];
if (route instanceof Route && isAction(routeHandler)) {
registerRoute(matcher, route, routeHandler, controllerMiddleware);
}
}
return router;
},
match(input) {
let url = resolveURL(input ?? getCurrentLocation(options, baseURL), baseURL);
let match = matcher.match(url);
if (!match) return null;
return {
url: match.url,
route: match.data.route,
params: match.params,
};
},
render(input) {
let url = resolveURL(input ?? getCurrentLocation(options, baseURL), baseURL);
return renderURL(url, new AbortController().signal);
},
async navigate(to, navigationOptions) {
let url = resolveURL(to, baseURL);
let visibleURL = navigationOptions?.mask ? resolveURL(navigationOptions.mask, baseURL) : url;
let routerWindow = getRouterWindow(options);
if (routerWindow && visibleURL.origin === routerWindow.location.origin) {
if (routerWindow.navigation) {
let state = createNavigationState(navigationOptions?.state, url, visibleURL);
let result = routerWindow.navigation.navigate(visibleURL.href, {
history: navigationOptions?.replace ? "replace" : "push",
state,
});
await result.finished;
return;
}
let state = createHistoryState(navigationOptions?.state, url, visibleURL);
if (navigationOptions?.replace) {
routerWindow.history.replaceState(state, "", visibleURL);
} else {
routerWindow.history.pushState(state, "", visibleURL);
}
}
await renderMountedRoots(url);
},
async submit<data = unknown>(target: SubmitTarget, submitOptions: SubmitOptions = {}) {
let fetcher = new Fetcher<data>(createFetcherKey("router", fetcherId++), executor);
await fetcher.submit(target, { ...submitOptions, revalidate: false });
if (!fetcher.request) return fetcher.data;
if (isRedirectResponse(fetcher.data)) return fetcher.data;
let url = new URL(fetcher.request.url);
let currentURL = lastRenderURL ?? resolveURL(getCurrentLocation(options, baseURL), baseURL);
if (url.href === currentURL.href) {
await router.revalidate();
} else {
await router.navigate(url);
}
return fetcher.data;
},
getFetcher<data = unknown>(name?: string) {
let key = name ?? createFetcherKey("fetcher", fetcherId++);
let fetcher = fetchers.get(key);
if (!fetcher) {
fetcher = new Fetcher(key, executor);
fetchers.set(key, fetcher);
}
return fetcher as Fetcher<data>;
},
revalidate() {
return renderMountedRoots(lastRenderURL ?? getCurrentLocation(options, baseURL)).then(
() => undefined,
);
},
form(formOptions) {
return routerFormMixin(router, formOptions ?? {});
},
mount(container) {
let root = createRoot(container, createRootOptions());
let routerWindow = getRouterWindow(options);
let activeController = new AbortController();
let renderVersion = 0;
let mounted: MountedRoot = {
async render(input) {
activeController.abort();
activeController = new AbortController();
let version = ++renderVersion;
let signal = activeController.signal;
let node = await renderURL(input ?? getCurrentLocation(options, baseURL), signal);
if (!signal.aborted && version === renderVersion) {
root.render(node);
}
return node;
},
navigate(to, navigationOptions) {
return router.navigate(to, navigationOptions);
},
flush() {
root.flush();
},
dispose() {
renderVersion++;
activeController.abort();
mountedRoots.delete(mounted);
if (routerWindow?.navigation) {
routerWindow.navigation.removeEventListener("navigate", handleNavigation);
} else if (routerWindow) {
routerWindow.removeEventListener("popstate", handlePopState);
}
if (interceptLinks) {
container.removeEventListener("click", handleClick);
}
root.dispose();
},
};
function handlePopState(event: PopStateEvent) {
void mounted.render(getHistoryRenderURL(event.state) ?? undefined);
}
function handleNavigation(event: Event) {
if (!isRouterNavigationEvent(event)) return;
let url = resolveURL(event.destination.url, baseURL);
if (!routerWindow || url.origin !== routerWindow.location.origin) return;
let state = readRouterState(event.destination.getState());
let shouldIntercept =
interceptLinks || event.navigationType === "traverse" || Boolean(state);
if (!shouldIntercept || event.canIntercept === false) return;
event.intercept({
async handler() {
await renderMountedRoots(state?.__r3UIRouter.renderURL ?? url);
},
});
}
function handleClick(event: MouseEvent) {
let link = getNavigableLink(event, routerWindow);
if (!link) return;
event.preventDefault();
void router.navigate(link.href);
}
mountedRoots.add(mounted);
if (routerWindow?.navigation) {
routerWindow.navigation.addEventListener("navigate", handleNavigation);
} else if (routerWindow) {
routerWindow.addEventListener("popstate", handlePopState);
}
if (interceptLinks && !routerWindow?.navigation) {
container.addEventListener("click", handleClick);
}
void mounted.render();
return mounted;
},
};
async function renderURL(input: RouterInput, signal: AbortSignal): Promise<RemixNode> {
let url = resolveURL(input, baseURL);
lastRenderURL = url;
let request = createRequest(url, "GET", signal);
let match = matchRequest(request);
if (!match) {
let context = createContext<NotFoundContext>({
request,
url,
method: request.method,
signal,
navigate: router.navigate,
submit: router.submit,
revalidate: router.revalidate,
getFetcher: router.getFetcher,
});
let node =
(await runMiddleware(options.middleware ?? [], context as Context, async () => {
return (await options.defaultElement?.(context)) ?? null;
})) ?? null;
return createElement(
RouterProvider,
{
value: createRouterProviderValue(router, context, null),
},
node,
);
}
let routeMatch: RouteMatch = {
url: match.url,
route: match.data.route,
params: match.params,
};
let context = createContext({
request,
url: routeMatch.url,
method: request.method,
params: routeMatch.params,
route: match.data.route,
signal,
navigate: router.navigate,
submit: router.submit,
revalidate: router.revalidate,
getFetcher: router.getFetcher,
} as Context);
let node = (await runRouteAction(context, match.data)) as RemixNode;
return createElement(
RouterProvider,
{
value: createRouterProviderValue(router, context, routeMatch),
},
node,
);
}
function renderMountedRoots(input: RouterInput): Promise<Array<RemixNode>> {
return Promise.all(Array.from(mountedRoots, (mountedRoot) => mountedRoot.render(input)));
}
function matchRequest(request: Request) {
return (
matcher
.matchAll(request.url)
.find((match) => routeMatchesMethod(match.data.method, request.method)) ?? null
);
}
async function runRequest(request: Request, signal: AbortSignal): Promise<unknown> {
let match = matchRequest(request);
if (!match) return null;
let context = createContext({
request,
url: match.url,
method: request.method,
params: match.params,
route: match.data.route,
signal,
navigate: router.navigate,
submit: router.submit,
revalidate: router.revalidate,
getFetcher: router.getFetcher,
} as Context);
return runRouteAction(context, match.data);
}
function runRouteAction(context: Context, entry: RouteEntry): Promise<unknown> {
return runMiddleware([...(options.middleware ?? []), ...entry.middleware], context, () =>
entry.handler(context),
);
}
function createRootOptions(): VirtualRootOptions {
let frameInit = options.rootOptions?.frameInit;
return {
...options.rootOptions,
frameInit: {
...frameInit,
src: frameInit?.src ?? resolveURL(getCurrentLocation(options, baseURL), baseURL).href,
resolveFrame(src, signal, target) {
if (frameInit?.resolveFrame) return frameInit.resolveFrame(src, signal, target);
return renderURL(src, signal ?? new AbortController().signal);
},
},
};
}
return router;
}
/** Creates the value exposed through `RouterProvider` for the current render. */
function createRouterProviderValue(
router: UIRouter,
context: Context | NotFoundContext,
match: RouteMatch | null,
): RouterProviderValue {
return {
...router,
context,
match,
url: context.url,
params: match?.params ?? {},
route: match?.route,
};
}
/** Stores the unmasked render URL only when it differs from the visible URL. */
function createHistoryState(userState: unknown, renderURL: URL, visibleURL: URL): unknown {
if (renderURL.href === visibleURL.href) return userState;
return {
__r3UIRouter: {
renderURL: renderURL.href,
visibleURL: visibleURL.href,
},
userState,
} satisfies RouterHistoryState;
}
/** Stores router navigation metadata so Navigation API events can render masked URLs. */
function createNavigationState(
userState: unknown,
renderURL: URL,
visibleURL: URL,
): RouterHistoryState {
return {
__r3UIRouter: {
renderURL: renderURL.href,
visibleURL: visibleURL.href,
},
userState,
};
}
/** Reads the unmasked render URL from a popstate event when available. */
function getHistoryRenderURL(state: unknown): string | undefined {
if (!isRouterHistoryState(state)) return undefined;
return state.__r3UIRouter?.renderURL;
}
/** Reads router metadata from Navigation API destination state. */
function readRouterState(state: unknown): RouterHistoryState | undefined {
if (!isRouterHistoryState(state)) return undefined;
return state;
}
/** Creates a stable fetcher key with an incrementing suffix. */
function createFetcherKey(prefix: string, id: number): string {
return `${prefix}:${id}`;
}
/** Checks whether a route entry can handle an HTTP method. */
function routeMatchesMethod(routeMethod: string, requestMethod: string): boolean {
return routeMethod === "ANY" || routeMethod.toUpperCase() === requestMethod.toUpperCase();
}
/** Creates a GET request for route rendering. */
function createRequest(url: URL, method: string, signal: AbortSignal): Request {
return new Request(url, { method, signal });
}
/** Copies a request while replacing its abort signal. */
function cloneRequestWithSignal(request: Request, signal: AbortSignal): Request {
return new Request(request, { signal });
}
/** Checks whether an action result is a redirect response. */
function isRedirectResponse(value: unknown): value is Response {
return (
value instanceof Response &&
value.status >= 300 &&
value.status < 400 &&
value.headers.has("Location")
);
}
/** Returns the direct route actions from supported controller shapes. */
function getControllerActions(controller: unknown): Record<string, unknown> {
if (isControllerObject(controller)) return controller.actions;
return controller as Record<string, unknown>;
}
/** Returns controller-level middleware from supported controller shapes. */
function getControllerMiddleware(controller: unknown): readonly Middleware[] {
if (!isControllerObject(controller)) return [];
return controller.middleware ?? [];
}
/** Checks for the fetch-router-style controller shape. */
function isControllerObject(value: unknown): value is {
middleware?: readonly Middleware[];
actions: Record<string, unknown>;
} {
if (!value || typeof value !== "object") return false;
if (!("actions" in value)) return false;
let actions = value.actions;
return Boolean(actions) && typeof actions === "object";
}
/** Checks whether a value can be registered as a route action. */
function isAction(value: unknown): value is Action {
return typeof value === "function" || isActionObject(value);
}
/** Checks whether a value is an action object with inline middleware. */
function isActionObject(value: unknown): value is ActionObject {
if (!value || typeof value !== "object") return false;
if (!("handler" in value)) return false;
return typeof value.handler === "function";
}
/** Normalizes plain handlers and action objects into one route entry shape. */
function normalizeAction<route extends RouteTarget>(
action: Action<route>,
controllerMiddleware: readonly Middleware[],
): { handler: ViewHandler<route>; middleware: readonly Middleware[] } {
if (isActionObject(action)) {
return {
handler: action.handler as ViewHandler<route>,
middleware: [...controllerMiddleware, ...(action.middleware ?? [])],
};
}
return {
handler: action,
middleware: controllerMiddleware,
};
}
/** Adds request-scoped context value storage to a context object. */
function createContext<context extends Context | NotFoundContext>(
context: Omit<context, "get" | "has" | "set">,
): context {
let values = new Map<ContextKey<unknown>, unknown>();
let extended = context as context;
extended.get = function get<key extends ContextKey<unknown>>(key: key) {
if (values.has(key)) return values.get(key) as ContextValue<key>;
return key.defaultValue as ContextValue<key> | undefined;
};
extended.has = function has<key extends ContextKey<unknown>>(key: key) {
return values.has(key) || "defaultValue" in key;
};
extended.set = function set<key extends ContextKey<unknown>>(key: key, value: ContextValue<key>) {
values.set(key, value);
};
return extended;
}
/** Runs middleware in order and falls through when middleware returns undefined. */
async function runMiddleware(
middleware: readonly Middleware[],
context: Context,
handler: () => Awaitable<unknown>,
): Promise<unknown> {
let index = -1;
async function dispatch(nextIndex: number): Promise<unknown> {
if (nextIndex <= index) throw new Error("Middleware next() called multiple times.");
index = nextIndex;
let current = middleware[nextIndex];
if (!current) return handler();
let nextCalled = false;
let nextResult: unknown;
let result = await current(context, async () => {
nextCalled = true;
nextResult = await dispatch(nextIndex + 1);
return nextResult;
});
if (result !== undefined) return result;
if (nextCalled) return nextResult;
return dispatch(nextIndex + 1);
}
return dispatch(0);
}
/** Mixin that submits forms through router navigation submissions. */
const routerFormMixin = createMixin<HTMLFormElement, [router: UIRouter, options: FormMixinOptions]>(
(handle) => {
let currentRouter: UIRouter | undefined;
let currentOptions: FormMixinOptions = {};
let currentNode: HTMLFormElement | undefined;
let handleSubmit = (submitEvent: SubmitEvent) => {
let event = submitEvent;
if (!currentRouter || !shouldHandleFormSubmit(event)) return;
event.preventDefault();
void currentRouter.submit(event.currentTarget as HTMLFormElement, {
submitter: event.submitter,
revalidate: currentOptions.revalidate,
});
};
handle.addEventListener("insert", (event) => {
currentNode = event.node;
currentNode.addEventListener("submit", handleSubmit);
});
handle.addEventListener("remove", () => {
currentNode?.removeEventListener("submit", handleSubmit);
currentNode = undefined;
});
return (router, options) => {
currentRouter = router;
currentOptions = options;
return handle.element;
};
},
);
/** Mixin that submits forms through one fetcher. */
const fetcherFormMixin = createMixin<
HTMLFormElement,
[fetcher: Fetcher, options: FormMixinOptions]
>((handle) => {
let currentFetcher: Fetcher | undefined;
let currentOptions: FormMixinOptions = {};
let currentNode: HTMLFormElement | undefined;
let handleSubmit = (submitEvent: SubmitEvent) => {
let event = submitEvent;
if (!currentFetcher || !shouldHandleFormSubmit(event)) return;
event.preventDefault();
void currentFetcher.submit(event.currentTarget as HTMLFormElement, {
submitter: event.submitter,
revalidate: currentOptions.revalidate,
});
};
handle.addEventListener("insert", (event) => {
currentNode = event.node;
currentNode.addEventListener("submit", handleSubmit);
});
handle.addEventListener("remove", () => {
currentNode?.removeEventListener("submit", handleSubmit);
currentNode = undefined;
});
return (fetcher, options) => {
currentFetcher = fetcher;
currentOptions = options;
return handle.element;
};
});
/** Checks whether browser history state contains router masking metadata. */
function isRouterHistoryState(state: unknown): state is RouterHistoryState {
if (!state || typeof state !== "object") return false;
if (!("__r3UIRouter" in state)) return false;
let routerState = state.__r3UIRouter;
if (!routerState || typeof routerState !== "object") return false;
if (!("renderURL" in routerState)) return false;
return typeof routerState.renderURL === "string";
}
/** Checks whether a browser event is a Navigation API event. */
function isRouterNavigationEvent(event: Event): event is RouterNavigationEvent {
if (!("destination" in event)) return false;
if (!("intercept" in event)) return false;
let destination = (event as { destination: unknown }).destination;
if (!destination || typeof destination !== "object") return false;
if (!("url" in destination) || !("getState" in destination)) return false;
return typeof destination.url === "string" && typeof destination.getState === "function";
}
/** Normalized submission produced from a form, submitter, or imperative target. */
interface Submission {
request: Request;
formData: FormData | undefined;
}
/** Creates a request from a form target or imperative submit data. */
function createSubmission(
target: SubmitTarget,
options: SubmitOptions,
executor: FetcherExecutor,
): Submission {
let form = getTargetForm(target);
let submitter = options.submitter ?? (isSubmitter(target) ? target : null);
let action = getSubmissionAction(form, submitter, options, executor);
let method = getSubmissionMethod(form, submitter, options);
let encType = getSubmissionEncType(form, submitter, options);
let formData = createSubmissionFormData(target, submitter);
let bodyMethod = method;
if (formData) bodyMethod = getMethodOverride(formData) ?? method;
let url = resolveURL(action, executor.baseURL);
if (bodyMethod === "GET") {
appendFormData(url, formData);
return {
request: new Request(url, { method: "GET" }),
formData,
};
}
return {
request: new Request(url, {
method: bodyMethod,
headers: createSubmissionHeaders(encType),
body: createSubmissionBody(formData, encType),
}),
formData,
};
}
/** Returns whether a submit event should be handled by the router. */
function shouldHandleFormSubmit(event: SubmitEvent): boolean {
if (event.defaultPrevented) return false;
if (!(event.currentTarget instanceof HTMLFormElement)) return false;
let target = getSubmitterTarget(event.submitter) ?? event.currentTarget.target;
return !target || target === "_self";
}
/** Finds the form associated with a submit target. */
function getTargetForm(target: SubmitTarget): HTMLFormElement | null {
if (typeof HTMLFormElement !== "undefined" && target instanceof HTMLFormElement) return target;
if (isSubmitter(target)) return target.form;
return null;
}
/** Checks whether a target is a button/input submitter. */
function isSubmitter(target: unknown): target is HTMLButtonElement | HTMLInputElement {
if (typeof HTMLButtonElement !== "undefined" && target instanceof HTMLButtonElement) return true;
if (typeof HTMLInputElement !== "undefined" && target instanceof HTMLInputElement) return true;
return false;
}
/** Resolves a submission action URL from overrides, form attributes, or current location. */
function getSubmissionAction(
form: HTMLFormElement | null,
submitter: HTMLElement | null,
options: SubmitOptions,
executor: FetcherExecutor,
): RouterInput {
if (options.action) return options.action;
let submitterAction = getSubmitterAction(submitter);
if (submitterAction) return submitterAction;
if (form?.action) return form.action;
return executor.getLocation();
}
/** Resolves a submission method from overrides, form attributes, or GET. */
function getSubmissionMethod(
form: HTMLFormElement | null,
submitter: HTMLElement | null,
options: SubmitOptions,
): string {
return (options.method ?? getSubmitterMethod(submitter) ?? form?.method ?? "GET").toUpperCase();
}
/** Resolves a submission encoding type from overrides, form attributes, or urlencoded. */
function getSubmissionEncType(
form: HTMLFormElement | null,
submitter: HTMLElement | null,
options: SubmitOptions,
): string {
return (
options.encType ??
getSubmitterEncType(submitter) ??
form?.enctype ??
"application/x-www-form-urlencoded"
);
}
/** Creates form data from supported submission targets. */
function createSubmissionFormData(
target: SubmitTarget,
submitter: HTMLElement | null,
): FormData | undefined {
if (target == null) return undefined;
if (target instanceof FormData) return target;
let form = getTargetForm(target);
if (form) return createFormData(form, submitter);
if (target instanceof URLSearchParams) return formDataFromSearchParams(target);
if (typeof target === "object") return formDataFromObject(target as Record<string, unknown>);
return undefined;
}
/** Creates browser FormData while preserving submitter button values. */
function createFormData(form: HTMLFormElement, submitter: HTMLElement | null): FormData {
if (submitter && isFormDataSubmitter(submitter)) return new FormData(form, submitter);
return new FormData(form);
}
/** Checks whether a submitter can be passed to the FormData constructor. */
function isFormDataSubmitter(
submitter: HTMLElement,
): submitter is HTMLButtonElement | HTMLInputElement {
return isSubmitter(submitter);
}
/** Converts URLSearchParams to FormData. */
function formDataFromSearchParams(searchParams: URLSearchParams): FormData {
let formData = new FormData();
for (let [name, value] of searchParams) formData.append(name, value);
return formData;
}
/** Converts plain object submit data to FormData. */
function formDataFromObject(object: Record<string, unknown>): FormData {
let formData = new FormData();
for (let name in object) appendFormValue(formData, name, object[name]);
return formData;
}
/** Appends one object field to FormData. */
function appendFormValue(formData: FormData, name: string, value: unknown) {
if (value == null) return;
if (Array.isArray(value)) {
for (let item of value) appendFormValue(formData, name, item);
return;
}
if (value instanceof Blob) {
formData.append(name, value);
return;
}
formData.append(name, String(value));
}
/** Applies FormData fields to a GET submission URL. */
function appendFormData(url: URL, formData: FormData | undefined) {
if (!formData) return;
for (let [name, value] of formData) {
url.searchParams.append(name, typeof value === "string" ? value : value.name);
}
}
/** Creates request headers for one form encoding type. */
function createSubmissionHeaders(encType: string): Headers | undefined {
if (encType === "application/x-www-form-urlencoded") {
return new Headers({ "Content-Type": encType });
}
return undefined;
}
/** Creates the request body for one form encoding type. */
function createSubmissionBody(formData: FormData | undefined, encType: string): BodyInit | null {
if (!formData) return null;
if (encType === "application/x-www-form-urlencoded")
return new URLSearchParams(formData as never);
return formData;
}
/** Reads a method override value from form data. */
function getMethodOverride(formData: FormData): string | undefined {
let method = formData.get("_method");
if (typeof method !== "string" || !method) return undefined;
return method.toUpperCase();
}
/** Reads submitter action override attributes. */
function getSubmitterAction(submitter: HTMLElement | null): string | undefined {
if (!submitter?.hasAttribute("formaction") || !("formAction" in submitter)) return undefined;
let action = submitter.formAction;
return typeof action === "string" && action ? action : undefined;
}
/** Reads submitter method override attributes. */
function getSubmitterMethod(submitter: HTMLElement | null): string | undefined {
if (!submitter?.hasAttribute("formmethod") || !("formMethod" in submitter)) return undefined;
let method = submitter.formMethod;
return typeof method === "string" && method ? method : undefined;
}
/** Reads submitter encoding override attributes. */
function getSubmitterEncType(submitter: HTMLElement | null): string | undefined {
if (!submitter?.hasAttribute("formenctype") || !("formEnctype" in submitter)) return undefined;
let encType = submitter.formEnctype;
return typeof encType === "string" && encType ? encType : undefined;
}
/** Reads submitter target override attributes. */
function getSubmitterTarget(submitter: HTMLElement | null): string | undefined {
if (!submitter?.hasAttribute("formtarget") || !("formTarget" in submitter)) return undefined;
let target = submitter.formTarget;
return typeof target === "string" && target ? target : undefined;
}
/** Registers one route target with the shared route-pattern matcher. */
function registerRoute<route extends RouteTarget>(
matcher: ReturnType<typeof createMultiMatcher<RouteEntry>>,
route: route,
action: Action<route>,
controllerMiddleware: readonly Middleware[] = [],
) {
let normalized = normalizeAction(action, controllerMiddleware);
matcher.add(getRoutePattern(route), {
route,
handler: normalized.handler,
method: getRouteMethod(route),
middleware: normalized.middleware,
});
}
/** Checks whether a value can be registered as a single route target. */
function isRouteTarget(value: unknown): value is RouteTarget {
return typeof value === "string" || value instanceof Route;
}
/** Returns the route-pattern source string used by the matcher. */
function getRoutePattern(route: RouteTarget): string {
if (route instanceof Route) return route.pattern.toString();
return route;
}
/** Returns the HTTP method declared by a route target. */
function getRouteMethod(route: RouteTarget): string {
if (route instanceof Route) return String(route.method).toUpperCase();
return "ANY";
}
/** Resolves the current location from explicit options, browser state, or the base URL. */
function getCurrentLocation(options: RouterOptions, baseURL: URL): RouterInput {
if (options.getLocation) return options.getLocation();
let routerWindow = getRouterWindow(options);
if (routerWindow) return routerWindow.location.href;
return baseURL;
}
/** Returns the configured browser adapter or the global browser window. */
function getRouterWindow(options: RouterOptions): RouterWindow | undefined {
if (options.window) return options.window;
if (typeof window !== "undefined") return window;
return undefined;
}
/** Normalizes an optional base URL without surfacing URL parser errors. */
function normalizeBaseURL(baseURL: string | URL | undefined, fallbackURL: string | undefined): URL {
if (baseURL instanceof URL) return baseURL;
let value = baseURL ?? fallbackURL ?? DEFAULT_BASE_URL;
if (URL.canParse(value)) return new URL(value);
return new URL(DEFAULT_BASE_URL);
}
/** Resolves router inputs against the normalized base URL. */
function resolveURL(input: RouterInput, baseURL: URL): URL {
let value = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
let url = value instanceof URL ? value.href : value;
if (URL.canParse(url, baseURL)) return new URL(url, baseURL);
return new URL(baseURL);
}
/** Returns a same-origin anchor that should be handled by client navigation. */
function getNavigableLink(
event: MouseEvent,
routerWindow: RouterWindow | undefined,
): HTMLAnchorElement | null {
if (!routerWindow) return null;
if (event.defaultPrevented || event.button !== 0) return null;
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return null;
if (typeof Element === "undefined") return null;
if (!(event.target instanceof Element)) return null;
let anchor = event.target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return null;
if (anchor.target && anchor.target !== "_self") return null;
if (anchor.hasAttribute("download")) return null;
if (!URL.canParse(anchor.href, routerWindow.location.href)) return null;
let url = new URL(anchor.href, routerWindow.location.href);
if (url.origin !== routerWindow.location.origin) return null;
return anchor;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment