@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.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/package.json +47 -0
- package/src/Await.tsrx +21 -0
- package/src/Await.tsrx.d.ts +6 -0
- package/src/CatchBoundary.tsrx +91 -0
- package/src/CatchBoundary.tsrx.d.ts +8 -0
- package/src/ClientOnly.tsrx +21 -0
- package/src/ClientOnly.tsrx.d.ts +3 -0
- package/src/Link.tsrx +32 -0
- package/src/Link.tsrx.d.ts +4 -0
- package/src/Match.tsrx +204 -0
- package/src/Match.tsrx.d.ts +2 -0
- package/src/MatchRoute.tsrx +32 -0
- package/src/MatchRoute.tsrx.d.ts +3 -0
- package/src/Matches.tsrx +58 -0
- package/src/Matches.tsrx.d.ts +2 -0
- package/src/Navigate.tsrx +14 -0
- package/src/Navigate.tsrx.d.ts +2 -0
- package/src/Outlet.tsrx +49 -0
- package/src/Outlet.tsrx.d.ts +2 -0
- package/src/RouteNotFound.tsrx +23 -0
- package/src/RouterProvider.tsrx +37 -0
- package/src/RouterProvider.tsrx.d.ts +8 -0
- package/src/SafeFragment.tsrx +12 -0
- package/src/SafeFragment.tsrx.d.ts +2 -0
- package/src/ScrollRestoration.tsrx +16 -0
- package/src/ScrollRestoration.tsrx.d.ts +2 -0
- package/src/Transitioner.tsrx +126 -0
- package/src/context.ts +27 -0
- package/src/history.ts +10 -0
- package/src/hooks.ts +225 -0
- package/src/index.ts +91 -0
- package/src/internal.ts +31 -0
- package/src/lazyRouteComponent.ts +56 -0
- package/src/link.ts +383 -0
- package/src/not-found.tsrx +39 -0
- package/src/not-found.tsrx.d.ts +7 -0
- package/src/route.ts +147 -0
- package/src/router.ts +63 -0
- package/src/useAwaited.ts +9 -0
- package/src/useBlocker.tsrx +136 -0
- package/src/useBlocker.tsrx.d.ts +3 -0
- package/src/useElementScrollRestoration.ts +12 -0
- package/src/useRouterState.ts +17 -0
- package/src/useStore.ts +46 -0
- 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,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
|
+
}
|
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,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
|
+
}
|
package/src/Matches.tsrx
ADDED
|
@@ -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
|
+
}
|