+}
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..2b36caccf5
--- /dev/null
+++ b/packages/octane-router/src/Head.ts
@@ -0,0 +1,22 @@
+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 server = isServer ?? router.isServer
+ const { children, ...attrs } = props
+ if (server) {
+ 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..a00be92f6f
--- /dev/null
+++ b/packages/octane-router/src/Html.ts
@@ -0,0 +1,19 @@
+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 server = isServer ?? router.isServer
+ const { children, ...attrs } = props
+ if (server) {
+ 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..6a93a2d7db
--- /dev/null
+++ b/packages/octane-router/src/Link.tsrx.d.ts
@@ -0,0 +1,3 @@
+import type { LinkComponent } from './link'
+
+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..20bdbc37fe
--- /dev/null
+++ b/packages/octane-router/src/ScriptOnce.tsrx
@@ -0,0 +1,23 @@
+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 server = isServer ?? router.isServer;
+ const [hydrating, setHydrating] = useState(true);
+ useEffect(() => {
+ setHydrating(false);
+ }, []);
+
+ @if (server || 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..7004fac609
--- /dev/null
+++ b/packages/octane-router/src/fileRoute.ts
@@ -0,0 +1,284 @@
+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 './hooks'
+
+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[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..c90a3bff2e
--- /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 './hooks'
+import type { LinkComponentRoute } from './link'
+
+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..e50c10a31e
--- /dev/null
+++ b/packages/octane-router/src/generator-plugin.js
@@ -0,0 +1,103 @@
+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') {
+ 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..15efd44cc6
--- /dev/null
+++ b/packages/octane-router/src/hooks.ts
@@ -0,0 +1,720 @@
+// 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,
+ ValidateSelected,
+} from './structuralSharing'
+import type {
+ AnyRouter,
+ FromPathOption,
+ MakeRouteMatch,
+ MakeRouteMatchUnion,
+ RegisteredRouter,
+ ResolveUseLoaderData,
+ ResolveUseLoaderDeps,
+ ResolveUseParams,
+ ResolveUseSearch,
+ RouterState,
+ StrictOrFrom,
+ ThrowConstraint,
+ ThrowOrOptional,
+ UseLoaderDataResult,
+ UseLoaderDepsResult,
+ UseNavigateResult,
+ UseParamsResult,
+ UseRouteContextBaseOptions,
+ UseRouteContextOptions,
+ UseRouteContextResult,
+ UseSearchResult,
+} from '@tanstack/router-core'
+
+export interface UseMatchBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+ TStructuralSharing extends boolean,
+> {
+ select?: (
+ match: MakeRouteMatch,
+ ) => ValidateSelected
+ shouldThrow?: TThrow
+}
+
+export type UseMatchRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseMatchBaseOptions<
+ TRouter,
+ TFrom,
+ true,
+ true,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption,
+) => UseMatchResult
+
+export type UseMatchOptions<
+ TRouter extends AnyRouter,
+ TFrom extends string | undefined,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+ TStructuralSharing extends boolean,
+> = StrictOrFrom &
+ UseMatchBaseOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ TThrow,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption
+
+export type UseMatchResult<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TSelected,
+> = unknown extends TSelected
+ ? TStrict extends true
+ ? MakeRouteMatch
+ : MakeRouteMatchUnion
+ : TSelected
+
+export interface UseParamsBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+ TStructuralSharing,
+> {
+ select?: (
+ params: ResolveUseParams,
+ ) => ValidateSelected
+ shouldThrow?: TThrow
+}
+
+export type UseParamsRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseParamsBaseOptions<
+ TRouter,
+ TFrom,
+ true,
+ true,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption,
+) => UseParamsResult
+
+export type UseParamsOptions<
+ TRouter extends AnyRouter,
+ TFrom extends string | undefined,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+ TStructuralSharing,
+> = StrictOrFrom &
+ UseParamsBaseOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ TThrow,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption
+
+export interface UseSearchBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+ TStructuralSharing,
+> {
+ select?: (
+ search: ResolveUseSearch,
+ ) => ValidateSelected
+ shouldThrow?: TThrow
+}
+
+export type UseSearchRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseSearchBaseOptions<
+ TRouter,
+ TFrom,
+ true,
+ true,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption,
+) => UseSearchResult
+
+export type UseSearchOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TThrow extends boolean,
+ TSelected,
+ TStructuralSharing,
+> = StrictOrFrom &
+ UseSearchBaseOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ TThrow,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption
+
+export interface UseLoaderDataBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TStrict extends boolean,
+ TSelected,
+ TStructuralSharing,
+> {
+ select?: (
+ data: ResolveUseLoaderData,
+ ) => ValidateSelected
+}
+
+export type UseLoaderDataRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseLoaderDataBaseOptions<
+ TRouter,
+ TFrom,
+ true,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption,
+) => UseLoaderDataResult
+
+export type UseLoaderDataOptions<
+ TRouter extends AnyRouter,
+ TFrom extends string | undefined,
+ TStrict extends boolean,
+ TSelected,
+ TStructuralSharing,
+> = StrictOrFrom &
+ UseLoaderDataBaseOptions<
+ TRouter,
+ TFrom,
+ TStrict,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption
+
+export interface UseLoaderDepsBaseOptions<
+ TRouter extends AnyRouter,
+ TFrom,
+ TSelected,
+ TStructuralSharing,
+> {
+ select?: (
+ deps: ResolveUseLoaderDeps,
+ ) => ValidateSelected
+}
+
+export type UseLoaderDepsRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+ TStructuralSharing extends boolean = boolean,
+>(
+ opts?: UseLoaderDepsBaseOptions<
+ TRouter,
+ TFrom,
+ TSelected,
+ TStructuralSharing
+ > &
+ StructuralSharingOption,
+) => UseLoaderDepsResult
+
+export type UseLoaderDepsOptions<
+ TRouter extends AnyRouter,
+ TFrom extends string | undefined,
+ TSelected,
+ TStructuralSharing,
+> = StrictOrFrom &
+ UseLoaderDepsBaseOptions &
+ StructuralSharingOption
+
+export type UseRouteContextRoute = <
+ TRouter extends AnyRouter = RegisteredRouter,
+ TSelected = unknown,
+>(
+ opts?: UseRouteContextBaseOptions,
+) => UseRouteContextResult
+
+export interface UseLocationBaseOptions<
+ TRouter extends AnyRouter,
+ TSelected,
+ TStructuralSharing extends boolean = boolean,
+> {
+ select?: (
+ location: RouterState['location'],
+ ) => ValidateSelected
+}
+
+export type UseLocationResult<
+ TRouter extends AnyRouter,
+ TSelected,
+> = unknown extends TSelected
+ ? RouterState['location']
+ : TSelected
+
+export interface UseMatchesBaseOptions<
+ TRouter extends AnyRouter,
+ TSelected,
+ TStructuralSharing,
+> {
+ select?: (
+ matches: Array>,
+ ) => ValidateSelected
+}
+
+export type UseMatchesResult<
+ TRouter extends AnyRouter,
+ TSelected,
+> = unknown extends TSelected ? Array> : TSelected
+
+// 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..c6f54625ce
--- /dev/null
+++ b/packages/octane-router/src/index.ts
@@ -0,0 +1,192 @@
+// @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 './hooks'
+export { useAwaited } from './useAwaited'
+export type { AwaitOptions } from './useAwaited'
+export { useLinkProps, createLink, linkOptions } from './link'
+export type {
+ ActiveLinkOptionProps,
+ ActiveLinkOptions,
+ CreateLinkProps,
+ LinkComponent,
+ LinkComponentProps,
+ LinkComponentRoute,
+ LinkOptionsFn,
+ LinkOptionsFnOptions,
+ LinkProps,
+ LinkPropsChildren,
+ OctaneAnchorProps,
+ OctaneRenderable,
+ UseLinkPropsOptions,
+} from './link'
+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 {
+ 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..7acb5de520
--- /dev/null
+++ b/packages/octane-router/src/link.ts
@@ -0,0 +1,640 @@
+// 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 `