+}
diff --git a/packages/octane-router/src/CatchBoundary.tsrx.d.ts b/packages/octane-router/src/CatchBoundary.tsrx.d.ts
new file mode 100644
index 0000000000..39eef3cfa2
--- /dev/null
+++ b/packages/octane-router/src/CatchBoundary.tsrx.d.ts
@@ -0,0 +1,13 @@
+import type { ComponentBody } from 'octane'
+import type { ErrorComponentProps } from '@tanstack/router-core'
+import type { ErrorRouteComponent } from './frameworkTypes'
+
+export interface CatchBoundaryProps {
+ getResetKey: () => number | string
+ errorComponent?: ErrorRouteComponent
+ onCatch?: (error: Error, errorInfo: { componentStack: string }) => void
+ children?: unknown
+}
+
+export declare const CatchBoundary: ComponentBody
+export declare const ErrorComponent: ComponentBody>
diff --git a/packages/octane-router/src/ClientOnly.tsrx b/packages/octane-router/src/ClientOnly.tsrx
new file mode 100644
index 0000000000..0f9d781afe
--- /dev/null
+++ b/packages/octane-router/src/ClientOnly.tsrx
@@ -0,0 +1,28 @@
+// ClientOnly / useHydrated — port of react-router's ClientOnly.tsx. Renders
+// children only once the client has hydrated: `useHydrated` reads a constant
+// external store whose server snapshot is `false` and client snapshot is
+// `true`, so SSR (and the hydration render) yield the fallback and the first
+// client-only render onward yields the children. Authored in .tsrx so the
+// compiler slots the hook calls (and withSlot-disambiguates useHydrated's
+// call sites).
+import { useSyncExternalStore } from 'octane';
+import { splitSlot, subSlot } from './internal.ts';
+
+const subscribe = () => () => {};
+
+export function useHydrated(...args) {
+ const [, slot] = splitSlot(args);
+ return useSyncExternalStore(
+ subscribe,
+ () => true,
+ () => false,
+ subSlot(slot, 'hydrated'),
+ );
+}
+
+export function ClientOnly(props) @{
+ const hydrated = useHydrated();
+ <>
+ {hydrated ? props.children : props.fallback ?? null}
+ >
+}
diff --git a/packages/octane-router/src/ClientOnly.tsrx.d.ts b/packages/octane-router/src/ClientOnly.tsrx.d.ts
new file mode 100644
index 0000000000..5e36216113
--- /dev/null
+++ b/packages/octane-router/src/ClientOnly.tsrx.d.ts
@@ -0,0 +1,7 @@
+import type { ComponentBody } from 'octane'
+
+export declare const ClientOnly: ComponentBody<{
+ children?: unknown
+ fallback?: unknown
+}>
+export declare const useHydrated: () => boolean
diff --git a/packages/octane-router/src/Head.ts b/packages/octane-router/src/Head.ts
new file mode 100644
index 0000000000..f48fe4e6ec
--- /dev/null
+++ b/packages/octane-router/src/Head.ts
@@ -0,0 +1,21 @@
+import { createElement, createPortal } from 'octane'
+import { isServer } from '@tanstack/router-core/isServer'
+import { useRouter } from './context'
+import type { ComponentBody } from 'octane'
+
+export interface HeadProps {
+ children?: unknown
+ [key: string]: unknown
+}
+
+export const Head: ComponentBody = (props) => {
+ const router = useRouter()
+ const { children, ...attrs } = props
+ if (isServer ?? router.isServer) {
+ return createElement('head', attrs, children)
+ }
+ if (typeof document !== 'undefined') {
+ return createPortal(children, document.head)
+ }
+ return null
+}
diff --git a/packages/octane-router/src/HeadContent.tsrx b/packages/octane-router/src/HeadContent.tsrx
new file mode 100644
index 0000000000..7fb0704a1c
--- /dev/null
+++ b/packages/octane-router/src/HeadContent.tsrx
@@ -0,0 +1,37 @@
+import { createElement, useEffect } from 'octane';
+import { DEV_STYLES_ATTR } from '@tanstack/router-core';
+import { Asset } from './Asset.tsrx';
+import { getAssetKey } from './assetKeys.ts';
+import { useTags } from './headContentUtils.ts';
+import { useHydrated } from './ClientOnly.tsrx';
+
+export function HeadContent(props) @{
+ const tags = useTags(props.assetCrossOrigin);
+ const hydrated = useHydrated();
+
+ useEffect(() => {
+ if (process.env.NODE_ENV === 'production' || !hydrated) {
+ return;
+ }
+ document
+ .querySelectorAll(`link[${DEV_STYLES_ATTR}]`)
+ .forEach((element) => element.remove());
+ }, [hydrated]);
+
+ const filteredTags =
+ process.env.NODE_ENV !== 'production' && hydrated
+ ? tags.filter(
+ (tag) => tag.tag !== 'link' || tag.attrs?.[DEV_STYLES_ATTR] !== true,
+ )
+ : tags;
+ <>
+ {filteredTags.map((tag, index) =>
+ createElement(Asset, {
+ ...tag,
+ assetKey: getAssetKey('head', tag, index),
+ target: 'head',
+ key: getAssetKey('head', tag, index),
+ }),
+ )}
+ >
+}
diff --git a/packages/octane-router/src/HeadContent.tsrx.d.ts b/packages/octane-router/src/HeadContent.tsrx.d.ts
new file mode 100644
index 0000000000..9fae8ed142
--- /dev/null
+++ b/packages/octane-router/src/HeadContent.tsrx.d.ts
@@ -0,0 +1,8 @@
+import type { ComponentBody } from 'octane'
+import type { AssetCrossOriginConfig } from '@tanstack/router-core'
+
+export interface HeadContentProps {
+ assetCrossOrigin?: AssetCrossOriginConfig
+}
+
+export declare const HeadContent: ComponentBody
diff --git a/packages/octane-router/src/Html.ts b/packages/octane-router/src/Html.ts
new file mode 100644
index 0000000000..6966b40d9e
--- /dev/null
+++ b/packages/octane-router/src/Html.ts
@@ -0,0 +1,18 @@
+import { createElement } from 'octane'
+import { isServer } from '@tanstack/router-core/isServer'
+import { useRouter } from './context'
+import type { ComponentBody } from 'octane'
+
+export interface HtmlProps {
+ children?: unknown
+ [key: string]: unknown
+}
+
+export const Html: ComponentBody = (props) => {
+ const router = useRouter()
+ const { children, ...attrs } = props
+ if (isServer ?? router.isServer) {
+ return createElement('html', attrs, children)
+ }
+ return children
+}
diff --git a/packages/octane-router/src/Link.tsrx b/packages/octane-router/src/Link.tsrx
new file mode 100644
index 0000000000..963c74d481
--- /dev/null
+++ b/packages/octane-router/src/Link.tsrx
@@ -0,0 +1,32 @@
+// A navigating anchor — a thin shell over `useLinkProps` (link.ts), which
+// carries ALL of react-router Link's behavior: href building (mask-aware),
+// external-URL + dangerous-protocol handling, active-state detection,
+// preloading (intent/viewport/render), and the navigate click handler with
+// replace/resetScroll/hashScrollIntoView/viewTransition/startTransition/
+// ignoreBlocker forwarded. Children may be a render prop —
+// `{({ isActive, isTransitioning }) => …}` — and `createLink` renders a custom
+// component via the internal `_asChild` prop. Ref is a prop (octane's model —
+// no forwardRef); `disabled` renders without an href + role="link" (upstream
+// drops the anchor's disabled attribute; the aria props carry the state).
+import { isChildrenBlock } from 'octane';
+import { useLinkProps } from './link.ts';
+
+export function Link(props) @{
+ const { _asChild: AsChild, children, ...rest } = props;
+ const linkProps = useLinkProps(rest);
+ const { disabled: _disabled, ...aProps } = linkProps;
+
+ const resolvedChildren =
+ typeof children === 'function' && !isChildrenBlock(children)
+ ? children({
+ isActive: linkProps['data-status'] === 'active',
+ isTransitioning: linkProps['data-transitioning'] === 'transitioning',
+ })
+ : children;
+
+ @if (AsChild) {
+ {resolvedChildren}
+ } @else {
+ {resolvedChildren}
+ }
+}
diff --git a/packages/octane-router/src/Link.tsrx.d.ts b/packages/octane-router/src/Link.tsrx.d.ts
new file mode 100644
index 0000000000..8afc4d1284
--- /dev/null
+++ b/packages/octane-router/src/Link.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { LinkComponent } from './linkTypes'
+
+export declare const Link: LinkComponent<'a'>
diff --git a/packages/octane-router/src/Match.tsrx b/packages/octane-router/src/Match.tsrx
new file mode 100644
index 0000000000..e54bf3deb4
--- /dev/null
+++ b/packages/octane-router/src/Match.tsrx
@@ -0,0 +1,236 @@
+// Renders one route match — port of react-router's Match.tsx (MatchView +
+// MatchInner + OnRendered). `Match` subscribes to the match's routeId and the
+// router's `loadedAt` (the error-boundary reset key), resolves the route's
+// boundary components, and composes the pipeline exactly like upstream:
+//
+// Shell > matchContext > Suspense? > CatchBoundary? > CatchNotFound? > MatchInner
+//
+// Each boundary is only present when the route (or router defaults) configured a
+// component for it — otherwise `SafeFragment` passes through so suspensions and
+// errors bubble to the nearest ancestor boundary (upstream's
+// ResolvedSuspenseBoundary/ResolvedCatchBoundary/ResolvedNotFoundBoundary).
+// `MatchInner` handles the match STATUS: it suspends (octane `use`, upstream
+// `throw promise`) on pending/redirected/_displayPending/_forcePending, renders
+// the route's not-found UI for `status === 'notFound'`, throws to the
+// CatchBoundary for `status === 'error'`, and otherwise renders the route
+// component (or `` for component-less layout routes) keyed by
+// remountDeps. `OnRendered` (rendered by the match directly below the root)
+// emits the router's `onRendered` event after the subtree commits — scroll
+// restoration restores on it.
+import { useRef, useLayoutEffect, use, createElement, Suspense } from 'octane';
+import {
+ createControlledPromise,
+ getLocationChangeInfo,
+ isNotFound,
+ rootRouteId,
+} from '@tanstack/router-core';
+import { useStore } from './useStore.ts';
+import { useRouter, matchContext } from './context.ts';
+import { Outlet } from './Outlet.tsrx';
+import { RouteNotFound } from './RouteNotFound.tsrx';
+import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
+import { CatchNotFound } from './not-found.tsrx';
+import { SafeFragment } from './SafeFragment.tsrx';
+import { ClientOnly } from './ClientOnly.tsrx';
+import { ScrollRestorationScript } from './scroll-restoration.tsrx';
+
+export function Match(props) @{
+ const router = useRouter();
+ const matchStore = router.stores.matchStores.get(props.matchId);
+ const resetKey = useStore(router.stores.loadedAt, (l) => l);
+ const matchState = useStore(
+ matchStore,
+ (m) => ({
+ routeId: m.routeId,
+ ssr: m.ssr,
+ _displayPending: m._displayPending,
+ }),
+ (previous, next) =>
+ previous.routeId === next.routeId &&
+ previous.ssr === next.ssr &&
+ previous._displayPending === next._displayPending,
+ );
+ const routeId = matchState.routeId;
+ const route = router.routesById[routeId];
+
+ const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent;
+ const routeErrorComponent = route.options.errorComponent ?? router.options.defaultErrorComponent;
+ const routeOnCatch = route.options.onCatch ?? router.options.defaultOnCatch;
+ const routeNotFoundComponent = route.isRoot
+ ? route.options.notFoundComponent ?? router.options.notFoundRoute?.options.component
+ : route.options.notFoundComponent;
+ const resolvedNoSsr = matchState.ssr === false || matchState.ssr === 'data-only';
+
+ // Boundary presence, per upstream MatchView. The root route only gets a
+ // Suspense boundary when explicitly opted in via wrapInSuspense.
+ const SuspenseWrap =
+ (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) &&
+ (route.options.wrapInSuspense ??
+ PendingComponent ??
+ ((route.options.errorComponent as any)?.preload || resolvedNoSsr))
+ ? Suspense
+ : SafeFragment;
+ const CatchWrap = routeErrorComponent ? CatchBoundary : SafeFragment;
+ const NotFoundWrap = routeNotFoundComponent ? CatchNotFound : SafeFragment;
+ const ShellComponent = route.isRoot ? route.options.shellComponent ?? SafeFragment : SafeFragment;
+
+ const pendingElement =
+ PendingComponent ? createElement(PendingComponent, {}) : null;
+ const parentRouteId = route.parentRoute?.id;
+
+
+
+
+ resetKey}
+ errorComponent={routeErrorComponent || ErrorComponent}
+ onCatch={(error, errorInfo) => {
+ if (isNotFound(error)) {
+ error.routeId ??= routeId;
+ throw error;
+ }
+ if (routeOnCatch) {
+ routeOnCatch(error, errorInfo);
+ }
+ }}
+ >
+ {
+ error.routeId ??= routeId;
+
+ if (
+ !routeNotFoundComponent || error.routeId && error.routeId !== routeId ||
+ !error.routeId && !route.isRoot
+ ) {
+ throw error;
+ }
+ return createElement(routeNotFoundComponent, error);
+ }}
+ >
+ @if (resolvedNoSsr || matchState._displayPending) {
+
+
+
+ } @else {
+
+ }
+
+
+
+
+ @if (parentRouteId === rootRouteId) {
+ <>
+
+ @if (router.options.scrollRestoration) {
+
+ }
+ >
+ }
+
+}
+
+function MatchInner(props) {
+ const router = useRouter();
+ const matchStore = router.stores.matchStores.get(props.matchId);
+ const match = useStore(matchStore, (m) => m);
+ const routeId = match.routeId;
+ const route = router.routesById[routeId];
+
+ // The live match in the router (if still mounted there) wins over the
+ // snapshot for promise lookups, per upstream getMatchPromise.
+ const getMatchPromise = (key) => router.getMatch(match.id)?._nonReactive[key] ??
+ match._nonReactive[key];
+
+ if (match._displayPending) {
+ use(getMatchPromise('displayPendingPromise'));
+ }
+
+ if (match._forcePending) {
+ use(getMatchPromise('minPendingPromise'));
+ }
+
+ if (match.status === 'pending') {
+ // Once pending UI shows, keep it up for at least pendingMinMs.
+ const pendingMinMs = route.options.pendingMinMs ?? router.options.defaultPendingMinMs;
+ if (pendingMinMs) {
+ const routerMatch = router.getMatch(match.id);
+ if (routerMatch && !routerMatch._nonReactive.minPendingPromise) {
+ const minPendingPromise = createControlledPromise();
+ routerMatch._nonReactive.minPendingPromise = minPendingPromise;
+ setTimeout(() => {
+ minPendingPromise.resolve();
+ routerMatch._nonReactive.minPendingPromise = undefined;
+ }, pendingMinMs);
+ }
+ }
+ use(getMatchPromise('loadPromise'));
+ }
+
+ if (match.status === 'notFound') {
+ return createElement(RouteNotFound, { routeId, error: match.error });
+ }
+
+ if (match.status === 'redirected') {
+ // Observed mid-transition while a redirect is in flight — suspend on the
+ // load so this stale render is abandoned and the redirect completes.
+ use(getMatchPromise('loadPromise'));
+ }
+
+ if (match.status === 'error') {
+ throw match.error;
+ }
+
+ const Comp = route.options.component ?? router.options.defaultComponent;
+ const remountFn = route.options.remountDeps ?? router.options.defaultRemountDeps;
+ const remountDeps =
+ remountFn
+ ? remountFn({
+ routeId,
+ loaderDeps: match.loaderDeps,
+ params: match._strictParams,
+ search: match._strictSearch,
+ })
+ : undefined;
+ const key =
+ remountDeps ? JSON.stringify(remountDeps) : undefined;
+ if (Comp) {
+ return createElement(Comp, key !== undefined ? { key } : {});
+ }
+ return createElement(Outlet, {});
+}
+
+// Emits the router's `onRendered` event once the subtree below the root layout
+// has committed (this component renders as a later sibling of the match content,
+// so its layout effect runs after the subtree's). Tracks the previously-resolved
+// location in a ref because by effect time Transitioner has already advanced
+// `resolvedLocation` to the new location.
+function OnRendered() @{
+ const router = useRouter();
+ const prevResolvedLocationRef = useRef(undefined);
+ const renderedLocationKey = useStore(
+ router.stores.resolvedLocation,
+ (loc) => loc?.state.__TSR_key,
+ );
+
+ useLayoutEffect(() => {
+ const currentResolvedLocation = router.stores.resolvedLocation.get();
+ const previousResolvedLocation = prevResolvedLocationRef.current;
+
+ if (
+ currentResolvedLocation &&
+ (!previousResolvedLocation ||
+ previousResolvedLocation.href !== currentResolvedLocation.href)
+ ) {
+ router.emit({
+ type: 'onRendered',
+ ...getLocationChangeInfo(
+ router.stores.location.get(),
+ previousResolvedLocation ?? currentResolvedLocation,
+ ),
+ });
+ }
+ prevResolvedLocationRef.current = currentResolvedLocation;
+ }, [renderedLocationKey, router]);
+
+ <>>
+}
diff --git a/packages/octane-router/src/Match.tsrx.d.ts b/packages/octane-router/src/Match.tsrx.d.ts
new file mode 100644
index 0000000000..994abcbb84
--- /dev/null
+++ b/packages/octane-router/src/Match.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { ComponentBody } from 'octane'
+
+export declare const Match: ComponentBody<{ matchId: string }>
diff --git a/packages/octane-router/src/MatchRoute.tsrx b/packages/octane-router/src/MatchRoute.tsrx
new file mode 100644
index 0000000000..40c28d51c8
--- /dev/null
+++ b/packages/octane-router/src/MatchRoute.tsrx
@@ -0,0 +1,32 @@
+// useMatchRoute / MatchRoute — port of react-router's Matches.tsx matcher pair.
+// The hook subscribes to `matchRouteDeps` (location href + resolved href +
+// status) so the matcher re-evaluates per navigation, and returns
+// `matchRoute(opts)` → false | matched params. The component renders its
+// children when matched — a render-prop child ALWAYS renders, receiving the
+// params (false when unmatched).
+import { useCallback, isChildrenBlock } from 'octane';
+import { useRouter } from './context.ts';
+import { useStore } from './useStore.ts';
+
+export function useMatchRoute() {
+ const router = useRouter();
+ useStore(router.stores.matchRouteDeps, (d) => d);
+ return useCallback((opts) => {
+ const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts;
+ return router.matchRoute(rest, { pending, caseSensitive, fuzzy, includeSearch });
+ }, [router]);
+}
+
+export function MatchRoute(props) @{
+ const matchRoute = useMatchRoute();
+ const params = matchRoute(props);
+ const out =
+ typeof props.children === 'function' && !isChildrenBlock(props.children)
+ ? props.children(params)
+ : params
+ ? props.children
+ : null;
+ <>
+ {out}
+ >
+}
diff --git a/packages/octane-router/src/MatchRoute.tsrx.d.ts b/packages/octane-router/src/MatchRoute.tsrx.d.ts
new file mode 100644
index 0000000000..26a884ba15
--- /dev/null
+++ b/packages/octane-router/src/MatchRoute.tsrx.d.ts
@@ -0,0 +1,66 @@
+import type {
+ AnyRouter,
+ DeepPartial,
+ Expand,
+ MakeOptionalPathParams,
+ MakeOptionalSearchParams,
+ MaskOptions,
+ MatchRouteOptions,
+ RegisteredRouter,
+ ResolveRoute,
+ ToSubOptionsProps,
+} from '@tanstack/router-core'
+
+export type UseMatchRouteOptions<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends string = string,
+ TTo extends string | undefined = undefined,
+ TMaskFrom extends string = TFrom,
+ TMaskTo extends string = '',
+> = ToSubOptionsProps &
+ DeepPartial> &
+ DeepPartial> &
+ MaskOptions &
+ MatchRouteOptions
+
+export type MatchRouteFn = <
+ const TFrom extends string = string,
+ const TTo extends string | undefined = undefined,
+ const TMaskFrom extends string = TFrom,
+ const TMaskTo extends string = '',
+>(
+ opts: UseMatchRouteOptions,
+) => false | Expand['types']['allParams']>
+
+export declare function useMatchRoute<
+ TRouter extends AnyRouter = RegisteredRouter,
+>(): MatchRouteFn
+
+export type MakeMatchRouteOptions<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends string = string,
+ TTo extends string | undefined = undefined,
+ TMaskFrom extends string = TFrom,
+ TMaskTo extends string = '',
+> = UseMatchRouteOptions & {
+ children?:
+ | string
+ | number
+ | boolean
+ | object
+ | null
+ | undefined
+ | ((
+ params?: Expand<
+ ResolveRoute['types']['allParams']
+ >,
+ ) => unknown)
+}
+
+export declare function MatchRoute<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string = string,
+ const TTo extends string | undefined = undefined,
+ const TMaskFrom extends string = TFrom,
+ const TMaskTo extends string = '',
+>(props: MakeMatchRouteOptions): void
diff --git a/packages/octane-router/src/Matches.tsrx b/packages/octane-router/src/Matches.tsrx
new file mode 100644
index 0000000000..2a7f91889a
--- /dev/null
+++ b/packages/octane-router/src/Matches.tsrx
@@ -0,0 +1,60 @@
+// Runs the navigation engine and renders the match tree root — port of
+// react-router's Matches.tsx. Structure mirrors upstream:
+//
+// useTransitioner + InnerWrap? > Suspense(root pending) > MatchesInner
+// MatchesInner: matchContext(firstId) > global CatchBoundary? > Match(firstId)
+//
+// The root Suspense is ALWAYS present on the client (fallback is the root
+// route's pendingComponent ?? defaultPendingComponent ?? null) — it's the
+// already-revealed boundary that lets a navigation transition hold the current
+// page when the next route suspends without a boundary of its own. The global
+// CatchBoundary (opt out: `disableGlobalCatchBoundary`) renders the generic
+// ErrorComponent for errors no route boundary caught, reset by `loadedAt`.
+import { useLayoutEffect, createElement, Suspense } from 'octane';
+import { setupScrollRestoration, rootRouteId } from '@tanstack/router-core';
+import { useStore } from './useStore.ts';
+import { useRouter, matchContext } from './context.ts';
+import { useTransitioner } from './Transitioner.tsrx';
+import { Match } from './Match.tsrx';
+import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
+import { SafeFragment } from './SafeFragment.tsrx';
+
+export function Matches() @{
+ const router = useRouter();
+ const rootRoute = router.routesById[rootRouteId];
+ const PendingComponent =
+ rootRoute.options.pendingComponent ?? router.options.defaultPendingComponent;
+ const pendingElement =
+ PendingComponent ? createElement(PendingComponent, {}) : null;
+ const InnerWrap = router.options.InnerWrap ?? SafeFragment;
+
+ // `createRouter({ scrollRestoration: true })` wires scroll save/restore here, so
+ // it works without the (deprecated) component.
+ useLayoutEffect(() => {
+ if (router.options.scrollRestoration) {
+ setupScrollRestoration(router);
+ }
+ }, [router]);
+ useTransitioner();
+
+
+
+
+
+
+}
+
+function MatchesInner() @{
+ const router = useRouter();
+ const matchId = useStore(router.stores.firstId, (id) => id);
+ const resetKey = useStore(router.stores.loadedAt, (l) => l);
+ const CatchWrap = router.options.disableGlobalCatchBoundary ? SafeFragment : CatchBoundary;
+
+
+ resetKey} errorComponent={ErrorComponent}>
+ @if (matchId) {
+
+ }
+
+
+}
diff --git a/packages/octane-router/src/Matches.tsrx.d.ts b/packages/octane-router/src/Matches.tsrx.d.ts
new file mode 100644
index 0000000000..88286441e4
--- /dev/null
+++ b/packages/octane-router/src/Matches.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { ComponentBody } from 'octane'
+
+export declare const Matches: ComponentBody>
diff --git a/packages/octane-router/src/Navigate.tsrx b/packages/octane-router/src/Navigate.tsrx
new file mode 100644
index 0000000000..784bac5ce2
--- /dev/null
+++ b/packages/octane-router/src/Navigate.tsrx
@@ -0,0 +1,16 @@
+// Imperative navigation as a component: navigates once on mount, renders nothing.
+import { useLayoutEffect, useRef } from 'octane';
+import { useNavigate } from './hooks.ts';
+
+export function Navigate(props) @{
+ const navigate = useNavigate();
+ const done = useRef(false);
+
+ useLayoutEffect(() => {
+ if (done.current) {
+ return;
+ }
+ done.current = true;
+ navigate(props);
+ }, []);
+}
diff --git a/packages/octane-router/src/Navigate.tsrx.d.ts b/packages/octane-router/src/Navigate.tsrx.d.ts
new file mode 100644
index 0000000000..db7be6a588
--- /dev/null
+++ b/packages/octane-router/src/Navigate.tsrx.d.ts
@@ -0,0 +1,13 @@
+import type {
+ AnyRouter,
+ NavigateOptions,
+ RegisteredRouter,
+} from '@tanstack/router-core'
+
+export declare function Navigate<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string = string,
+ const TTo extends string | undefined = undefined,
+ const TMaskFrom extends string = TFrom,
+ const TMaskTo extends string = '',
+>(props: NavigateOptions): void
diff --git a/packages/octane-router/src/Outlet.tsrx b/packages/octane-router/src/Outlet.tsrx
new file mode 100644
index 0000000000..205391a65d
--- /dev/null
+++ b/packages/octane-router/src/Outlet.tsrx
@@ -0,0 +1,49 @@
+// Renders the child match below the current one. Reads the current match id from
+// `matchContext`, finds the NEXT id in the match-id chain, and renders its ``
+// — the pull-based descent that replaces a top-down state diff. Renders nothing at
+// a leaf.
+//
+// Not-found (react-router's Outlet, same order): when the URL matched no route,
+// router-core flags ONE match `globalNotFound` — with the default
+// `notFoundMode: 'fuzzy'` the deepest fuzzy-matched route that has children, with
+// `notFoundMode: 'root'` the root. That match still renders its own component
+// (the layout), and its `` renders the not-found UI INSTEAD of a child
+// match — so the 404 lands inside the layout chrome.
+import { useContext, createElement, Suspense } from 'octane';
+import { rootRouteId } from '@tanstack/router-core';
+import { useStore } from './useStore.ts';
+import { useRouter, matchContext } from './context.ts';
+import { Match } from './Match.tsrx';
+import { RouteNotFound } from './RouteNotFound.tsrx';
+import { SafeFragment } from './SafeFragment.tsrx';
+
+export function Outlet() @{
+ const router = useRouter();
+ const parentId = useContext(matchContext);
+ const parentStore = router.stores.matchStores.get(parentId);
+ const parentRouteId = useStore(parentStore, (m) => m?.routeId);
+ const globalNotFound = useStore(parentStore, (m) => m?.globalNotFound ?? false);
+ const childId = useStore(router.stores.matchesId, (ids) => {
+ const i = ids.indexOf(parentId);
+ return i >= 0 ? ids[i + 1] : undefined;
+ });
+
+ // The root route's outlet wraps the first real match in a Suspense boundary
+ // whose fallback is the router's defaultPendingComponent (react-router's
+ // Outlet does the same) — the outermost pending UI for the initial load.
+ const DefaultPending = router.options.defaultPendingComponent;
+ const RootSuspense =
+ parentRouteId === rootRouteId ? Suspense : SafeFragment;
+ const pendingElement =
+ DefaultPending ? createElement(DefaultPending, {}) : null;
+
+ @if (globalNotFound) {
+
+ } @else {
+ @if (childId) {
+
+
+
+ }
+ }
+}
diff --git a/packages/octane-router/src/Outlet.tsrx.d.ts b/packages/octane-router/src/Outlet.tsrx.d.ts
new file mode 100644
index 0000000000..fa7975faa2
--- /dev/null
+++ b/packages/octane-router/src/Outlet.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { ComponentBody } from 'octane'
+
+export declare const Outlet: ComponentBody>
diff --git a/packages/octane-router/src/RouteNotFound.tsrx b/packages/octane-router/src/RouteNotFound.tsrx
new file mode 100644
index 0000000000..2647b15244
--- /dev/null
+++ b/packages/octane-router/src/RouteNotFound.tsrx
@@ -0,0 +1,23 @@
+// Renders a route's not-found UI — the port of react-router's
+// `renderRouteNotFound` (+ its `DefaultGlobalNotFound`). Resolution order matches
+// upstream: the route's own `notFoundComponent`, else the router-level
+// `defaultNotFoundComponent`, else TanStack's generic `
Not Found
` (the
+// upstream dev-only console.warn is not ported — repo policy: functional outcomes
+// only). Rendered from two places, mirroring react-router: `` when its
+// match is flagged `globalNotFound` (the URL matched no route), and ``
+// when the match resolved with `status === 'notFound'` (a loader threw
+// `notFound()`). Upstream spreads the NotFoundError onto the component, so a
+// `notFound({ data })` payload arrives as the `data` prop.
+import { useRouter } from './context.ts';
+
+export function RouteNotFound(props) @{
+ const router = useRouter();
+ const route = router.routesById[props.routeId];
+ const NotFound = route.options.notFoundComponent ?? router.options.defaultNotFoundComponent;
+
+ @if (NotFound) {
+
+ } @else {
+
Not Found
+ }
+}
diff --git a/packages/octane-router/src/RouterProvider.tsrx b/packages/octane-router/src/RouterProvider.tsrx
new file mode 100644
index 0000000000..eb8cd81c62
--- /dev/null
+++ b/packages/octane-router/src/RouterProvider.tsrx
@@ -0,0 +1,37 @@
+// Places the router into context and renders the active match tree. Mirrors
+// react-router's RouterProvider.tsx: `RouterContextProvider` is the low-level
+// provider — it forwards any extra props into `router.update()` (so
+// `` reconfigures the
+// instance), wraps in `router.options.Wrap` when configured, and provides the
+// router; `RouterProvider` renders `` inside it.
+import { hasKeys } from '@tanstack/router-core';
+import { routerContext } from './context.ts';
+import { Matches } from './Matches.tsrx';
+import { SafeFragment } from './SafeFragment.tsrx';
+
+export function RouterContextProvider(props) @{
+ const { router, children, ...rest } = props;
+ if (hasKeys(rest)) {
+ router.update({
+ ...router.options,
+ ...rest,
+ context: {
+ ...router.options.context,
+ ...rest.context,
+ },
+ });
+ }
+ const Wrap = router.options.Wrap ?? SafeFragment;
+
+
+ {children}
+
+}
+
+export function RouterProvider(props) @{
+ const { router, ...rest } = props;
+
+
+
+
+}
diff --git a/packages/octane-router/src/RouterProvider.tsrx.d.ts b/packages/octane-router/src/RouterProvider.tsrx.d.ts
new file mode 100644
index 0000000000..c0e17d05fc
--- /dev/null
+++ b/packages/octane-router/src/RouterProvider.tsrx.d.ts
@@ -0,0 +1,40 @@
+import type {
+ AnyRouter,
+ RegisteredRouter,
+ RouterOptions,
+} from '@tanstack/router-core'
+
+export type RouterProps<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDehydrated extends Record = Record,
+> = Omit<
+ RouterOptions<
+ TRouter['routeTree'],
+ NonNullable,
+ NonNullable,
+ TRouter['history'],
+ TDehydrated
+ >,
+ 'context'
+> & {
+ router: TRouter
+ context?: Partial<
+ RouterOptions<
+ TRouter['routeTree'],
+ NonNullable,
+ NonNullable,
+ TRouter['history'],
+ TDehydrated
+ >['context']
+ >
+}
+
+export declare function RouterProvider<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDehydrated extends Record = Record,
+>(props: RouterProps): void
+
+export declare function RouterContextProvider<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDehydrated extends Record = Record,
+>(props: RouterProps & { children?: unknown }): void
diff --git a/packages/octane-router/src/SafeFragment.tsrx b/packages/octane-router/src/SafeFragment.tsrx
new file mode 100644
index 0000000000..a50b54f87d
--- /dev/null
+++ b/packages/octane-router/src/SafeFragment.tsrx
@@ -0,0 +1,12 @@
+// Pass-through wrapper used wherever react-router conditionally omits a boundary
+// (Suspense / CatchBoundary / CatchNotFound / shellComponent). Rendering through
+// SafeFragment instead of conditionally nesting the template keeps the boundary
+// slot's component identity dynamic (`` where Wrap is Suspense or this) and
+// — crucially — means a route WITHOUT a pendingComponent/errorComponent does not
+// create a boundary at all, so suspensions and errors bubble to the nearest
+// ancestor that has one (react-router parity).
+export function SafeFragment(props) @{
+ <>
+ {props.children}
+ >
+}
diff --git a/packages/octane-router/src/SafeFragment.tsrx.d.ts b/packages/octane-router/src/SafeFragment.tsrx.d.ts
new file mode 100644
index 0000000000..db215a74a6
--- /dev/null
+++ b/packages/octane-router/src/SafeFragment.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { ComponentBody } from 'octane'
+
+export declare const SafeFragment: ComponentBody<{ children?: unknown }>
diff --git a/packages/octane-router/src/ScriptOnce.tsrx b/packages/octane-router/src/ScriptOnce.tsrx
new file mode 100644
index 0000000000..1f521c4501
--- /dev/null
+++ b/packages/octane-router/src/ScriptOnce.tsrx
@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'octane';
+import { isServer } from '@tanstack/router-core/isServer';
+import { useRouter } from './context.ts';
+
+export function ScriptOnce(props) @{
+ const router = useRouter();
+ const [hydrating, setHydrating] = useState(true);
+ useEffect(() => {
+ setHydrating(false);
+ }, []);
+
+ @if ((isServer ?? router.isServer) || hydrating) {
+
+ } @else {
+ <>>
+ }
+}
diff --git a/packages/octane-router/src/ScriptOnce.tsrx.d.ts b/packages/octane-router/src/ScriptOnce.tsrx.d.ts
new file mode 100644
index 0000000000..65a7fe198c
--- /dev/null
+++ b/packages/octane-router/src/ScriptOnce.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { ComponentBody } from 'octane'
+
+export declare const ScriptOnce: ComponentBody<{ children: string }>
diff --git a/packages/octane-router/src/Scripts.tsrx b/packages/octane-router/src/Scripts.tsrx
new file mode 100644
index 0000000000..f712560de0
--- /dev/null
+++ b/packages/octane-router/src/Scripts.tsrx
@@ -0,0 +1,41 @@
+import { createElement, useEffect, useState } from 'octane';
+import { isServer } from '@tanstack/router-core/isServer';
+import { getAssetKey } from './assetKeys.ts';
+import { useRouter } from './context.ts';
+import { useScripts } from './scriptContentUtils.ts';
+
+export function Scripts() @{
+ const router = useRouter();
+ const scripts = useScripts();
+ const [hydrating, setHydrating] = useState(true);
+ useEffect(() => {
+ setHydrating(false);
+ }, []);
+ const renderScripts =
+ !(isServer ?? router.isServer) && hydrating
+ ? [
+ {
+ tag: 'script',
+ attrs: {
+ nonce: router.options.ssr?.nonce,
+ className: '$tsr',
+ id: '$tsr-stream-barrier',
+ suppressHydrationWarning: true,
+ },
+ children: '',
+ },
+ ...scripts,
+ ]
+ : scripts;
+ <>
+ {renderScripts.map((script, index) => {
+ const assetKey = getAssetKey('body', script, index);
+ return createElement('script', {
+ ...script.attrs,
+ key: assetKey,
+ 'data-tsr-managed-key': assetKey,
+ dangerouslySetInnerHTML: { __html: script.children ?? '' },
+ });
+ })}
+ >
+}
diff --git a/packages/octane-router/src/Scripts.tsrx.d.ts b/packages/octane-router/src/Scripts.tsrx.d.ts
new file mode 100644
index 0000000000..3ccb684f5c
--- /dev/null
+++ b/packages/octane-router/src/Scripts.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { ComponentBody } from 'octane'
+
+export declare const Scripts: ComponentBody>
diff --git a/packages/octane-router/src/ScrollRestoration.tsrx b/packages/octane-router/src/ScrollRestoration.tsrx
new file mode 100644
index 0000000000..d8b2579504
--- /dev/null
+++ b/packages/octane-router/src/ScrollRestoration.tsrx
@@ -0,0 +1,16 @@
+// Restores scroll position across navigations. router-core's
+// `setupScrollRestoration` does all the work (save on scroll keyed by location,
+// restore on navigation) — the component just wires it on mount and renders nothing.
+// You can also enable it via `createRouter({ scrollRestoration: true })` (handled in
+// Matches); use one or the other.
+import { useLayoutEffect } from 'octane';
+import { setupScrollRestoration } from '@tanstack/router-core';
+import { useRouter } from './context.ts';
+
+export function ScrollRestoration() @{
+ const router = useRouter();
+ useLayoutEffect(() => {
+ setupScrollRestoration(router, true);
+ }, [router]);
+ <>>
+}
diff --git a/packages/octane-router/src/ScrollRestoration.tsrx.d.ts b/packages/octane-router/src/ScrollRestoration.tsrx.d.ts
new file mode 100644
index 0000000000..871135e8b8
--- /dev/null
+++ b/packages/octane-router/src/ScrollRestoration.tsrx.d.ts
@@ -0,0 +1,5 @@
+import type { ScrollRestorationOptions } from '@tanstack/router-core'
+
+export declare function ScrollRestoration(
+ _props?: ScrollRestorationOptions,
+): void
diff --git a/packages/octane-router/src/Transitioner.tsrx b/packages/octane-router/src/Transitioner.tsrx
new file mode 100644
index 0000000000..104fb876a8
--- /dev/null
+++ b/packages/octane-router/src/Transitioner.tsrx
@@ -0,0 +1,127 @@
+// The navigation engine — port of react-router's Transitioner. It is a hook,
+// rather than a component that renders nothing, because the root match can own
+// the full document. A rendered sibling would put its SSR marker before
+// and outside the #__app hydration root.
+// It (1) supplies `router.startTransition` so every navigation state update rides
+// an octane transition (concurrent navigation: the current page holds while the
+// next route suspends), (2) subscribes to history so back/forward and `Link`
+// clicks reload the matches, (3) kicks off the initial `router.load()` (skipped
+// when hydrating SSR — ssr-client triggers it) and commits a canonical-URL
+// replace when the mounted URL isn't in canonical form, and (4) drives the router
+// EVENT LIFECYCLE: `onLoad` when a load settles, `onBeforeRouteMount` /
+// `onResolved` when all pending work (load + transition + pending matches)
+// drains, then commits `status: 'idle'` + `resolvedLocation`. router-core's
+// scroll restoration, devtools, and `router.subscribe` consumers all key off
+// these events, and `useRouterState` selectors on `resolvedLocation` depend on
+// the commit.
+import { useEffect, useLayoutEffect, useRef, useState, startTransition } from 'octane';
+import { batch } from '@tanstack/store';
+import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core';
+import { useRouter } from './context.ts';
+import { useStore } from './useStore.ts';
+import { usePrevious } from './utils.ts';
+
+export function useTransitioner() {
+ const router = useRouter();
+ const mountLoadForRouter = useRef({ router, mounted: false });
+ const [isTransitioning, setIsTransitioning] = useState(false);
+
+ const isLoading = useStore(router.stores.isLoading, (v) => v);
+ const hasPending = useStore(router.stores.hasPending, (v) => v);
+
+ const previousIsLoading = usePrevious(isLoading);
+ const isAnyPending = isLoading || isTransitioning || hasPending;
+ const previousIsAnyPending = usePrevious(isAnyPending);
+ const isPagePending = isLoading || hasPending;
+ const previousIsPagePending = usePrevious(isPagePending);
+
+ router.startTransition = (fn) => {
+ setIsTransitioning(true);
+ startTransition(() => {
+ fn();
+ setIsTransitioning(false);
+ });
+ };
+
+ // Subscribe to location changes and load the new location. On mount, also
+ // verify the current URL is in canonical form (search serialization,
+ // trailing slash) and replace it if not — mirroring the server-side redirect
+ // check in router.beforeLoad.
+ useEffect(() => {
+ const unsub = router.history.subscribe(() => router.load());
+
+ const nextLocation = router.buildLocation({
+ to: router.latestLocation.pathname,
+ search: true,
+ params: true,
+ hash: true,
+ state: true,
+ _includeValidateSearch: true,
+ });
+ if (
+ trimPathRight(router.latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref)
+ ) {
+ router.commitLocation({ ...nextLocation, replace: true });
+ }
+
+ return () => unsub();
+ }, [router, router.history]);
+
+ // Initial load. Skipped when hydrating from SSR (ssr-client triggers it) and
+ // on re-runs for the same router instance.
+ useLayoutEffect(() => {
+ if (
+ typeof window !== 'undefined' && router.ssr ||
+ mountLoadForRouter.current.router === router && mountLoadForRouter.current.mounted
+ ) {
+ return;
+ }
+ mountLoadForRouter.current = { router, mounted: true };
+ router.load().catch((err: unknown) => {
+ console.error(err);
+ });
+ }, [router]);
+
+ // The router was loading and now it's not — the new matches are in state.
+ useLayoutEffect(() => {
+ if (previousIsLoading && !isLoading) {
+ router.emit({
+ type: 'onLoad',
+ ...getLocationChangeInfo(
+ router.stores.location.get(),
+ router.stores.resolvedLocation.get(),
+ ),
+ });
+ }
+ }, [previousIsLoading, router, isLoading]);
+
+ useLayoutEffect(() => {
+ if (previousIsPagePending && !isPagePending) {
+ router.emit({
+ type: 'onBeforeRouteMount',
+ ...getLocationChangeInfo(
+ router.stores.location.get(),
+ router.stores.resolvedLocation.get(),
+ ),
+ });
+ }
+ }, [isPagePending, previousIsPagePending, router]);
+
+ // Everything pending has drained — resolve the navigation: emit onResolved and
+ // commit status idle + resolvedLocation (what `onRendered` and scroll
+ // restoration key off).
+ useLayoutEffect(() => {
+ if (previousIsAnyPending && !isAnyPending) {
+ const changeInfo = getLocationChangeInfo(
+ router.stores.location.get(),
+ router.stores.resolvedLocation.get(),
+ );
+ router.emit({ type: 'onResolved', ...changeInfo });
+
+ batch(() => {
+ router.stores.status.set('idle');
+ router.stores.resolvedLocation.set(router.stores.location.get());
+ });
+ }
+ }, [isAnyPending, previousIsAnyPending, router]);
+}
diff --git a/packages/octane-router/src/assetKeys.ts b/packages/octane-router/src/assetKeys.ts
new file mode 100644
index 0000000000..7eb81c667f
--- /dev/null
+++ b/packages/octane-router/src/assetKeys.ts
@@ -0,0 +1,15 @@
+import type { RouterManagedTag } from '@tanstack/router-core'
+
+export function getAssetKey(
+ scope: 'head' | 'body',
+ asset: RouterManagedTag,
+ index: number,
+) {
+ const inlineCss = asset.tag === 'style' && asset.inlineCss
+ return `${scope}:${index}:${JSON.stringify({
+ tag: asset.tag,
+ attrs: asset.attrs,
+ children: inlineCss ? undefined : asset.children,
+ inlineCss,
+ })}`
+}
diff --git a/packages/octane-router/src/context.ts b/packages/octane-router/src/context.ts
new file mode 100644
index 0000000000..f5d82b73c5
--- /dev/null
+++ b/packages/octane-router/src/context.ts
@@ -0,0 +1,33 @@
+// Router + match contexts. `routerContext` carries the Router instance (provided
+// by RouterProvider, read by every hook). `matchContext` carries the current
+// match id down the render tree so `Outlet` can find the NEXT match to render —
+// the pull-based chaining that replaces a top-down state diff.
+import { createContext, useContext } from 'octane'
+import type { AnyRouter, RegisteredRouter } from '@tanstack/router-core'
+
+export const routerContext = createContext(undefined)
+export const getRouterContext = (): typeof routerContext => routerContext
+
+// The id of the nearest rendered match (undefined above the first match).
+export const matchContext = createContext(undefined)
+
+// Resolve the active router: an explicitly-passed one wins, else the context.
+// `useContext` is keyed by context identity (not a per-call-site slot), so it's
+// safe to call from this binding code without a slot.
+export function useRouter(opts?: {
+ router?: TRouter
+ warn?: boolean
+}): TRouter
+export function useRouter(...args: Array): AnyRouter {
+ const opts = (
+ args.length && typeof args[0] !== 'symbol' ? args[0] : undefined
+ ) as { router?: AnyRouter; warn?: boolean } | undefined
+ const ctx = useContext(routerContext)
+ const router = opts?.router ?? ctx
+ if (!router && opts?.warn !== false) {
+ throw new Error(
+ 'useRouter must be used inside a component!',
+ )
+ }
+ return router as AnyRouter
+}
diff --git a/packages/octane-router/src/externalHydration.ts b/packages/octane-router/src/externalHydration.ts
new file mode 100644
index 0000000000..81de81cdee
--- /dev/null
+++ b/packages/octane-router/src/externalHydration.ts
@@ -0,0 +1,88 @@
+const EXTERNAL_HYDRATION_PROMISE = Symbol.for(
+ 'octane.external-hydration-promise',
+)
+
+type ExternalHydrationThenable = PromiseLike & {
+ [EXTERNAL_HYDRATION_PROMISE]: true
+ status?: ThenableStatus
+ value?: T
+ reason?: unknown
+}
+
+type ThenableStatus = 'pending' | 'fulfilled' | 'rejected'
+
+const externalHydrationThenables = new WeakMap<
+ object,
+ ExternalHydrationThenable
+>()
+
+/**
+ * Wrap a router-owned promise so Octane still schedules its suspense boundary,
+ * while TanStack's serializer remains the only owner of its hydration value.
+ */
+export function toExternalHydrationThenable(
+ thenable: PromiseLike,
+): PromiseLike {
+ const key = thenable as object
+ const existing = externalHydrationThenables.get(key)
+ if (existing) {
+ return existing as ExternalHydrationThenable
+ }
+
+ let localStatus: ThenableStatus | undefined
+ let localValue: T | undefined
+ let localReason: unknown
+ let hasLocalValue = false
+ let hasLocalReason = false
+
+ const externalThenable: ExternalHydrationThenable = {
+ [EXTERNAL_HYDRATION_PROMISE]: true,
+ get status() {
+ return localStatus ?? readThenableStatus(thenable)
+ },
+ set status(status) {
+ localStatus = status
+ },
+ get value(): T | undefined {
+ return hasLocalValue
+ ? localValue
+ : readThenableProperty(thenable, 'value')
+ },
+ set value(value: T | undefined) {
+ hasLocalValue = true
+ localValue = value
+ },
+ get reason(): unknown {
+ return hasLocalReason
+ ? localReason
+ : readThenableProperty(thenable, 'reason')
+ },
+ set reason(reason) {
+ hasLocalReason = true
+ localReason = reason
+ },
+ then(onfulfilled, onrejected) {
+ return thenable.then(onfulfilled, onrejected)
+ },
+ }
+ externalHydrationThenables.set(key, externalThenable)
+ return externalThenable
+}
+
+function readThenableStatus(thenable: PromiseLike) {
+ const status = readThenableProperty(thenable, 'status')
+ return status === 'pending' || status === 'fulfilled' || status === 'rejected'
+ ? status
+ : undefined
+}
+
+function readThenableProperty(
+ thenable: PromiseLike,
+ property: 'status' | 'value' | 'reason',
+): T | undefined {
+ try {
+ return (thenable as PromiseLike & Record)[property]
+ } catch {
+ return undefined
+ }
+}
diff --git a/packages/octane-router/src/fileRoute.ts b/packages/octane-router/src/fileRoute.ts
new file mode 100644
index 0000000000..2619b2fea2
--- /dev/null
+++ b/packages/octane-router/src/fileRoute.ts
@@ -0,0 +1,286 @@
+import { createRoute } from './route'
+import { internalHooks } from './hooks'
+import { useRouter } from './context'
+import { splitSlot, subSlot } from './internal'
+import type {
+ AnyContext,
+ AnyRoute,
+ AnyRouter,
+ Constrain,
+ ConstrainLiteral,
+ FileBaseRouteOptions,
+ FileRoutesByPath,
+ LazyRouteOptions,
+ Register,
+ RegisteredRouter,
+ ResolveParams,
+ Route,
+ RouteById,
+ RouteConstraints,
+ RouteIds,
+ RouteLoaderEntry,
+ UpdatableRouteOptions,
+ UseNavigateResult,
+} from '@tanstack/router-core'
+import type {
+ UseLoaderDataRoute,
+ UseLoaderDepsRoute,
+ UseMatchRoute,
+ UseParamsRoute,
+ UseRouteContextRoute,
+ UseSearchRoute,
+} from './routeHookTypes'
+
+declare const process: {
+ env: {
+ NODE_ENV?: string
+ }
+}
+
+export function createFileRoute<
+ TFilePath extends keyof FileRoutesByPath,
+ TParentRoute extends AnyRoute = FileRoutesByPath[TFilePath]['parentRoute'],
+ TId extends RouteConstraints['TId'] = FileRoutesByPath[TFilePath]['id'],
+ TPath extends RouteConstraints['TPath'] = FileRoutesByPath[TFilePath]['path'],
+ TFullPath extends RouteConstraints['TFullPath'] =
+ FileRoutesByPath[TFilePath]['fullPath'],
+>(
+ path?: TFilePath,
+): FileRoute['createRoute'] {
+ return new FileRoute(path, {
+ silent: true,
+ }).createRoute
+}
+
+/** @deprecated Use `createFileRoute(path)(options)` instead. */
+export class FileRoute<
+ TFilePath extends keyof FileRoutesByPath,
+ TParentRoute extends AnyRoute = FileRoutesByPath[TFilePath]['parentRoute'],
+ TId extends RouteConstraints['TId'] = FileRoutesByPath[TFilePath]['id'],
+ TPath extends RouteConstraints['TPath'] = FileRoutesByPath[TFilePath]['path'],
+ TFullPath extends RouteConstraints['TFullPath'] =
+ FileRoutesByPath[TFilePath]['fullPath'],
+> {
+ silent?: boolean
+
+ constructor(
+ public path?: TFilePath,
+ _opts?: { silent: boolean },
+ ) {
+ this.silent = _opts?.silent
+ }
+
+ createRoute = <
+ TRegister = Register,
+ TSearchValidator = undefined,
+ TParams = ResolveParams,
+ TRouteContextFn = AnyContext,
+ TBeforeLoadFn = AnyContext,
+ TLoaderDeps extends Record = {},
+ TLoaderFn = undefined,
+ TChildren = unknown,
+ TSSR = unknown,
+ const TMiddlewares = unknown,
+ THandlers = undefined,
+ >(
+ options?: FileBaseRouteOptions<
+ TRegister,
+ TParentRoute,
+ TId,
+ TPath,
+ TSearchValidator,
+ TParams,
+ TLoaderDeps,
+ TLoaderFn,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ AnyContext,
+ TSSR,
+ TMiddlewares,
+ THandlers
+ > &
+ UpdatableRouteOptions<
+ TParentRoute,
+ TId,
+ TFullPath,
+ TParams,
+ TSearchValidator,
+ TLoaderFn,
+ TLoaderDeps,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn
+ >,
+ ): Route<
+ TRegister,
+ TParentRoute,
+ TPath,
+ TFullPath,
+ TFilePath,
+ TId,
+ TSearchValidator,
+ TParams,
+ AnyContext,
+ TRouteContextFn,
+ TBeforeLoadFn,
+ TLoaderDeps,
+ TLoaderFn,
+ TChildren,
+ unknown,
+ TSSR,
+ TMiddlewares,
+ THandlers
+ > => {
+ if (process.env.NODE_ENV !== 'production' && !this.silent) {
+ console.warn(
+ 'Warning: FileRoute is deprecated. Use createFileRoute(path)(options) instead.',
+ )
+ }
+ const route = createRoute(options as any)
+ ;(route as any).isRoot = false
+ return route as any
+ }
+}
+
+/** @deprecated Place the loader in the main `createFileRoute` options. */
+export function FileRouteLoader<
+ TFilePath extends keyof FileRoutesByPath,
+ TRoute extends FileRoutesByPath[TFilePath]['preLoaderRoute'],
+>(
+ _path: TFilePath,
+): (
+ loaderFn: Constrain<
+ TLoaderFn,
+ RouteLoaderEntry<
+ Register,
+ TRoute['parentRoute'],
+ TRoute['types']['id'],
+ TRoute['types']['params'],
+ TRoute['types']['loaderDeps'],
+ TRoute['types']['routerContext'],
+ TRoute['types']['routeContextFn'],
+ TRoute['types']['beforeLoadFn']
+ >
+ >,
+) => TLoaderFn {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(
+ 'Warning: FileRouteLoader is deprecated. Place the loader in createFileRoute options.',
+ )
+ }
+ return (loaderFn) => loaderFn as never
+}
+
+declare module '@tanstack/router-core' {
+ export interface LazyRoute {
+ useMatch: UseMatchRoute
+ useRouteContext: UseRouteContextRoute
+ useSearch: UseSearchRoute
+ useParams: UseParamsRoute
+ useLoaderDeps: UseLoaderDepsRoute
+ useLoaderData: UseLoaderDataRoute
+ useNavigate: () => UseNavigateResult
+ }
+}
+
+export class LazyRoute {
+ options: { id: string } & LazyRouteOptions
+ declare useMatch: UseMatchRoute
+ declare useRouteContext: UseRouteContextRoute
+ declare useSearch: UseSearchRoute
+ declare useParams: UseParamsRoute
+ declare useLoaderDeps: UseLoaderDepsRoute
+ declare useLoaderData: UseLoaderDataRoute
+ declare useNavigate: () => UseNavigateResult
+
+ constructor(opts: { id: string } & LazyRouteOptions) {
+ this.options = opts
+ const id = this.options.id
+ this.useMatch = ((...args: Array) => {
+ const [user, slot] = splitSlot(args)
+ const options = user[0] ?? {}
+ return internalHooks.useMatch(
+ {
+ select: options.select,
+ from: id,
+ structuralSharing: options.structuralSharing,
+ },
+ subSlot(slot, 'lr:m'),
+ )
+ }) as typeof this.useMatch
+ this.useRouteContext = ((...args: Array) => {
+ const [user, slot] = splitSlot(args)
+ return internalHooks.useRouteContext(
+ { ...(user[0] ?? {}), from: id },
+ subSlot(slot, 'lr:c'),
+ )
+ }) as typeof this.useRouteContext
+ this.useSearch = ((...args: Array) => {
+ const [user, slot] = splitSlot(args)
+ const options = user[0] ?? {}
+ return internalHooks.useSearch(
+ {
+ select: options.select,
+ from: id,
+ structuralSharing: options.structuralSharing,
+ },
+ subSlot(slot, 'lr:s'),
+ )
+ }) as typeof this.useSearch
+ this.useParams = ((...args: Array) => {
+ const [user, slot] = splitSlot(args)
+ const options = user[0] ?? {}
+ return internalHooks.useParams(
+ {
+ select: options.select,
+ from: id,
+ structuralSharing: options.structuralSharing,
+ },
+ subSlot(slot, 'lr:p'),
+ )
+ }) as typeof this.useParams
+ this.useLoaderDeps = ((...args: Array) => {
+ const [user, slot] = splitSlot(args)
+ return internalHooks.useLoaderDeps(
+ { ...(user[0] ?? {}), from: id },
+ subSlot(slot, 'lr:d'),
+ )
+ }) as typeof this.useLoaderDeps
+ this.useLoaderData = ((...args: Array) => {
+ const [user, slot] = splitSlot(args)
+ return internalHooks.useLoaderData(
+ { ...(user[0] ?? {}), from: id },
+ subSlot(slot, 'lr:l'),
+ )
+ }) as typeof this.useLoaderData
+ this.useNavigate = ((...args: Array) => {
+ const [, slot] = splitSlot(args)
+ const router = useRouter()
+ return internalHooks.useNavigate(
+ {
+ from: (router.routesById as Record)[id].fullPath,
+ },
+ subSlot(slot, 'lr:n'),
+ )
+ }) as typeof this.useNavigate
+ }
+}
+
+export function createLazyRoute<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TId extends string = string,
+ TRoute extends AnyRoute = RouteById,
+>(id: ConstrainLiteral>) {
+ return (opts: LazyRouteOptions) => new LazyRoute({ id, ...opts })
+}
+
+export function createLazyFileRoute<
+ TFilePath extends keyof FileRoutesByPath,
+ TRoute extends FileRoutesByPath[TFilePath]['preLoaderRoute'],
+>(id: TFilePath): (opts: LazyRouteOptions) => LazyRoute {
+ if (typeof id === 'object') {
+ return new LazyRoute(id) as any
+ }
+ return (opts: LazyRouteOptions) => new LazyRoute({ id, ...opts })
+}
diff --git a/packages/octane-router/src/frameworkTypes.ts b/packages/octane-router/src/frameworkTypes.ts
new file mode 100644
index 0000000000..33e8b73646
--- /dev/null
+++ b/packages/octane-router/src/frameworkTypes.ts
@@ -0,0 +1,89 @@
+import type { ComponentBody } from 'octane'
+import type {
+ ErrorComponentProps,
+ MetaDescriptor,
+ NotFoundRouteProps,
+ UseNavigateResult,
+} from '@tanstack/router-core'
+import type {
+ UseLoaderDataRoute,
+ UseLoaderDepsRoute,
+ UseMatchRoute,
+ UseParamsRoute,
+ UseRouteContextRoute,
+ UseSearchRoute,
+} from './routeHookTypes'
+import type { LinkComponentRoute } from './linkTypes'
+
+export type OctaneElementAttributes = Record<
+ string,
+ string | number | boolean | null | undefined
+>
+
+export type OctaneScriptAttributes = OctaneElementAttributes & {
+ children?: string
+}
+
+export interface DefaultRouteTypes {
+ component: ComponentBody
+}
+
+export interface RouteTypes extends DefaultRouteTypes {}
+
+export type AsyncRouteComponent = RouteTypes['component'] & {
+ preload?: () => Promise
+}
+
+export type RouteComponent = AsyncRouteComponent>
+
+export type ErrorRouteComponent = AsyncRouteComponent
+
+export type NotFoundRouteComponent = RouteTypes['component']
+
+declare module '@tanstack/router-core' {
+ export interface RouteMatchExtensions {
+ meta?: Array
+ links?: Array
+ scripts?: Array
+ styles?: Array
+ headScripts?: Array
+ }
+
+ export interface UpdatableRouteOptionsExtensions {
+ component?: RouteComponent
+ errorComponent?: false | null | undefined | ErrorRouteComponent
+ notFoundComponent?: NotFoundRouteComponent
+ pendingComponent?: RouteComponent
+ }
+
+ export interface RootRouteOptionsExtensions {
+ shellComponent?: ComponentBody<{ children?: unknown }>
+ }
+
+ export interface RouteExtensions<
+ in out TId extends string,
+ in out TFullPath extends string,
+ > {
+ useMatch: UseMatchRoute
+ useRouteContext: UseRouteContextRoute
+ useSearch: UseSearchRoute
+ useParams: UseParamsRoute
+ useLoaderDeps: UseLoaderDepsRoute
+ useLoaderData: UseLoaderDataRoute
+ useNavigate: () => UseNavigateResult
+ Link: LinkComponentRoute
+ }
+
+ export interface RouterOptionsExtensions {
+ defaultComponent?: RouteComponent
+ defaultErrorComponent?: ErrorRouteComponent
+ defaultPendingComponent?: RouteComponent
+ defaultNotFoundComponent?: NotFoundRouteComponent
+ Wrap?: ComponentBody<{ children?: unknown }>
+ InnerWrap?: ComponentBody<{ children?: unknown }>
+ defaultOnCatch?: (
+ error: Error,
+ errorInfo: { componentStack: string },
+ ) => void
+ }
+}
diff --git a/packages/octane-router/src/generator-plugin.d.ts b/packages/octane-router/src/generator-plugin.d.ts
new file mode 100644
index 0000000000..7771265763
--- /dev/null
+++ b/packages/octane-router/src/generator-plugin.d.ts
@@ -0,0 +1,23 @@
+export interface TransformRouteSourceOptions {
+ source: string
+ filename: string
+ node: unknown
+}
+
+export interface FormatRouteOptions {
+ source: string
+ node: unknown
+}
+
+export interface OctaneRouteGeneratorPlugin {
+ name: string
+ transformRouteSource: (options: TransformRouteSourceOptions) => string
+ formatRoute: (options: FormatRouteOptions) => string
+}
+
+export declare function maskOctaneRouteSource(
+ source: string,
+ filename?: string,
+): string
+
+export declare function octaneRouteGeneratorPlugin(): OctaneRouteGeneratorPlugin
diff --git a/packages/octane-router/src/generator-plugin.js b/packages/octane-router/src/generator-plugin.js
new file mode 100644
index 0000000000..74e6bfd7d6
--- /dev/null
+++ b/packages/octane-router/src/generator-plugin.js
@@ -0,0 +1,109 @@
+import { compileToVolarMappings } from 'octane/compiler/volar'
+
+/**
+ * @typedef {object} AstNode
+ * @property {AstNode | Array} [body]
+ * @property {number} [start]
+ * @property {number} [end]
+ * @property {{ native_tsrx_body?: boolean }} [metadata]
+ */
+
+/**
+ * Makes TSRX route modules parseable by the router generator without changing
+ * source offsets. The generator applies edits to the original source, so the
+ * authored Octane template bodies remain byte-for-byte intact.
+ *
+ * @param {string} source
+ * @param {string} [filename]
+ * @returns {string}
+ */
+export function maskOctaneRouteSource(source, filename = 'route.tsrx') {
+ // Only TSRX files contain Octane's native template syntax. Plain TypeScript
+ // server routes and route helpers must pass through untouched.
+ if (!filename.endsWith('.tsrx')) {
+ return source
+ }
+
+ const { sourceAst } = compileToVolarMappings(source, filename)
+ const output = source.split('')
+
+ for (const body of findNativeTemplateBodies(
+ /** @type {AstNode} */ (sourceAst),
+ )) {
+ const { start, end } = body
+ output[start] = ' '
+ output[start + 1] = '{'
+ for (let index = start + 2; index < end - 1; index++) {
+ if (source[index] !== '\n' && source[index] !== '\r') {
+ output[index] = ' '
+ }
+ }
+ output[end - 1] = '}'
+ }
+
+ return output.join('')
+}
+
+/**
+ * @returns {{
+ * name: string
+ * transformRouteSource: (options: { source: string, filename: string }) => string
+ * formatRoute: (options: { source: string }) => string
+ * }}
+ */
+export function octaneRouteGeneratorPlugin() {
+ return {
+ name: 'octane-route-source',
+ transformRouteSource: ({ source, filename }) =>
+ maskOctaneRouteSource(source, filename),
+ // Router scaffolds are already formatted. Returning them unchanged avoids
+ // passing TSRX's `@{}` syntax through a TypeScript-only formatter.
+ formatRoute: ({ source }) => source,
+ }
+}
+
+/**
+ * @param {AstNode} root
+ * @returns {Array<{ start: number, end: number }>}
+ */
+function findNativeTemplateBodies(root) {
+ /** @type {Array<{ start: number, end: number }>} */
+ const bodies = []
+ const visited = new WeakSet()
+
+ /** @param {unknown} value */
+ const visit = (value) => {
+ if (!value || typeof value !== 'object' || visited.has(value)) {
+ return
+ }
+ visited.add(value)
+
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ visit(item)
+ }
+ return
+ }
+
+ const node = /** @type {AstNode} */ (value)
+ if (
+ node.metadata?.native_tsrx_body === true &&
+ node.body &&
+ !Array.isArray(node.body) &&
+ typeof node.body.start === 'number' &&
+ typeof node.body.end === 'number'
+ ) {
+ bodies.push({ start: node.body.start, end: node.body.end })
+ return
+ }
+
+ for (const [key, child] of Object.entries(node)) {
+ if (key !== 'metadata' && key !== 'loc') {
+ visit(child)
+ }
+ }
+ }
+
+ visit(root)
+ return bodies
+}
diff --git a/packages/octane-router/src/headContentUtils.ts b/packages/octane-router/src/headContentUtils.ts
new file mode 100644
index 0000000000..b20d542f9c
--- /dev/null
+++ b/packages/octane-router/src/headContentUtils.ts
@@ -0,0 +1,187 @@
+import {
+ appendUniqueUserTags,
+ deepEqual,
+ escapeHtml,
+ getAssetCrossOrigin,
+ getScriptPreloadAttrs,
+ resolveManifestCssLink,
+} from '@tanstack/router-core'
+import { isServer } from '@tanstack/router-core/isServer'
+import { useRouter } from './context'
+import { splitSlot, subSlot } from './internal'
+import { useStore } from './useStore'
+import type {
+ AnyRouteMatch,
+ AnyRouter,
+ AssetCrossOriginConfig,
+ RouterManagedTag,
+} from '@tanstack/router-core'
+
+function buildTagsFromMatches(
+ router: AnyRouter,
+ nonce: string | undefined,
+ matches: Array,
+ assetCrossOrigin?: AssetCrossOriginConfig,
+): Array {
+ const routeMeta = matches
+ .map((match) => match.meta)
+ .filter((meta) => meta !== undefined)
+
+ const resultMeta: Array = []
+ const metaByAttribute: Record = {}
+ let title: RouterManagedTag | undefined
+ for (let i = routeMeta.length - 1; i >= 0; i--) {
+ const metas = routeMeta[i]!
+ for (let j = metas.length - 1; j >= 0; j--) {
+ const meta = metas[j]
+ if (!meta) {
+ continue
+ }
+
+ if ('title' in meta && typeof meta.title === 'string') {
+ title ??= { tag: 'title', children: meta.title }
+ } else if ('script:ld+json' in meta) {
+ try {
+ resultMeta.push({
+ tag: 'script',
+ attrs: { type: 'application/ld+json' },
+ children: escapeHtml(JSON.stringify(meta['script:ld+json'])),
+ })
+ } catch {
+ // Ignore values that cannot be serialized as JSON-LD.
+ }
+ } else {
+ const attribute =
+ ('name' in meta && typeof meta.name === 'string'
+ ? meta.name
+ : undefined) ??
+ ('property' in meta && typeof meta.property === 'string'
+ ? meta.property
+ : undefined)
+ if (attribute && metaByAttribute[attribute]) {
+ continue
+ }
+ if (attribute) {
+ metaByAttribute[attribute] = true
+ }
+ resultMeta.push({ tag: 'meta', attrs: { ...meta, nonce } })
+ }
+ }
+ }
+
+ if (title) {
+ resultMeta.push(title)
+ }
+ if (nonce) {
+ resultMeta.push({
+ tag: 'meta',
+ attrs: { property: 'csp-nonce', content: nonce },
+ })
+ }
+ resultMeta.reverse()
+
+ const links = matches
+ .flatMap((match) => match.links ?? [])
+ .filter((link) => link !== undefined)
+ .map(
+ (link) =>
+ ({ tag: 'link', attrs: { ...link, nonce } }) satisfies RouterManagedTag,
+ )
+
+ const manifestTags: Array = []
+ const preloadTags: Array = []
+ const manifest = router.ssr?.manifest
+ if (manifest) {
+ for (const match of matches) {
+ for (const link of manifest.routes[match.routeId]?.css ?? []) {
+ const resolvedLink = resolveManifestCssLink(link)
+ manifestTags.push({
+ tag: 'link',
+ attrs: {
+ rel: 'stylesheet',
+ ...resolvedLink,
+ crossOrigin:
+ getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ??
+ resolvedLink.crossOrigin,
+ nonce,
+ },
+ })
+ }
+ for (const preload of manifest.routes[match.routeId]?.preloads ?? []) {
+ preloadTags.push({
+ tag: 'link',
+ attrs: {
+ ...getScriptPreloadAttrs(manifest, preload, assetCrossOrigin),
+ nonce,
+ },
+ })
+ }
+ }
+
+ if (manifest.inlineStyle) {
+ manifestTags.push({
+ tag: 'style',
+ attrs: { ...manifest.inlineStyle.attrs, nonce },
+ children: manifest.inlineStyle.children,
+ inlineCss: true,
+ })
+ }
+ }
+
+ const styles = matches
+ .flatMap((match) => match.styles ?? [])
+ .filter((style) => style !== undefined)
+ .map(
+ ({ children, ...attrs }) =>
+ ({
+ tag: 'style',
+ attrs: { ...attrs, nonce },
+ children: children,
+ }) satisfies RouterManagedTag,
+ )
+
+ const headScripts = matches
+ .flatMap((match) => match.headScripts ?? [])
+ .filter((script) => script !== undefined)
+ .map(
+ ({ children, ...attrs }) =>
+ ({
+ tag: 'script',
+ attrs: { ...attrs, nonce },
+ children: children,
+ }) satisfies RouterManagedTag,
+ )
+
+ const tags: Array = []
+ appendUniqueUserTags(tags, resultMeta)
+ tags.push(...preloadTags)
+ appendUniqueUserTags(tags, links)
+ tags.push(...manifestTags)
+ appendUniqueUserTags(tags, styles)
+ appendUniqueUserTags(tags, headScripts)
+ return tags
+}
+
+export function useTags(...args: Array): Array {
+ const [userArgs, slot] = splitSlot(args)
+ const assetCrossOrigin = userArgs[0] as AssetCrossOriginConfig | undefined
+ const router = useRouter()
+ const nonce = router.options.ssr?.nonce
+
+ if (isServer ?? router.isServer) {
+ return buildTagsFromMatches(
+ router,
+ nonce,
+ router.stores.matches.get(),
+ assetCrossOrigin,
+ )
+ }
+
+ return useStore(
+ router.stores.matches,
+ (matches: Array) =>
+ buildTagsFromMatches(router, nonce, matches, assetCrossOrigin),
+ deepEqual,
+ subSlot(slot, 'head:tags'),
+ )
+}
diff --git a/packages/octane-router/src/history.ts b/packages/octane-router/src/history.ts
new file mode 100644
index 0000000000..bab3a32937
--- /dev/null
+++ b/packages/octane-router/src/history.ts
@@ -0,0 +1,10 @@
+// `@tanstack/history` is a separate framework-agnostic dependency (the browser/
+// hash/memory history abstractions). react-router re-exports it from `./history`;
+// we mirror that so `@tanstack/octane-router/history` and the bare entry both resolve it.
+export {
+ createHistory,
+ createBrowserHistory,
+ createHashHistory,
+ createMemoryHistory,
+} from '@tanstack/history'
+export type * from '@tanstack/history'
diff --git a/packages/octane-router/src/hooks.ts b/packages/octane-router/src/hooks.ts
new file mode 100644
index 0000000000..89035f6d44
--- /dev/null
+++ b/packages/octane-router/src/hooks.ts
@@ -0,0 +1,449 @@
+// The read hooks — ports of react-router's useMatch.tsx / useParams / useSearch /
+// useLoaderData / useLoaderDeps / useRouteContext / useNavigate / useCanGoBack /
+// Matches.tsx (useMatches / useParentMatches / useChildMatches). Everything match-
+// shaped funnels through `useMatch`, which subscribes to ONE match store:
+// - `from` given → `router.stores.getRouteMatchStore(from)` (a cached computed
+// that resolves a routeId to its current match);
+// - no `from` → the NEAREST match via `matchContext` (the match id the enclosing
+// `` provided) — NOT the leaf match.
+// A missing match throws unless `shouldThrow: false` (upstream invariant).
+// Selectors run through `useStructuralSharing` (replaceEqualDeep against the
+// previous selection when `structuralSharing ?? defaultStructuralSharing`).
+import { useCallback, useContext, useRef } from 'octane'
+import { replaceEqualDeep } from '@tanstack/router-core'
+import { matchContext, useRouter } from './context'
+import { useStore } from './useStore'
+import { splitSlot, subSlot } from './internal'
+import type { StructuralSharingOption } from './structuralSharing'
+import type {
+ AnyRouter,
+ FromPathOption,
+ RegisteredRouter,
+ ThrowConstraint,
+ ThrowOrOptional,
+ UseLoaderDataResult,
+ UseLoaderDepsResult,
+ UseNavigateResult,
+ UseParamsResult,
+ UseRouteContextOptions,
+ UseRouteContextResult,
+ UseSearchResult,
+} from '@tanstack/router-core'
+import type {
+ UseLoaderDataOptions,
+ UseLoaderDepsOptions,
+ UseLocationBaseOptions,
+ UseLocationResult,
+ UseMatchOptions,
+ UseMatchResult,
+ UseMatchesBaseOptions,
+ UseMatchesResult,
+ UseParamsOptions,
+ UseSearchOptions,
+} from './routeHookTypes'
+
+// Sentinel store + selection for "no match at this id" (upstream's dummyStore).
+const dummyStore = {
+ get() {},
+ subscribe() {
+ return { unsubscribe() {} }
+ },
+}
+
+// Selector wrapper honoring structural sharing: when enabled, the selection is
+// replaceEqualDeep'd against the previous one so deep-equal slices keep their
+// reference (no re-render). Port of react-router's useStructuralSharing.
+function useStructuralSharing(
+ opts: any,
+ router: any,
+ slot: symbol | undefined,
+) {
+ const previousResult = useRef(undefined, subSlot(slot, 'ss'))
+ return (slice: any) => {
+ const selected = opts?.select ? opts.select(slice) : slice
+ if (opts?.structuralSharing ?? router.options.defaultStructuralSharing) {
+ return (previousResult.current = replaceEqualDeep(
+ previousResult.current,
+ selected,
+ ))
+ }
+ return selected
+ }
+}
+
+export function useMatch<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TThrow extends boolean = true,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts: UseMatchOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ ThrowConstraint,
+ TSelected,
+ TStructuralSharing
+ >,
+): ThrowOrOptional, TThrow>
+export function useMatch(...args: Array): any {
+ return useMatchImpl(args)
+}
+
+function useMatchImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ const router = useRouter()
+ // octane has no rules of hooks, so the nearest-match context is read
+ // unconditionally (upstream reads a dummy context when `from` is given).
+ const nearestMatchId = useContext(matchContext)
+ const matchStore = opts.from
+ ? router.stores.getRouteMatchStore(opts.from)
+ : router.stores.matchStores.get(nearestMatchId as string)
+
+ const selector = useStructuralSharing(opts, router, subSlot(slot, 'm'))
+ const matchSelection = useStore(
+ matchStore ?? dummyStore,
+ (match: any) => (match ? selector(match) : dummyStore),
+ undefined,
+ subSlot(slot, 'm:us'),
+ )
+
+ if (matchSelection !== dummyStore) {
+ return matchSelection
+ }
+ if (opts.shouldThrow ?? true) {
+ throw new Error(
+ `Invariant failed: Could not find ${
+ opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'
+ }`,
+ )
+ }
+ return undefined
+}
+
+export function useParams<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TThrow extends boolean = true,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts: UseParamsOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ ThrowConstraint,
+ TSelected,
+ TStructuralSharing
+ >,
+): ThrowOrOptional, TThrow>
+export function useParams(...args: Array): any {
+ return useParamsImpl(args)
+}
+
+function useParamsImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ return useMatchImpl([
+ {
+ from: opts.from,
+ strict: opts.strict,
+ shouldThrow: opts.shouldThrow,
+ structuralSharing: opts.structuralSharing,
+ select: (match: any) => {
+ const params =
+ opts.strict === false ? match.params : match._strictParams
+ return opts.select ? opts.select(params) : params
+ },
+ },
+ subSlot(slot, 'params'),
+ ])
+}
+
+export function useSearch<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TThrow extends boolean = true,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts: UseSearchOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ ThrowConstraint,
+ TSelected,
+ TStructuralSharing
+ >,
+): ThrowOrOptional, TThrow>
+export function useSearch(...args: Array): any {
+ return useSearchImpl(args)
+}
+
+function useSearchImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ return useMatchImpl([
+ {
+ from: opts.from,
+ strict: opts.strict,
+ shouldThrow: opts.shouldThrow,
+ structuralSharing: opts.structuralSharing,
+ select: (match: any) =>
+ opts.select ? opts.select(match.search) : match.search,
+ },
+ subSlot(slot, 'search'),
+ ])
+}
+
+export function useLoaderData<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts: UseLoaderDataOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ TSelected,
+ TStructuralSharing
+ >,
+): UseLoaderDataResult
+export function useLoaderData(...args: Array): any {
+ return useLoaderDataImpl(args)
+}
+
+function useLoaderDataImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ return useMatchImpl([
+ {
+ from: opts.from,
+ strict: opts.strict,
+ structuralSharing: opts.structuralSharing,
+ select: (match: any) =>
+ opts.select ? opts.select(match.loaderData) : match.loaderData,
+ },
+ subSlot(slot, 'loader'),
+ ])
+}
+
+export function useLoaderDeps<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts: UseLoaderDepsOptions,
+): UseLoaderDepsResult
+export function useLoaderDeps(...args: Array): any {
+ return useLoaderDepsImpl(args)
+}
+
+function useLoaderDepsImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ const { select, ...rest } = opts
+ return useMatchImpl([
+ {
+ ...rest,
+ select: (match: any) =>
+ select ? select(match.loaderDeps) : match.loaderDeps,
+ },
+ subSlot(slot, 'deps'),
+ ])
+}
+
+export function useRouteContext<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string | undefined = undefined,
+ TStrict extends boolean = true,
+ TSelected = unknown,
+>(
+ opts: UseRouteContextOptions,
+): UseRouteContextResult
+export function useRouteContext(...args: Array): any {
+ return useRouteContextImpl(args)
+}
+
+function useRouteContextImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ return useMatchImpl([
+ {
+ ...opts,
+ select: (match: any) =>
+ opts.select ? opts.select(match.context) : match.context,
+ },
+ subSlot(slot, 'ctx'),
+ ])
+}
+
+export function useLocation<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseLocationBaseOptions &
+ StructuralSharingOption,
+): UseLocationResult
+export function useLocation(...args: Array): any {
+ return useLocationImpl(args)
+}
+
+function useLocationImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ const router = useRouter()
+ return useStore(
+ router.stores.location,
+ useStructuralSharing(opts, router, subSlot(slot, 'loc')),
+ undefined,
+ subSlot(slot, 'loc:us'),
+ )
+}
+
+export function useMatches<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseMatchesBaseOptions &
+ StructuralSharingOption,
+): UseMatchesResult
+export function useMatches(...args: Array): any {
+ return useMatchesImpl(args)
+}
+
+function useMatchesImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ const router = useRouter()
+ return useStore(
+ router.stores.matches,
+ useStructuralSharing(opts, router, subSlot(slot, 'matches')),
+ undefined,
+ subSlot(slot, 'matches:us'),
+ )
+}
+
+export function useParentMatches<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseMatchesBaseOptions &
+ StructuralSharingOption,
+): UseMatchesResult
+export function useParentMatches(...args: Array): any {
+ return useParentMatchesImpl(args)
+}
+
+function useParentMatchesImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ const contextMatchId = useContext(matchContext)
+ return useMatchesImpl([
+ {
+ select: (matches: Array) => {
+ matches = matches.slice(
+ 0,
+ matches.findIndex((d: any) => d.id === contextMatchId),
+ )
+ return opts.select ? opts.select(matches) : matches
+ },
+ structuralSharing: opts.structuralSharing,
+ },
+ subSlot(slot, 'parents'),
+ ])
+}
+
+export function useChildMatches<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseMatchesBaseOptions &
+ StructuralSharingOption,
+): UseMatchesResult
+export function useChildMatches(...args: Array): any {
+ return useChildMatchesImpl(args)
+}
+
+function useChildMatchesImpl(args: Array): any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ const contextMatchId = useContext(matchContext)
+ return useMatchesImpl([
+ {
+ select: (matches: Array) => {
+ matches = matches.slice(
+ matches.findIndex((d: any) => d.id === contextMatchId) + 1,
+ )
+ return opts.select ? opts.select(matches) : matches
+ },
+ structuralSharing: opts.structuralSharing,
+ },
+ subSlot(slot, 'children'),
+ ])
+}
+
+// Returns a STABLE navigate function (upstream useCallback([from, router])) that
+// forwards to `router.navigate`, defaulting `from` to the hook's option.
+export function useNavigate<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TDefaultFrom extends string = string,
+>(options?: {
+ from?: FromPathOption
+}): UseNavigateResult
+export function useNavigate(...args: Array): (to: any) => any {
+ return useNavigateImpl(args)
+}
+
+function useNavigateImpl(args: Array): (to: any) => any {
+ const [user, slot] = splitSlot(args)
+ const opts = user[0] ?? {}
+ const router = useRouter(opts.router ? { router: opts.router } : undefined)
+ return useCallback(
+ (options: any) =>
+ router.navigate({ ...options, from: options?.from ?? opts.from }),
+ [opts.from, router],
+ subSlot(slot, 'nav'),
+ )
+}
+
+// True when the current history entry isn't the first (there is somewhere to go
+// back to) — per upstream useCanGoBack (location.state.__TSR_index !== 0).
+export function useCanGoBack(): boolean
+export function useCanGoBack(...args: Array): boolean {
+ const [, slot] = splitSlot(args)
+ const router = useRouter()
+ return useStore(
+ router.stores.location,
+ (location: any) => location.state.__TSR_index !== 0,
+ undefined,
+ subSlot(slot, 'back'),
+ )
+}
+
+/** @internal Framework primitives use explicit Octane hook slots. */
+export const internalHooks = {
+ useMatch: (...args: Array) => useMatchImpl(args),
+ useParams: (...args: Array) => useParamsImpl(args),
+ useSearch: (...args: Array) => useSearchImpl(args),
+ useLoaderData: (...args: Array) => useLoaderDataImpl(args),
+ useLoaderDeps: (...args: Array) => useLoaderDepsImpl(args),
+ useRouteContext: (...args: Array) => useRouteContextImpl(args),
+ useLocation: (...args: Array) => useLocationImpl(args),
+ useMatches: (...args: Array) => useMatchesImpl(args),
+ useParentMatches: (...args: Array) => useParentMatchesImpl(args),
+ useChildMatches: (...args: Array) => useChildMatchesImpl(args),
+ useNavigate: (...args: Array) => useNavigateImpl(args),
+}
diff --git a/packages/octane-router/src/index.ts b/packages/octane-router/src/index.ts
new file mode 100644
index 0000000000..cb37676f16
--- /dev/null
+++ b/packages/octane-router/src/index.ts
@@ -0,0 +1,195 @@
+// @tanstack/octane-router — TanStack Router for the Octane renderer.
+//
+// TanStack Router splits a framework-agnostic core (`@tanstack/router-core`: the
+// Router/route-tree/matching/history/reactive-store) from a thin React binding
+// (`@tanstack/react-router`). This package re-exports the core verbatim and
+// implements the framework binding on Octane's hooks. The
+// load-bearing seam is router-core's reactive store: `createRouter` supplies the
+// CLIENT store factory (`createAtom`/`batch` from `@tanstack/store`), whose atoms
+// expose `.subscribe`/`.get` — bound to octane's `useSyncExternalStore` by
+// `useStore`. The match tree renders pull-based: `RouterProvider` → first match →
+// each route's `` looks up the NEXT match via `matchContext`.
+//
+// Scope: code- and file-based routing at framework parity — RouterProvider (+
+// RouterContextProvider/Wrap/InnerWrap), the full Match pipeline (Suspense /
+// CatchBoundary / CatchNotFound per route, pending/error/redirect statuses,
+// remountDeps, shellComponent), the router event lifecycle
+// (onLoad/onBeforeRouteMount/onResolved/onRendered + resolvedLocation — scroll
+// restoration restores off it), Link with preloading/masking/active-options,
+// createLink/useLinkProps, navigation blocking (useBlocker/Block), the full
+// read-hook set (useMatch and friends, nearest-match resolution via
+// matchContext), Route/getRouteApi hook accessors, Await/useAwaited, lazy
+// routes, search validation/middleware from core, generator integration, and
+// Start-compatible SSR/head/script entries. Devtools remain separate.
+import './frameworkTypes'
+
+export * from '@tanstack/router-core'
+export type {
+ AsyncRouteComponent,
+ DefaultRouteTypes,
+ ErrorRouteComponent,
+ NotFoundRouteComponent,
+ RouteComponent,
+ RouteTypes,
+} from './frameworkTypes'
+export {
+ createHistory,
+ createBrowserHistory,
+ createHashHistory,
+ createMemoryHistory,
+} from './history'
+// History types on the main entry (upstream parity). `NavigateOptions` is NOT
+// re-exported from history — router-core's richer NavigateOptions wins.
+export type {
+ RouterHistory,
+ HistoryLocation,
+ ParsedPath,
+ HistoryState,
+ ParsedHistoryState,
+ HistoryAction,
+ BlockerFnArgs,
+ BlockerFn,
+ NavigationBlocker,
+} from '@tanstack/history'
+
+export { createRouter, Router } from './router'
+export {
+ createRoute,
+ createRootRoute,
+ createRootRouteWithContext,
+ rootRouteWithContext,
+ createRouteMask,
+ getRouteApi,
+ Route,
+ RootRoute,
+ RouteApi,
+ NotFoundRoute,
+} from './route'
+export type { AnyRootRoute } from './route'
+export {
+ FileRoute,
+ createFileRoute,
+ FileRouteLoader,
+ LazyRoute,
+ createLazyRoute,
+ createLazyFileRoute,
+} from './fileRoute'
+export {
+ routerContext,
+ getRouterContext,
+ matchContext,
+ useRouter,
+} from './context'
+export { useStore } from './useStore'
+export { useRouterState } from './useRouterState'
+export type {
+ UseRouterStateOptions,
+ UseRouterStateResult,
+} from './useRouterState'
+export {
+ useMatch,
+ useLocation,
+ useParams,
+ useSearch,
+ useLoaderData,
+ useLoaderDeps,
+ useRouteContext,
+ useMatches,
+ useParentMatches,
+ useChildMatches,
+ useNavigate,
+ useCanGoBack,
+} from './hooks'
+export type {
+ UseLoaderDataBaseOptions,
+ UseLoaderDataOptions,
+ UseLoaderDataRoute,
+ UseLoaderDepsBaseOptions,
+ UseLoaderDepsOptions,
+ UseLoaderDepsRoute,
+ UseLocationBaseOptions,
+ UseLocationResult,
+ UseMatchBaseOptions,
+ UseMatchOptions,
+ UseMatchResult,
+ UseMatchRoute,
+ UseMatchesBaseOptions,
+ UseMatchesResult,
+ UseParamsBaseOptions,
+ UseParamsOptions,
+ UseParamsRoute,
+ UseRouteContextRoute,
+ UseSearchBaseOptions,
+ UseSearchOptions,
+ UseSearchRoute,
+} from './routeHookTypes'
+export { useAwaited } from './useAwaited'
+export type { AwaitOptions } from './useAwaited'
+export { useLinkProps, createLink, linkOptions } from './link'
+export type { LinkOptionsFn, LinkOptionsFnOptions } from './link'
+export type {
+ ActiveLinkOptionProps,
+ ActiveLinkOptions,
+ CreateLinkProps,
+ LinkComponent,
+ LinkComponentProps,
+ LinkComponentRoute,
+ LinkProps,
+ LinkPropsChildren,
+ OctaneAnchorProps,
+ OctaneRenderable,
+ UseLinkPropsOptions,
+} from './linkTypes'
+export { useBlocker, Block } from './useBlocker.tsrx'
+export type {
+ BlockerResolver,
+ ShouldBlockFn,
+ ShouldBlockFnArgs,
+ UseBlockerOpts,
+} from './useBlocker.tsrx'
+export { useMatchRoute, MatchRoute } from './MatchRoute.tsrx'
+export type {
+ MakeMatchRouteOptions,
+ MatchRouteFn,
+ UseMatchRouteOptions,
+} from './MatchRoute.tsrx'
+export { useElementScrollRestoration } from './useElementScrollRestoration'
+export { lazyRouteComponent } from './lazyRouteComponent'
+
+export { RouterProvider, RouterContextProvider } from './RouterProvider.tsrx'
+export type { RouterProps } from './RouterProvider.tsrx'
+export { Outlet } from './Outlet.tsrx'
+export { Link } from './Link.tsrx'
+export { Navigate } from './Navigate.tsrx'
+export { Await } from './Await.tsrx'
+export { ScrollRestoration } from './ScrollRestoration.tsrx'
+export { Matches } from './Matches.tsrx'
+export { Match } from './Match.tsrx'
+export { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx'
+export { CatchNotFound, DefaultGlobalNotFound } from './not-found.tsrx'
+export { ClientOnly, useHydrated } from './ClientOnly.tsrx'
+export { HeadContent } from './HeadContent.tsrx'
+export type { HeadContentProps } from './HeadContent.tsrx'
+export { Scripts } from './Scripts.tsrx'
+export { ScriptOnce } from './ScriptOnce.tsrx'
+export { Asset } from './Asset.tsrx'
+export type { AssetProps } from './Asset.tsrx'
+export { useTags } from './headContentUtils'
+export { Html } from './Html'
+export type { HtmlProps } from './Html'
+export { Head } from './Head'
+export type { HeadProps } from './Head'
+export { Body } from './Body'
+export type { BodyProps } from './Body'
+export type {
+ OctaneElementAttributes,
+ OctaneScriptAttributes,
+} from './frameworkTypes'
+
+export type {
+ InferStructuralSharing,
+ ValidateLinkOptions,
+ ValidateLinkOptionsArray,
+ ValidateUseParamsOptions,
+ ValidateUseSearchOptions,
+} from './typePrimitives'
diff --git a/packages/octane-router/src/internal.ts b/packages/octane-router/src/internal.ts
new file mode 100644
index 0000000000..e1443a011a
--- /dev/null
+++ b/packages/octane-router/src/internal.ts
@@ -0,0 +1,40 @@
+// Slot mechanics shared by the binding's plain-`.ts` hooks (copied from
+// Octane's TanStack bindings). The Octane compiler injects a per-call-site Symbol slot into
+// every hook call in `.tsrx`/`.tsx`, but these binding files are NOT compiled — so
+// a hook here receives the caller's slot as its trailing argument and derives a
+// distinct sub-slot for each base hook it composes.
+
+// Derive a stable, distinct sub-slot from a wrapper's slot, namespaced per hook so
+// composing multiple base hooks gives each its own identity.
+// Memoized: subSlot runs on EVERY hook call every render, and the naive form
+// pays a string concat + global symbol-registry lookup each time. The cache is
+// keyed by the slot symbol itself; the minted value is byte-identical to the
+// uncached Symbol.for result, so identity is preserved across HMR re-evals and
+// the per-package copies of this helper. Key universe is bounded: slots are
+// per-call-site module constants (never minted per render).
+const subSlotCache = new Map>()
+export function subSlot(
+ slot: symbol | undefined,
+ tag: string,
+): symbol | undefined {
+ if (slot === undefined) {
+ return undefined
+ }
+ let byTag = subSlotCache.get(slot)
+ if (byTag === undefined) {
+ subSlotCache.set(slot, (byTag = new Map()))
+ }
+ let sym = byTag.get(tag)
+ if (sym === undefined) {
+ byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)))
+ }
+ return sym
+}
+
+// Split the compiler-injected (or .ts-forwarded) trailing slot off a hook's args,
+// returning the user args (everything before it) and the slot.
+export function splitSlot(args: Array): [Array, symbol | undefined] {
+ const tail = args[args.length - 1]
+ const slot = typeof tail === 'symbol' ? tail : undefined
+ return [slot !== undefined ? args.slice(0, -1) : args, slot]
+}
diff --git a/packages/octane-router/src/lazyRouteComponent.ts b/packages/octane-router/src/lazyRouteComponent.ts
new file mode 100644
index 0000000000..0634ca5d97
--- /dev/null
+++ b/packages/octane-router/src/lazyRouteComponent.ts
@@ -0,0 +1,74 @@
+// Code-split a route's component: wrap a dynamic import as a component that suspends
+// (via octane's `use`) until the module loads, then renders it. Carries `.preload()`
+// for hover/intent preloading, and reloads the page once on a stale-chunk
+// module-not-found error (the same recovery react-router does).
+//
+// createRoute({ path: 'item/$id', component: lazyRouteComponent(() => import('./Item')) })
+import { createElement, use } from 'octane'
+import { isModuleNotFoundError } from '@tanstack/router-core'
+import { toExternalHydrationThenable } from './externalHydration'
+import type { ComponentBody } from 'octane'
+import type { AsyncRouteComponent } from './frameworkTypes'
+
+export function lazyRouteComponent<
+ T extends Record,
+ TKey extends keyof T = 'default',
+>(
+ importer: () => Promise,
+ exportName?: TKey,
+): T[TKey] extends ComponentBody
+ ? AsyncRouteComponent
+ : never
+export function lazyRouteComponent(
+ importer: () => Promise,
+ exportName?: string,
+): AsyncRouteComponent> {
+ let loadPromise: Promise | undefined
+ let comp: ComponentBody> | undefined
+ let error: unknown
+ let reload = false
+
+ const load = () => {
+ if (!loadPromise) {
+ loadPromise = importer()
+ .then((res) => {
+ loadPromise = undefined
+ comp = res[exportName ?? 'default'] as ComponentBody<
+ Record
+ >
+ })
+ .catch((err) => {
+ error = err
+ if (
+ isModuleNotFoundError(error) &&
+ error instanceof Error &&
+ typeof window !== 'undefined' &&
+ typeof sessionStorage !== 'undefined'
+ ) {
+ const key = `tanstack_router_reload:${error.message}`
+ if (!sessionStorage.getItem(key)) {
+ sessionStorage.setItem(key, '1')
+ reload = true
+ }
+ }
+ })
+ }
+ return loadPromise
+ }
+
+ const Lazy: AsyncRouteComponent> = (props) => {
+ if (reload) {
+ window.location.reload()
+ throw new Promise(() => {})
+ }
+ if (error) {
+ throw error
+ }
+ if (!comp) {
+ use(toExternalHydrationThenable(load()))
+ }
+ return createElement(comp!, props)
+ }
+ Lazy.preload = load
+ return Lazy
+}
diff --git a/packages/octane-router/src/link.ts b/packages/octane-router/src/link.ts
new file mode 100644
index 0000000000..c76c9027e1
--- /dev/null
+++ b/packages/octane-router/src/link.ts
@@ -0,0 +1,508 @@
+// useLinkProps / createLink / linkOptions — port of react-router's link.tsx
+// (client path; SSR link rendering arrives with the SSR entries). All of Link's
+// behavior lives here so both `` and custom `createLink` components share
+// it: href building (mask-aware via publicHref), external-URL detection with
+// dangerous-protocol blocking, active-state detection (exactPathTest /
+// trailing-slash-aware fuzzy prefix + deepEqual partial search match),
+// preloading ('intent' with delay + touchstart/focus, 'viewport' via
+// IntersectionObserver, 'render' once on mount), the click handler (navigate
+// with replace/resetScroll/hashScrollIntoView/viewTransition/startTransition/
+// ignoreBlocker forwarded), and data-status/data-transitioning reflection.
+import {
+ createElement,
+ flushSync,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'octane'
+import {
+ deepEqual,
+ exactPathTest,
+ functionalUpdate,
+ isDangerousProtocol,
+ preloadWarning,
+ removeTrailingSlash,
+} from '@tanstack/router-core'
+import { useRouter } from './context'
+import { useStore } from './useStore'
+import { splitSlot, subSlot } from './internal'
+import { Link } from './Link.tsrx'
+import type { ComponentBody } from 'octane'
+import type { AnyRouter, RegisteredRouter } from '@tanstack/router-core'
+import type {
+ LinkComponent,
+ OctaneAnchorProps,
+ UseLinkPropsOptions,
+} from './linkTypes'
+import type {
+ ValidateLinkOptions,
+ ValidateLinkOptionsArray,
+} from './typePrimitives'
+
+const STATIC_EMPTY_OBJECT = {}
+const STATIC_ACTIVE_OBJECT = { class: 'active' }
+const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true }
+const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' }
+const STATIC_TRANSITIONING_PROPS = { 'data-transitioning': 'transitioning' }
+
+const timeoutMap = new WeakMap>()
+
+const composeHandlers =
+ (handlers: Array void)>) => (e: Event) => {
+ for (const handler of handlers) {
+ if (!handler) {
+ continue
+ }
+ if (e.defaultPrevented) {
+ return
+ }
+ handler(e)
+ }
+ }
+
+function getHrefOption(
+ publicHref: string,
+ external: boolean,
+ history: any,
+ disabled: boolean | undefined,
+): { href: string; external: boolean } | undefined {
+ if (disabled) {
+ return undefined
+ }
+ // Full URL means a rewrite changed the origin — treat as external-like.
+ if (external) {
+ return { href: publicHref, external: true }
+ }
+ return { href: history.createHref(publicHref) || '/', external: false }
+}
+
+function isSafeInternal(to: unknown): boolean {
+ if (typeof to !== 'string') {
+ return false
+ }
+ const zero = to.charCodeAt(0)
+ if (zero === 47) {
+ return to.charCodeAt(1) !== 47 // '/' but not '//'
+ }
+ return zero === 46 // '.', '..', './', '../'
+}
+
+function isCtrlEvent(e: MouseEvent): boolean {
+ return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
+}
+
+// Merge base/active/inactive styles. Objects merge like upstream; if any is a
+// string the parts join with ';' (octane host styles accept both forms).
+function mergeStyles(base: any, active: any, inactive: any): any {
+ if (!base && !active && !inactive) {
+ return undefined
+ }
+ const parts = [base, active, inactive].filter(Boolean)
+ if (parts.length === 1) {
+ return parts[0]
+ }
+ if (parts.some((p) => typeof p === 'string')) {
+ return parts
+ .map((p) =>
+ typeof p === 'string'
+ ? p.replace(/;\s*$/, '')
+ : Object.entries(p)
+ .map(
+ ([k, v]) =>
+ `${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}:${v}`,
+ )
+ .join(';'),
+ )
+ .join(';')
+ }
+ return Object.assign({}, ...parts)
+}
+
+export function useLinkProps<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string = string,
+ const TTo extends string | undefined = undefined,
+ const TMaskFrom extends string = TFrom,
+ const TMaskTo extends string = '',
+>(
+ options: UseLinkPropsOptions,
+): OctaneAnchorProps
+export function useLinkProps(...args: Array): Record {
+ const [user, slot] = splitSlot(args)
+ const options = (user[0] ?? {}) as Record
+ const router = useRouter()
+
+ const {
+ // custom props
+ activeProps,
+ inactiveProps,
+ activeOptions,
+ to,
+ preload: userPreload,
+ preloadDelay: userPreloadDelay,
+ preloadIntentProximity: _preloadIntentProximity,
+ hashScrollIntoView,
+ replace,
+ startTransition,
+ resetScroll,
+ viewTransition,
+ // element props
+ children: _children,
+ target,
+ disabled,
+ style,
+ class: klass,
+ className,
+ onClick,
+ onBlur,
+ onFocus,
+ onMouseEnter,
+ onMouseLeave,
+ onTouchStart,
+ ignoreBlocker,
+ ref: userRef,
+ // consumed by buildLocation — not spread onto the element
+ params: _params,
+ search: _search,
+ hash: _hash,
+ state: _state,
+ mask: _mask,
+ reloadDocument: _reloadDocument,
+ unsafeRelative: _unsafeRelative,
+ from: _from,
+ _fromLocation,
+ ...propsSafeToSpread
+ } = options
+
+ // Subscribe to the location (by href) — active state re-derives per commit.
+ const currentLocation: any = useStore(
+ router.stores.location,
+ (l: any) => l,
+ (prev: any, next: any) => prev.href === next.href,
+ subSlot(slot, 'lp:loc'),
+ )
+
+ const next = router.buildLocation({
+ _fromLocation: currentLocation,
+ ...options,
+ } as any)
+
+ const hrefOptionPublicHref = next.maskedLocation
+ ? next.maskedLocation.publicHref
+ : next.publicHref
+ const hrefOptionExternal = next.maskedLocation
+ ? next.maskedLocation.external
+ : next.external
+ const hrefOption = getHrefOption(
+ hrefOptionPublicHref,
+ hrefOptionExternal,
+ router.history,
+ disabled,
+ )
+
+ // External URL detection + dangerous-protocol blocking (javascript:, data:…).
+ const externalLink = (() => {
+ if (hrefOption?.external) {
+ if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) {
+ return undefined
+ }
+ return hrefOption.href
+ }
+ if (isSafeInternal(to)) {
+ return undefined
+ }
+ if (typeof to !== 'string' || to.indexOf(':') === -1) {
+ return undefined
+ }
+ try {
+ new URL(to)
+ if (isDangerousProtocol(to, router.protocolAllowlist)) {
+ return undefined
+ }
+ return to
+ } catch {
+ /* not an absolute URL */
+ }
+ return undefined
+ })()
+
+ const isActive = (() => {
+ if (externalLink) {
+ return false
+ }
+ if (activeOptions?.exact) {
+ if (
+ !exactPathTest(currentLocation.pathname, next.pathname, router.basepath)
+ ) {
+ return false
+ }
+ } else {
+ const currentPathSplit = removeTrailingSlash(
+ currentLocation.pathname,
+ router.basepath,
+ )
+ const nextPathSplit = removeTrailingSlash(next.pathname, router.basepath)
+ const pathIsFuzzyEqual =
+ currentPathSplit.startsWith(nextPathSplit) &&
+ (currentPathSplit.length === nextPathSplit.length ||
+ currentPathSplit[nextPathSplit.length] === '/')
+ if (!pathIsFuzzyEqual) {
+ return false
+ }
+ }
+ if (activeOptions?.includeSearch ?? true) {
+ const searchTest = deepEqual(currentLocation.search, next.search, {
+ partial: !activeOptions?.exact,
+ ignoreUndefined: !activeOptions?.explicitUndefined,
+ })
+ if (!searchTest) {
+ return false
+ }
+ }
+ if (activeOptions?.includeHash) {
+ return currentLocation.hash === next.hash
+ }
+ return true
+ })()
+
+ const resolvedActiveProps: Record = isActive
+ ? (functionalUpdate(activeProps, {}) ?? STATIC_ACTIVE_OBJECT)
+ : STATIC_EMPTY_OBJECT
+ const resolvedInactiveProps: Record = isActive
+ ? STATIC_EMPTY_OBJECT
+ : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT)
+
+ // Class composes clsx-style (octane normalizeClass folds arrays + falsy).
+ const resolvedClass = [
+ klass ?? className,
+ resolvedActiveProps.class ?? resolvedActiveProps.className,
+ resolvedInactiveProps.class ?? resolvedInactiveProps.className,
+ ].filter(Boolean)
+ const resolvedStyle = mergeStyles(
+ style,
+ resolvedActiveProps.style,
+ resolvedInactiveProps.style,
+ )
+
+ const [isTransitioning, setIsTransitioning] = useState(
+ false,
+ subSlot(slot, 'lp:t'),
+ )
+ const hasRenderFetched = useRef(false, subSlot(slot, 'lp:rf'))
+ const elRef = useRef(null, subSlot(slot, 'lp:el'))
+
+ const preload =
+ options.reloadDocument || externalLink
+ ? false
+ : (userPreload ?? router.options.defaultPreload)
+ const preloadDelay =
+ userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
+
+ const doPreload = useCallback(
+ () => {
+ router
+ .preloadRoute({ ...options, _builtLocation: next } as any)
+ .catch((err: unknown) => {
+ console.warn(err)
+ console.warn(preloadWarning)
+ })
+ },
+ [router, next.href],
+ subSlot(slot, 'lp:dp'),
+ )
+
+ // preload="viewport": preload when the anchor scrolls into view (100px margin).
+ useEffect(
+ () => {
+ if (disabled || preload !== 'viewport') {
+ return
+ }
+ const el = elRef.current
+ if (!el || typeof IntersectionObserver === 'undefined') {
+ return
+ }
+ const io = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ doPreload()
+ }
+ }
+ },
+ { rootMargin: '100px' },
+ )
+ io.observe(el)
+ return () => io.disconnect()
+ },
+ [disabled, preload, doPreload],
+ subSlot(slot, 'lp:io'),
+ )
+
+ // preload="render": preload once on mount.
+ useEffect(
+ () => {
+ if (hasRenderFetched.current) {
+ return
+ }
+ if (!disabled && preload === 'render') {
+ doPreload()
+ hasRenderFetched.current = true
+ }
+ },
+ [disabled, doPreload, preload],
+ subSlot(slot, 'lp:pr'),
+ )
+
+ const handleClick = (e: MouseEvent) => {
+ const elementTarget = (e.currentTarget as Element | null)?.getAttribute?.(
+ 'target',
+ )
+ const effectiveTarget = target !== undefined ? target : elementTarget
+ if (
+ !disabled &&
+ !isCtrlEvent(e) &&
+ !e.defaultPrevented &&
+ (!effectiveTarget || effectiveTarget === '_self') &&
+ e.button === 0
+ ) {
+ e.preventDefault()
+
+ flushSync(() => {
+ setIsTransitioning(true)
+ })
+ const unsub = router.subscribe('onResolved', () => {
+ unsub()
+ setIsTransitioning(false)
+ })
+
+ router.navigate({
+ ...options,
+ replace,
+ resetScroll,
+ hashScrollIntoView,
+ startTransition,
+ viewTransition,
+ ignoreBlocker,
+ })
+ }
+ }
+
+ const captureRef = (el: Element | null) => {
+ elRef.current = el
+ }
+ const composedRef = userRef ? [captureRef, userRef] : captureRef
+
+ if (externalLink) {
+ return {
+ ...propsSafeToSpread,
+ ref: composedRef,
+ href: externalLink,
+ ...(target !== undefined && { target }),
+ ...(disabled !== undefined && { disabled }),
+ ...(resolvedStyle !== undefined && { style: resolvedStyle }),
+ ...(resolvedClass.length > 0 && { class: resolvedClass }),
+ ...(onClick && { onClick }),
+ ...(onBlur && { onBlur }),
+ ...(onFocus && { onFocus }),
+ ...(onMouseEnter && { onMouseEnter }),
+ ...(onMouseLeave && { onMouseLeave }),
+ ...(onTouchStart && { onTouchStart }),
+ }
+ }
+
+ const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => {
+ if (disabled || preload !== 'intent') {
+ return
+ }
+ if (!preloadDelay) {
+ doPreload()
+ return
+ }
+ const eventTarget = e.currentTarget as EventTarget
+ if (timeoutMap.has(eventTarget)) {
+ return
+ }
+ const id = setTimeout(() => {
+ timeoutMap.delete(eventTarget)
+ doPreload()
+ }, preloadDelay)
+ timeoutMap.set(eventTarget, id)
+ }
+
+ const handleTouchStart = () => {
+ if (disabled || preload !== 'intent') {
+ return
+ }
+ doPreload()
+ }
+
+ const handleLeave = (e: MouseEvent | FocusEvent) => {
+ if (disabled || !preload || !preloadDelay) {
+ return
+ }
+ const eventTarget = e.currentTarget as EventTarget
+ const id = timeoutMap.get(eventTarget)
+ if (id) {
+ clearTimeout(id)
+ timeoutMap.delete(eventTarget)
+ }
+ }
+
+ return {
+ ...propsSafeToSpread,
+ ...resolvedActiveProps,
+ ...resolvedInactiveProps,
+ href: hrefOption?.href,
+ ref: composedRef,
+ onClick: composeHandlers([onClick, handleClick]),
+ onBlur: composeHandlers([onBlur, handleLeave]),
+ onFocus: composeHandlers([onFocus, enqueueIntentPreload]),
+ onMouseEnter: composeHandlers([onMouseEnter, enqueueIntentPreload]),
+ onMouseLeave: composeHandlers([onMouseLeave, handleLeave]),
+ onTouchStart: composeHandlers([onTouchStart, handleTouchStart]),
+ disabled: !!disabled,
+ ...(target !== undefined && { target }),
+ ...(resolvedStyle !== undefined && { style: resolvedStyle }),
+ ...(resolvedClass.length > 0 && { class: resolvedClass }),
+ ...(disabled && STATIC_DISABLED_PROPS),
+ ...(isActive && STATIC_ACTIVE_PROPS),
+ ...(isTransitioning && STATIC_TRANSITIONING_PROPS),
+ }
+}
+
+// Wrap a design-system component so it navigates like — the component
+// receives the fully-built link props (href, handlers, data-status, …).
+export function createLink>(
+ Comp: TComp,
+): LinkComponent
+export function createLink(Comp: any): any {
+ return function CreatedLink(props: any) {
+ return createElement(Link as any, { ...props, _asChild: Comp })
+ }
+}
+
+// Identity helper for pre-validating navigation options (type-level upstream).
+export type LinkOptionsFnOptions<
+ TOptions,
+ TComp,
+ TRouter extends AnyRouter = RegisteredRouter,
+> =
+ TOptions extends ReadonlyArray
+ ? ValidateLinkOptionsArray
+ : ValidateLinkOptions
+
+export type LinkOptionsFn = <
+ const TOptions,
+ TRouter extends AnyRouter = RegisteredRouter,
+>(
+ options: LinkOptionsFnOptions,
+) => TOptions
+
+export function linkOptions<
+ const TOptions,
+ TRouter extends AnyRouter = RegisteredRouter,
+>(options: LinkOptionsFnOptions): TOptions
+export function linkOptions(options: unknown): unknown {
+ return options
+}
diff --git a/packages/octane-router/src/linkTypes.ts b/packages/octane-router/src/linkTypes.ts
new file mode 100644
index 0000000000..22a0df118f
--- /dev/null
+++ b/packages/octane-router/src/linkTypes.ts
@@ -0,0 +1,139 @@
+import type { ComponentBody, ElementDescriptor } from 'octane'
+import type {
+ AnyRouter,
+ LinkOptions,
+ RegisteredRouter,
+ RoutePaths,
+} from '@tanstack/router-core'
+
+export interface OctaneAnchorProps {
+ children?: OctaneRenderable
+ class?: unknown
+ className?: string
+ style?: unknown
+ href?: string
+ target?: string
+ disabled?: boolean
+ ref?: unknown
+ onClick?: (event: MouseEvent) => void
+ onBlur?: (event: FocusEvent) => void
+ onFocus?: (event: FocusEvent) => void
+ onMouseEnter?: (event: MouseEvent) => void
+ onMouseLeave?: (event: MouseEvent) => void
+ onTouchStart?: (event: TouchEvent) => void
+ [key: string]: unknown
+}
+
+export type OctaneRenderable =
+ | ElementDescriptor
+ | string
+ | number
+ | bigint
+ | boolean
+ | null
+ | undefined
+ | ReadonlyArray
+
+type OctaneComponentProps = TComp extends 'a'
+ ? OctaneAnchorProps
+ : TComp extends ComponentBody
+ ? TProps
+ : Record
+
+export type UseLinkPropsOptions<
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends RoutePaths | string = string,
+ TTo extends string | undefined = '.',
+ TMaskFrom extends RoutePaths | string = TFrom,
+ TMaskTo extends string = '.',
+> = ActiveLinkOptions<'a', TRouter, TFrom, TTo, TMaskFrom, TMaskTo> &
+ OctaneAnchorProps
+
+export type ActiveLinkOptions<
+ TComp = 'a',
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends string = string,
+ TTo extends string | undefined = '.',
+ TMaskFrom extends string = TFrom,
+ TMaskTo extends string = '.',
+> = LinkOptions &
+ ActiveLinkOptionProps
+
+type ActiveLinkProps = Partial<
+ OctaneComponentProps & {
+ [key: `data-${string}`]: unknown
+ }
+>
+
+export interface ActiveLinkOptionProps {
+ activeProps?: ActiveLinkProps | (() => ActiveLinkProps)
+ inactiveProps?: ActiveLinkProps | (() => ActiveLinkProps)
+}
+
+export type LinkProps<
+ TComp = 'a',
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends string = string,
+ TTo extends string | undefined = '.',
+ TMaskFrom extends string = TFrom,
+ TMaskTo extends string = '.',
+> = ActiveLinkOptions &
+ LinkPropsChildren
+
+export interface LinkPropsChildren {
+ children?:
+ | OctaneRenderable
+ | ((state: { isActive: boolean; isTransitioning: boolean }) => unknown)
+}
+
+export type CreateLinkProps = LinkProps<
+ unknown,
+ AnyRouter,
+ string,
+ string,
+ string,
+ string
+>
+
+export type LinkComponentProps<
+ TComp = 'a',
+ TRouter extends AnyRouter = RegisteredRouter,
+ TFrom extends string = string,
+ TTo extends string | undefined = '.',
+ TMaskFrom extends string = TFrom,
+ TMaskTo extends string = '.',
+> = Omit, keyof CreateLinkProps> &
+ LinkProps
+
+export type LinkComponent<
+ in out TComp,
+ in out TDefaultFrom extends string = string,
+> = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string = TDefaultFrom,
+ const TTo extends string | undefined = undefined,
+ const TMaskFrom extends string = TFrom,
+ const TMaskTo extends string = '',
+>(
+ props: LinkComponentProps,
+) => void
+
+export interface LinkComponentRoute<
+ in out TDefaultFrom extends string = string,
+> {
+ defaultFrom: TDefaultFrom;
+ <
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TTo extends string | undefined = undefined,
+ const TMaskTo extends string = '',
+ >(
+ props: LinkComponentProps<
+ 'a',
+ TRouter,
+ this['defaultFrom'],
+ TTo,
+ this['defaultFrom'],
+ TMaskTo
+ >,
+ ): void
+}
diff --git a/packages/octane-router/src/not-found.tsrx b/packages/octane-router/src/not-found.tsrx
new file mode 100644
index 0000000000..ff8ce5f224
--- /dev/null
+++ b/packages/octane-router/src/not-found.tsrx
@@ -0,0 +1,41 @@
+// CatchNotFound — a CatchBoundary specialized to `notFound()` errors (port of
+// react-router's not-found.tsx). Anything that isn't a NotFoundError is rethrown
+// (from onCatch during the catch render) so it reaches the next error boundary
+// up; the reset key is `not-found-${pathname}-${status}` so navigating away (or a
+// new load settling) clears the not-found UI.
+import { isNotFound } from '@tanstack/router-core';
+import { useRouter } from './context.ts';
+import { useStore } from './useStore.ts';
+import { CatchBoundary } from './CatchBoundary.tsrx';
+
+function NotFoundFallback(props) {
+ if (isNotFound(props.error)) {
+ return props.render ? props.render(props.error) : null;
+ }
+ throw props.error;
+}
+
+export function CatchNotFound(props) @{
+ const router = useRouter();
+ const pathname = useStore(router.stores.location, (l) => l.pathname);
+ const status = useStore(router.stores.status, (s) => s);
+ const resetKey = `not-found-${pathname}-${status}`;
+
+ resetKey}
+ onCatch={(error, errorInfo) => {
+ if (isNotFound(error)) {
+ if (props.onCatch) {
+ props.onCatch(error, errorInfo);
+ }
+ } else {
+ throw error;
+ }
+ }}
+ errorComponent={(p) => NotFoundFallback({ error: p.error, render: props.fallback })}
+ >{props.children}
+}
+
+export function DefaultGlobalNotFound() @{
+