@octanejs/tanstack-router 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/package.json +47 -0
  4. package/src/Await.tsrx +21 -0
  5. package/src/Await.tsrx.d.ts +6 -0
  6. package/src/CatchBoundary.tsrx +91 -0
  7. package/src/CatchBoundary.tsrx.d.ts +8 -0
  8. package/src/ClientOnly.tsrx +21 -0
  9. package/src/ClientOnly.tsrx.d.ts +3 -0
  10. package/src/Link.tsrx +32 -0
  11. package/src/Link.tsrx.d.ts +4 -0
  12. package/src/Match.tsrx +204 -0
  13. package/src/Match.tsrx.d.ts +2 -0
  14. package/src/MatchRoute.tsrx +32 -0
  15. package/src/MatchRoute.tsrx.d.ts +3 -0
  16. package/src/Matches.tsrx +58 -0
  17. package/src/Matches.tsrx.d.ts +2 -0
  18. package/src/Navigate.tsrx +14 -0
  19. package/src/Navigate.tsrx.d.ts +2 -0
  20. package/src/Outlet.tsrx +49 -0
  21. package/src/Outlet.tsrx.d.ts +2 -0
  22. package/src/RouteNotFound.tsrx +23 -0
  23. package/src/RouterProvider.tsrx +37 -0
  24. package/src/RouterProvider.tsrx.d.ts +8 -0
  25. package/src/SafeFragment.tsrx +12 -0
  26. package/src/SafeFragment.tsrx.d.ts +2 -0
  27. package/src/ScrollRestoration.tsrx +16 -0
  28. package/src/ScrollRestoration.tsrx.d.ts +2 -0
  29. package/src/Transitioner.tsrx +126 -0
  30. package/src/context.ts +27 -0
  31. package/src/history.ts +10 -0
  32. package/src/hooks.ts +225 -0
  33. package/src/index.ts +91 -0
  34. package/src/internal.ts +31 -0
  35. package/src/lazyRouteComponent.ts +56 -0
  36. package/src/link.ts +383 -0
  37. package/src/not-found.tsrx +39 -0
  38. package/src/not-found.tsrx.d.ts +7 -0
  39. package/src/route.ts +147 -0
  40. package/src/router.ts +63 -0
  41. package/src/useAwaited.ts +9 -0
  42. package/src/useBlocker.tsrx +136 -0
  43. package/src/useBlocker.tsrx.d.ts +3 -0
  44. package/src/useElementScrollRestoration.ts +12 -0
  45. package/src/useRouterState.ts +17 -0
  46. package/src/useStore.ts +46 -0
  47. package/src/utils.ts +18 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @octanejs/tanstack-router
2
+
3
+ [TanStack Router](https://tanstack.com/router) for the [octane](https://github.com/octanejs/octane) UI framework.
4
+
5
+ TanStack Router splits a framework-agnostic core (`@tanstack/router-core` — the
6
+ router, route tree, matching, history, and the reactive store) from a thin React
7
+ binding (`@tanstack/react-router`). Mirroring `@octanejs/tanstack-query`, this package
8
+ re-exports the core **verbatim** and reimplements only the binding on octane's hooks.
9
+ The public surface matches `@tanstack/react-router`, so most router code works by
10
+ changing the import.
11
+
12
+ ```tsx
13
+ import {
14
+ createRouter,
15
+ createRootRoute,
16
+ createRoute,
17
+ RouterProvider,
18
+ Outlet,
19
+ Link,
20
+ useParams,
21
+ } from '@octanejs/tanstack-router';
22
+ import { createRoot } from 'octane';
23
+
24
+ const rootRoute = createRootRoute({ component: RootLayout });
25
+ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Home });
26
+ const itemRoute = createRoute({ getParentRoute: () => rootRoute, path: 'item/$id', component: Item });
27
+
28
+ const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute, itemRoute]) });
29
+
30
+ createRoot(document.getElementById('app')!).render(() => <RouterProvider router={router} />);
31
+
32
+ function RootLayout() @{
33
+ <div>
34
+ <Link to="/">{'Home'}</Link>
35
+ <Outlet />
36
+ </div>
37
+ }
38
+
39
+ function Item() @{
40
+ const { id } = useParams({ strict: false });
41
+ <h1>{('Item ' + id) as string}</h1>
42
+ }
43
+ ```
44
+
45
+ The same code works in React-style `.tsx` too (`className`, `return <jsx>`, `<Link>Home</Link>`) — see `tests/_fixtures/basic-react.tsx`.
46
+
47
+ ## How it works
48
+
49
+ `@tanstack/router-core` keeps the router state in a reactive store. On the client,
50
+ `createRouter` supplies the store factory (`createAtom`/`batch` from
51
+ `@tanstack/store` — framework-agnostic), whose atoms expose `.subscribe`/`.get`.
52
+ `useStore` binds those to octane's `useSyncExternalStore`, and every read hook
53
+ (`useRouterState`, `useLocation`, `useParams`, …) is a selector over it. The match
54
+ tree renders **pull-based**: `RouterProvider` renders the first match, and each route
55
+ component's `<Outlet/>` looks up the next match via `matchContext` — so navigation
56
+ re-renders only the matches that changed.
57
+
58
+ ## v1 scope
59
+
60
+ Included: `createRouter`, `createRootRoute`, `createRoute`, `RouterProvider`,
61
+ `Outlet`, `Link`, `Navigate`, `useRouter`, `useRouterState`, `useLocation`,
62
+ `useParams`, `useSearch`, `useLoaderData`, `useMatches`, `useNavigate`,
63
+ **`ScrollRestoration`**, **`Await`/`useAwaited` + `defer` (streaming deferred data)**,
64
+ and **`lazyRouteComponent` (lazy/code-split route components)**, and **not-found
65
+ rendering** (`notFoundComponent`/`defaultNotFoundComponent`/`notFoundMode` — an
66
+ unknown URL or a loader throwing `notFound()` renders the not-found UI inside the
67
+ layout, per TanStack's fuzzy/root boundary rules), plus the full
68
+ `@tanstack/router-core` re-export (`redirect`, `notFound`, history, search helpers,
69
+ types).
70
+
71
+ ```tsx
72
+ // streaming a deferred loader value
73
+ <Await promise={data.slow} fallback={'loading…'}>
74
+ {(value) => <pre>{JSON.stringify(value) as string}</pre>}
75
+ </Await>
76
+
77
+ // scroll restoration: either the component or createRouter({ scrollRestoration: true })
78
+ <ScrollRestoration />
79
+
80
+ // code-split a route's component
81
+ createRoute({ path: 'item/$id', component: lazyRouteComponent(() => import('./Item')) })
82
+ ```
83
+
84
+ Deferred: file-based routing + the codegen plugin, devtools, search-param
85
+ validation/middleware, `useBlocker`, SSR head/scripts.
86
+
87
+ ## Divergences from `@tanstack/react-router`
88
+
89
+ - **Refs are props** (octane's model) — `createLink`'s `forwardRef` becomes a `ref`
90
+ prop.
91
+ - **No `flushSync`** in the `Link` click handler (the one hard `react-dom`
92
+ coupling) — navigation state updates run synchronously in v1.
93
+ - `Link` v1 reflects active state via `data-status="active"` + `aria-current`; the
94
+ `activeProps`/`inactiveProps` className-merge API is a follow-up.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@octanejs/tanstack-router",
3
+ "version": "0.1.2",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "TanStack Router bindings for the octane renderer — reuses @tanstack/router-core and swaps the React binding for octane's hooks.",
7
+ "author": {
8
+ "name": "Dominic Gannaway",
9
+ "email": "dg@domgan.com"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/octanejs/octane.git",
17
+ "directory": "packages/tanstack-router"
18
+ },
19
+ "main": "src/index.ts",
20
+ "module": "src/index.ts",
21
+ "types": "src/index.ts",
22
+ "files": [
23
+ "src",
24
+ "README.md"
25
+ ],
26
+ "exports": {
27
+ ".": "./src/index.ts",
28
+ "./history": "./src/history.ts"
29
+ },
30
+ "dependencies": {
31
+ "@tanstack/history": "1.162.0",
32
+ "@tanstack/router-core": "1.171.13",
33
+ "@tanstack/store": "^0.9.3",
34
+ "octane": "0.1.3"
35
+ },
36
+ "devDependencies": {
37
+ "@tanstack/react-router": "1.170.16",
38
+ "@tsrx/react": "^0.2.37",
39
+ "esbuild": "^0.28.1",
40
+ "react": "^19.2.0",
41
+ "react-dom": "^19.2.0",
42
+ "vitest": "^4.1.9"
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run"
46
+ }
47
+ }
package/src/Await.tsrx ADDED
@@ -0,0 +1,21 @@
1
+ // Suspends on a deferred promise and renders its child — a render-prop
2
+ // `(data) => <jsx/>` — with the resolved value. `AwaitInner` does the `use(promise)`
3
+ // inside a `@try`/`@pending` boundary; the render function is handed to it as a
4
+ // `render` prop (re-passing it as `{children}` would be an identifier, not a literal
5
+ // render-prop, so the consumer's `<Await>{(d) => …}</Await>` stays the public API).
6
+ import { use } from 'octane';
7
+
8
+ function AwaitInner(props) {
9
+ const data = use(props.promise);
10
+ return props.render(data);
11
+ }
12
+
13
+ export function Await(props) @{
14
+ @try {
15
+ <AwaitInner promise={props.promise} render={props.children} />
16
+ } @pending {
17
+ <>
18
+ {props.fallback}
19
+ </>
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ // Type declaration for the .tsrx component (resolved by relative path).
2
+ export declare const Await: (props: {
3
+ promise: any;
4
+ children: (data: any) => unknown;
5
+ fallback?: unknown;
6
+ }) => unknown;
@@ -0,0 +1,91 @@
1
+ // react-router's CatchBoundary + default ErrorComponent, rebuilt on octane's
2
+ // `@try`/`@catch` (octane has no class components, so the upstream
3
+ // CatchBoundaryImpl class becomes a catch branch). Contract preserved from
4
+ // upstream CatchBoundary.tsx:
5
+ // - renders `children` until a descendant throws during render/effects;
6
+ // - then renders `errorComponent` with `{ error, reset }`;
7
+ // - `onCatch(error, errorInfo)` fires once per caught error (componentDidCatch
8
+ // parity). It runs during the catch-branch render — BEFORE the error UI — so
9
+ // an onCatch that rethrows (Match forwards `notFound()` errors this way)
10
+ // propagates to the parent boundary exactly like a throwing componentDidCatch;
11
+ // - when `getResetKey()` changes while an error is showing, the boundary resets
12
+ // and re-renders `children` (upstream clears the error in
13
+ // getDerivedStateFromProps; here a layout effect calls the catch reset — one
14
+ // commit later, same observable outcome). Match passes the router's `loadedAt`
15
+ // so navigation clears route error UI.
16
+ import { useState, useRef, useLayoutEffect } from 'octane';
17
+
18
+ export function CatchBoundary(props) @{
19
+ @try {
20
+ <>
21
+ {props.children}
22
+ </>
23
+ } @catch (error, reset) {
24
+ <CatchErrorView
25
+ error={error}
26
+ reset={reset}
27
+ getResetKey={props.getResetKey}
28
+ errorComponent={props.errorComponent}
29
+ onCatch={props.onCatch}
30
+ />
31
+ }
32
+ }
33
+
34
+ function CatchErrorView(props) @{
35
+ // componentDidCatch parity — once per distinct caught error, during render so a
36
+ // rethrow (the notFound-forwarding path) escapes to the parent boundary.
37
+ const reported = useRef(null);
38
+ if (reported.current !== props.error) {
39
+ reported.current = props.error;
40
+ if (props.onCatch) props.onCatch(props.error, { componentStack: '' });
41
+ }
42
+
43
+ // Reset when the reset key rolls (navigation). Skip the mount render — the key
44
+ // captured while catching is the baseline.
45
+ const resetKey =
46
+ props.getResetKey ? props.getResetKey() : undefined;
47
+ const keyRef = useRef(resetKey);
48
+ useLayoutEffect(() => {
49
+ if (keyRef.current !== resetKey) {
50
+ keyRef.current = resetKey;
51
+ props.reset();
52
+ }
53
+ }, [resetKey]);
54
+
55
+ const ErrorComp = props.errorComponent ?? ErrorComponent;
56
+
57
+ <ErrorComp error={props.error} reset={props.reset} info={{ componentStack: '' }} />
58
+ }
59
+
60
+ // The default error UI, markup-identical to react-router's ErrorComponent (string
61
+ // styles serialize to the same inline css React produces, so the differential rig
62
+ // can byte-compare it).
63
+ export function ErrorComponent(props) @{
64
+ const [show, setShow] = useState(process.env.NODE_ENV !== 'production');
65
+
66
+ <div style="padding:.5rem;max-width:100%">
67
+ <div style="display:flex;align-items:center;gap:.5rem">
68
+ <strong style="font-size:1rem">Something went wrong!</strong>
69
+ <button
70
+ style="appearance:none;font-size:.6em;border:1px solid currentColor;padding:.1rem .2rem;font-weight:bold;border-radius:.25rem"
71
+ onClick={() => setShow((d) => !d)}
72
+ >
73
+ {show ? 'Hide Error' : 'Show Error'}
74
+ </button>
75
+ </div>
76
+ <div style="height:.25rem"></div>
77
+ @if (show) {
78
+ <div>
79
+ <pre
80
+ style="font-size:.7em;border:1px solid red;border-radius:.25rem;padding:.3rem;color:red;overflow:auto"
81
+ >
82
+ @if (props.error?.message) {
83
+ <code>
84
+ {props.error.message as string}
85
+ </code>
86
+ }
87
+ </pre>
88
+ </div>
89
+ }
90
+ </div>
91
+ }
@@ -0,0 +1,8 @@
1
+ // Type declaration for the .tsrx components (resolved by relative path).
2
+ export declare const CatchBoundary: (props: {
3
+ getResetKey?: () => number | string;
4
+ errorComponent?: unknown;
5
+ onCatch?: (error: any, errorInfo: { componentStack: string }) => void;
6
+ children?: unknown;
7
+ }) => unknown;
8
+ export declare const ErrorComponent: (props: { error: any; reset?: () => void }) => unknown;
@@ -0,0 +1,21 @@
1
+ // ClientOnly / useHydrated — port of react-router's ClientOnly.tsx. Renders
2
+ // children only once the client has hydrated: `useHydrated` reads a constant
3
+ // external store whose server snapshot is `false` and client snapshot is
4
+ // `true`, so SSR (and the hydration render) yield the fallback and the first
5
+ // client-only render onward yields the children. Authored in .tsrx so the
6
+ // compiler slots the hook calls (and withSlot-disambiguates useHydrated's
7
+ // call sites).
8
+ import { useSyncExternalStore } from 'octane';
9
+
10
+ const subscribe = () => () => {};
11
+
12
+ export function useHydrated() {
13
+ return useSyncExternalStore(subscribe, () => true, () => false);
14
+ }
15
+
16
+ export function ClientOnly(props) @{
17
+ const hydrated = useHydrated();
18
+ <>
19
+ {hydrated ? props.children : props.fallback ?? null}
20
+ </>
21
+ }
@@ -0,0 +1,3 @@
1
+ // Type declaration for the .tsrx module (resolved by relative path).
2
+ export declare const ClientOnly: (props: { children?: unknown; fallback?: unknown }) => unknown;
3
+ export declare const useHydrated: () => boolean;
package/src/Link.tsrx ADDED
@@ -0,0 +1,32 @@
1
+ // A navigating anchor — a thin shell over `useLinkProps` (link.ts), which
2
+ // carries ALL of react-router Link's behavior: href building (mask-aware),
3
+ // external-URL + dangerous-protocol handling, active-state detection,
4
+ // preloading (intent/viewport/render), and the navigate click handler with
5
+ // replace/resetScroll/hashScrollIntoView/viewTransition/startTransition/
6
+ // ignoreBlocker forwarded. Children may be a render prop —
7
+ // `{({ isActive, isTransitioning }) => …}` — and `createLink` renders a custom
8
+ // component via the internal `_asChild` prop. Ref is a prop (octane's model —
9
+ // no forwardRef); `disabled` renders without an href + role="link" (upstream
10
+ // drops the anchor's disabled attribute; the aria props carry the state).
11
+ import { isChildrenBlock } from 'octane';
12
+ import { useLinkProps } from './link.ts';
13
+
14
+ export function Link(props) @{
15
+ const { _asChild: AsChild, children, ...rest } = props;
16
+ const linkProps = useLinkProps(rest);
17
+ const { disabled: _disabled, ...aProps } = linkProps;
18
+
19
+ const resolvedChildren =
20
+ typeof children === 'function' && !isChildrenBlock(children)
21
+ ? children({
22
+ isActive: linkProps['data-status'] === 'active',
23
+ isTransitioning: linkProps['data-transitioning'] === 'transitioning',
24
+ })
25
+ : children;
26
+
27
+ @if (AsChild) {
28
+ <AsChild {...linkProps}>{resolvedChildren}</AsChild>
29
+ } @else {
30
+ <a {...aProps}>{resolvedChildren}</a>
31
+ }
32
+ }
@@ -0,0 +1,4 @@
1
+ // Type declaration for the .tsrx component (resolved by relative path). Props are
2
+ // permissive for v1 (the full type-safe `to`/`params`/`search` surface from
3
+ // react-router's `LinkProps` is a follow-up).
4
+ export declare const Link: (props: Record<string, unknown>) => unknown;
package/src/Match.tsrx ADDED
@@ -0,0 +1,204 @@
1
+ // Renders one route match — port of react-router's Match.tsx (MatchView +
2
+ // MatchInner + OnRendered). `Match` subscribes to the match's routeId and the
3
+ // router's `loadedAt` (the error-boundary reset key), resolves the route's
4
+ // boundary components, and composes the pipeline exactly like upstream:
5
+ //
6
+ // Shell > matchContext > Suspense? > CatchBoundary? > CatchNotFound? > MatchInner
7
+ //
8
+ // Each boundary is only present when the route (or router defaults) configured a
9
+ // component for it — otherwise `SafeFragment` passes through so suspensions and
10
+ // errors bubble to the nearest ancestor boundary (upstream's
11
+ // ResolvedSuspenseBoundary/ResolvedCatchBoundary/ResolvedNotFoundBoundary).
12
+ // `MatchInner` handles the match STATUS: it suspends (octane `use`, upstream
13
+ // `throw promise`) on pending/redirected/_displayPending/_forcePending, renders
14
+ // the route's not-found UI for `status === 'notFound'`, throws to the
15
+ // CatchBoundary for `status === 'error'`, and otherwise renders the route
16
+ // component (or `<Outlet/>` for component-less layout routes) keyed by
17
+ // remountDeps. `OnRendered` (rendered by the match directly below the root)
18
+ // emits the router's `onRendered` event after the subtree commits — scroll
19
+ // restoration restores on it.
20
+ import { useRef, useLayoutEffect, use, createElement, Suspense } from 'octane';
21
+ import {
22
+ createControlledPromise,
23
+ getLocationChangeInfo,
24
+ isNotFound,
25
+ rootRouteId,
26
+ } from '@tanstack/router-core';
27
+ import { useStore } from './useStore.ts';
28
+ import { useRouter, matchContext } from './context.ts';
29
+ import { Outlet } from './Outlet.tsrx';
30
+ import { RouteNotFound } from './RouteNotFound.tsrx';
31
+ import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
32
+ import { CatchNotFound } from './not-found.tsrx';
33
+ import { SafeFragment } from './SafeFragment.tsrx';
34
+
35
+ export function Match(props) @{
36
+ const router = useRouter();
37
+ const matchStore = router.stores.matchStores.get(props.matchId);
38
+ const resetKey = useStore(router.stores.loadedAt, (l) => l);
39
+ const routeId = useStore(matchStore, (m) => m.routeId);
40
+ const route = router.routesById[routeId];
41
+
42
+ const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent;
43
+ const routeErrorComponent = route.options.errorComponent ?? router.options.defaultErrorComponent;
44
+ const routeOnCatch = route.options.onCatch ?? router.options.defaultOnCatch;
45
+ const routeNotFoundComponent = route.isRoot
46
+ ? route.options.notFoundComponent ?? router.options.notFoundRoute?.options.component
47
+ : route.options.notFoundComponent;
48
+
49
+ // Boundary presence, per upstream MatchView. The root route only gets a
50
+ // Suspense boundary when explicitly opted in via wrapInSuspense.
51
+ const SuspenseWrap =
52
+ (!route.isRoot || route.options.wrapInSuspense) &&
53
+ (route.options.wrapInSuspense ?? PendingComponent ?? routeErrorComponent?.preload)
54
+ ? Suspense
55
+ : SafeFragment;
56
+ const CatchWrap = routeErrorComponent ? CatchBoundary : SafeFragment;
57
+ const NotFoundWrap = routeNotFoundComponent ? CatchNotFound : SafeFragment;
58
+ const ShellComponent = route.isRoot ? route.options.shellComponent ?? SafeFragment : SafeFragment;
59
+
60
+ const pendingElement =
61
+ PendingComponent ? createElement(PendingComponent, {}) : null;
62
+ const parentRouteId = route.parentRoute?.id;
63
+
64
+ <ShellComponent>
65
+ <matchContext.Provider value={props.matchId}>
66
+ <SuspenseWrap fallback={pendingElement}>
67
+ <CatchWrap
68
+ getResetKey={() => resetKey}
69
+ errorComponent={routeErrorComponent || ErrorComponent}
70
+ onCatch={(error, errorInfo) => {
71
+ if (isNotFound(error)) {
72
+ error.routeId ??= routeId;
73
+ throw error;
74
+ }
75
+ if (routeOnCatch) routeOnCatch(error, errorInfo);
76
+ }}
77
+ >
78
+ <NotFoundWrap
79
+ fallback={(error) => {
80
+ error.routeId ??= routeId;
81
+
82
+ if (
83
+ !routeNotFoundComponent || error.routeId && error.routeId !== routeId ||
84
+ !error.routeId && !route.isRoot
85
+ ) {
86
+ throw error;
87
+ }
88
+ return createElement(routeNotFoundComponent, error);
89
+ }}
90
+ >
91
+ <MatchInner matchId={props.matchId} />
92
+ </NotFoundWrap>
93
+ </CatchWrap>
94
+ </SuspenseWrap>
95
+ </matchContext.Provider>
96
+ @if (parentRouteId === rootRouteId) {
97
+ <OnRendered />
98
+ }
99
+ </ShellComponent>
100
+ }
101
+
102
+ function MatchInner(props) {
103
+ const router = useRouter();
104
+ const matchStore = router.stores.matchStores.get(props.matchId);
105
+ const match = useStore(matchStore, (m) => m);
106
+ const routeId = match.routeId;
107
+ const route = router.routesById[routeId];
108
+
109
+ // The live match in the router (if still mounted there) wins over the
110
+ // snapshot for promise lookups, per upstream getMatchPromise.
111
+ const getMatchPromise = (key) => router.getMatch(match.id)?._nonReactive[key] ??
112
+ match._nonReactive[key];
113
+
114
+ if (match._displayPending) {
115
+ use(getMatchPromise('displayPendingPromise'));
116
+ }
117
+
118
+ if (match._forcePending) {
119
+ use(getMatchPromise('minPendingPromise'));
120
+ }
121
+
122
+ if (match.status === 'pending') {
123
+ // Once pending UI shows, keep it up for at least pendingMinMs.
124
+ const pendingMinMs = route.options.pendingMinMs ?? router.options.defaultPendingMinMs;
125
+ if (pendingMinMs) {
126
+ const routerMatch = router.getMatch(match.id);
127
+ if (routerMatch && !routerMatch._nonReactive.minPendingPromise) {
128
+ const minPendingPromise = createControlledPromise();
129
+ routerMatch._nonReactive.minPendingPromise = minPendingPromise;
130
+ setTimeout(() => {
131
+ minPendingPromise.resolve();
132
+ routerMatch._nonReactive.minPendingPromise = undefined;
133
+ }, pendingMinMs);
134
+ }
135
+ }
136
+ use(getMatchPromise('loadPromise'));
137
+ }
138
+
139
+ if (match.status === 'notFound') {
140
+ return createElement(RouteNotFound, { routeId, error: match.error });
141
+ }
142
+
143
+ if (match.status === 'redirected') {
144
+ // Observed mid-transition while a redirect is in flight — suspend on the
145
+ // load so this stale render is abandoned and the redirect completes.
146
+ use(getMatchPromise('loadPromise'));
147
+ }
148
+
149
+ if (match.status === 'error') {
150
+ throw match.error;
151
+ }
152
+
153
+ const Comp = route.options.component ?? router.options.defaultComponent;
154
+ const remountFn = route.options.remountDeps ?? router.options.defaultRemountDeps;
155
+ const remountDeps =
156
+ remountFn
157
+ ? remountFn({
158
+ routeId,
159
+ loaderDeps: match.loaderDeps,
160
+ params: match._strictParams,
161
+ search: match._strictSearch,
162
+ })
163
+ : undefined;
164
+ const key =
165
+ remountDeps ? JSON.stringify(remountDeps) : undefined;
166
+ if (Comp) return createElement(Comp, key !== undefined ? { key } : {});
167
+ return createElement(Outlet, {});
168
+ }
169
+
170
+ // Emits the router's `onRendered` event once the subtree below the root layout
171
+ // has committed (this component renders as a later sibling of the match content,
172
+ // so its layout effect runs after the subtree's). Tracks the previously-resolved
173
+ // location in a ref because by effect time Transitioner has already advanced
174
+ // `resolvedLocation` to the new location.
175
+ function OnRendered() @{
176
+ const router = useRouter();
177
+ const prevResolvedLocationRef = useRef(undefined);
178
+ const renderedLocationKey = useStore(
179
+ router.stores.resolvedLocation,
180
+ (loc) => loc?.state.__TSR_key,
181
+ );
182
+
183
+ useLayoutEffect(() => {
184
+ const currentResolvedLocation = router.stores.resolvedLocation.get();
185
+ const previousResolvedLocation = prevResolvedLocationRef.current;
186
+
187
+ if (
188
+ currentResolvedLocation &&
189
+ (!previousResolvedLocation ||
190
+ previousResolvedLocation.href !== currentResolvedLocation.href)
191
+ ) {
192
+ router.emit({
193
+ type: 'onRendered',
194
+ ...getLocationChangeInfo(
195
+ router.stores.location.get(),
196
+ previousResolvedLocation ?? currentResolvedLocation,
197
+ ),
198
+ });
199
+ }
200
+ prevResolvedLocationRef.current = currentResolvedLocation;
201
+ }, [renderedLocationKey, router]);
202
+
203
+ <></>
204
+ }
@@ -0,0 +1,2 @@
1
+ // Type declaration for the .tsrx component (resolved by relative path).
2
+ export declare const Match: (props: { matchId: string }) => unknown;
@@ -0,0 +1,32 @@
1
+ // useMatchRoute / MatchRoute — port of react-router's Matches.tsx matcher pair.
2
+ // The hook subscribes to `matchRouteDeps` (location href + resolved href +
3
+ // status) so the matcher re-evaluates per navigation, and returns
4
+ // `matchRoute(opts)` → false | matched params. The component renders its
5
+ // children when matched — a render-prop child ALWAYS renders, receiving the
6
+ // params (false when unmatched).
7
+ import { useCallback, isChildrenBlock } from 'octane';
8
+ import { useRouter } from './context.ts';
9
+ import { useStore } from './useStore.ts';
10
+
11
+ export function useMatchRoute() {
12
+ const router = useRouter();
13
+ useStore(router.stores.matchRouteDeps, (d) => d);
14
+ return useCallback((opts) => {
15
+ const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts;
16
+ return router.matchRoute(rest, { pending, caseSensitive, fuzzy, includeSearch });
17
+ }, [router]);
18
+ }
19
+
20
+ export function MatchRoute(props) @{
21
+ const matchRoute = useMatchRoute();
22
+ const params = matchRoute(props);
23
+ const out =
24
+ typeof props.children === 'function' && !isChildrenBlock(props.children)
25
+ ? props.children(params)
26
+ : params
27
+ ? props.children
28
+ : null;
29
+ <>
30
+ {out}
31
+ </>
32
+ }
@@ -0,0 +1,3 @@
1
+ // Type declaration for the .tsrx module (resolved by relative path).
2
+ export declare const MatchRoute: (props: Record<string, unknown>) => unknown;
3
+ export declare const useMatchRoute: () => (opts: Record<string, unknown>) => unknown;
@@ -0,0 +1,58 @@
1
+ // Renders the navigation engine + the match tree root — port of react-router's
2
+ // Matches.tsx. Structure mirrors upstream:
3
+ //
4
+ // InnerWrap? > Suspense(root pending) > Transitioner + MatchesInner
5
+ // MatchesInner: matchContext(firstId) > global CatchBoundary? > Match(firstId)
6
+ //
7
+ // The root Suspense is ALWAYS present on the client (fallback is the root
8
+ // route's pendingComponent ?? defaultPendingComponent ?? null) — it's the
9
+ // already-revealed boundary that lets a navigation transition hold the current
10
+ // page when the next route suspends without a boundary of its own. The global
11
+ // CatchBoundary (opt out: `disableGlobalCatchBoundary`) renders the generic
12
+ // ErrorComponent for errors no route boundary caught, reset by `loadedAt`.
13
+ import { useLayoutEffect, createElement, Suspense } from 'octane';
14
+ import { setupScrollRestoration, rootRouteId } from '@tanstack/router-core';
15
+ import { useStore } from './useStore.ts';
16
+ import { useRouter, matchContext } from './context.ts';
17
+ import { Transitioner } from './Transitioner.tsrx';
18
+ import { Match } from './Match.tsrx';
19
+ import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
20
+ import { SafeFragment } from './SafeFragment.tsrx';
21
+
22
+ export function Matches() @{
23
+ const router = useRouter();
24
+ const rootRoute = router.routesById[rootRouteId];
25
+ const PendingComponent =
26
+ rootRoute.options.pendingComponent ?? router.options.defaultPendingComponent;
27
+ const pendingElement =
28
+ PendingComponent ? createElement(PendingComponent, {}) : null;
29
+ const InnerWrap = router.options.InnerWrap ?? SafeFragment;
30
+
31
+ // `createRouter({ scrollRestoration: true })` wires scroll save/restore here, so
32
+ // it works without the (deprecated) <ScrollRestoration/> component.
33
+ useLayoutEffect(() => {
34
+ if (router.options.scrollRestoration) setupScrollRestoration(router);
35
+ }, [router]);
36
+
37
+ <InnerWrap>
38
+ <Suspense fallback={pendingElement}>
39
+ <Transitioner />
40
+ <MatchesInner />
41
+ </Suspense>
42
+ </InnerWrap>
43
+ }
44
+
45
+ function MatchesInner() @{
46
+ const router = useRouter();
47
+ const matchId = useStore(router.stores.firstId, (id) => id);
48
+ const resetKey = useStore(router.stores.loadedAt, (l) => l);
49
+ const CatchWrap = router.options.disableGlobalCatchBoundary ? SafeFragment : CatchBoundary;
50
+
51
+ <matchContext.Provider value={matchId}>
52
+ <CatchWrap getResetKey={() => resetKey} errorComponent={ErrorComponent}>
53
+ @if (matchId) {
54
+ <Match matchId={matchId} />
55
+ }
56
+ </CatchWrap>
57
+ </matchContext.Provider>
58
+ }
@@ -0,0 +1,2 @@
1
+ // Type declaration for the .tsrx component (resolved by relative path).
2
+ export declare const Matches: () => unknown;