@octanejs/tanstack-router 0.2.9 → 0.3.0

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 (48) hide show
  1. package/README.md +23 -7
  2. package/package.json +4 -3
  3. package/src/Asset.tsrx +1 -1
  4. package/src/Await.tsrx +1 -1
  5. package/src/CatchBoundary.tsrx +11 -41
  6. package/src/ClientOnly.tsrx +1 -1
  7. package/src/ErrorComponent.tsrx +38 -0
  8. package/src/HeadContent.tsrx +4 -2
  9. package/src/Link.tsrx +3 -1
  10. package/src/Link.tsrx.d.ts +3 -0
  11. package/src/Match.tsrx +3 -3
  12. package/src/MatchRoute.tsrx +5 -5
  13. package/src/Matches.tsrx +6 -19
  14. package/src/Navigate.tsrx +1 -1
  15. package/src/Outlet.tsrx +2 -2
  16. package/src/RouteNotFound.tsrx +1 -1
  17. package/src/RouterProvider.tsrx +1 -1
  18. package/src/ScriptOnce.tsrx +14 -15
  19. package/src/Scripts.tsrx +1 -1
  20. package/src/ScrollRestoration.tsrx +1 -1
  21. package/src/Transitioner.tsrx +80 -62
  22. package/src/assetComponent.tsrx +1 -1
  23. package/src/compiled/Document.tsrx +2 -0
  24. package/src/compiled/Link.tsrx +1 -3
  25. package/src/compiled/Matches.tsrx +128 -120
  26. package/src/compiled/RouterProvider.tsrx +27 -0
  27. package/src/compiled.ts +6 -3
  28. package/src/core.ts +8 -0
  29. package/src/externalHydration.ts +2 -2
  30. package/src/fileRoute.ts +1 -1
  31. package/src/headContentBinding.ts +10 -15
  32. package/src/history.ts +16 -8
  33. package/src/historyState.ts +37 -0
  34. package/src/hookBindings.ts +2 -0
  35. package/src/index.ts +4 -4
  36. package/src/{link.ts → linkApi.ts} +1 -3
  37. package/src/linkBinding.ts +14 -10
  38. package/src/linkComponent.tsrx +3 -3
  39. package/src/not-found.tsrx +4 -4
  40. package/src/route.ts +2 -1
  41. package/src/router.ts +5 -0
  42. package/src/routerStateBinding.ts +2 -0
  43. package/src/scriptComponent.tsrx +22 -15
  44. package/src/scroll-restoration.tsrx +1 -1
  45. package/src/ssr/RouterClient.tsrx +1 -1
  46. package/src/ssr/renderRouterToStream.ts +13 -4
  47. package/src/useBlocker.tsrx +1 -1
  48. package/src/useStore.ts +2 -0
package/README.md CHANGED
@@ -62,6 +62,16 @@ tree renders **pull-based**: `RouterProvider` renders the first match, and each
62
62
  component's `<Outlet/>` looks up the next match via `matchContext` — so navigation
63
63
  re-renders only the matches that changed.
64
64
 
65
+ ## Headless routing
66
+
67
+ Use `@octanejs/tanstack-router/core` when an application owns its render tree and
68
+ only needs matching, loading, and navigation. It exposes Octane's router adapter,
69
+ the framework-neutral `@tanstack/router-core` APIs, and history constructors
70
+ without importing route components or hooks.
71
+
72
+ Browser and hash histories created by this package preserve existing application
73
+ fields in History API state objects and Navigation API entry state during initialization.
74
+
65
75
  ## Same-route search navigation
66
76
 
67
77
  Same-route search navigations now render store updates urgently. If the route
@@ -144,6 +154,12 @@ existing `CompiledRouterProvider`, `CompiledOutlet`, and
144
154
  `lazyCompiledRouteComponent` names remain aliases. TanStack owns matching,
145
155
  loading, navigation, history and render acknowledgements.
146
156
 
157
+ `PreconfiguredRouterProvider` accepts only `{ router }` and owns the provider,
158
+ match projection, and failure presentation in Strong mode. The compiled error
159
+ boundary owns synchronous route error reporting and not-found propagation.
160
+ Configure the router before rendering it. Use `RouterProvider` when provider
161
+ option props need to reconfigure the router synchronously.
162
+
147
163
  ```tsx
148
164
  import {
149
165
  createRootRoute, createRoute, createRouter, createBrowserHistory,
@@ -191,13 +207,13 @@ Compiled selection removes descriptor creation from route selection, bound
191
207
  links and document shells, but does not make the entire binding Strong or
192
208
  remove the generic renderer. Link retains synchronous `flushSync` so its
193
209
  transition cue is already visible in `onBeforeNavigate` callbacks. Both entries
194
- share a non-Strong script component that renders host descriptors: new inline and
195
- external scripts execute, and self-removing hydration data scripts leave no
196
- template row frames behind. Route scripts retain their managed nodes through
197
- hydration and are replaced with route ownership. The scroll-restoration
198
- script remains through adoption and is dropped afterward. Error propagation,
199
- scroll-script hydration compatibility, and lifecycle dependencies retain their
200
- existing contracts.
210
+ share a Strong keyed script template. Newly mounted route scripts execute once;
211
+ hydration adopts existing scripts without replaying them, and self-removing
212
+ transport scripts leave no item markers behind. Managed keys, nonces and route
213
+ ownership are retained. Scroll-restoration scripts use a server-owned static
214
+ hydration range, which is removed after hydration and omitted on client-only
215
+ mounts. Error propagation and lifecycle dependencies retain their existing
216
+ contracts.
201
217
 
202
218
  `Wrap` and `InnerWrap` remain unsupported and produce an explicit diagnostic.
203
219
  For returned JSX, host strings, renderable wrappers or mixed route trees, retain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/tanstack-router",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -36,6 +36,7 @@
36
36
  ],
37
37
  "exports": {
38
38
  ".": "./src/index.ts",
39
+ "./core": "./src/core.ts",
39
40
  "./history": "./src/history.ts",
40
41
  "./compiled": "./src/compiled.ts",
41
42
  "./ssr/server": {
@@ -59,13 +60,13 @@
59
60
  "isbot": "^5.1.22"
60
61
  },
61
62
  "peerDependencies": {
62
- "octane": "^0.13.0"
63
+ "octane": "^0.14.0"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@tanstack/react-router": "1.170.41",
66
67
  "@tsrx/react": "^0.4.5",
67
68
  "esbuild": "^0.28.1",
68
- "octane": "0.13.7",
69
+ "octane": "0.14.0",
69
70
  "react": "19.2.7",
70
71
  "react-dom": "19.2.7",
71
72
  "vitest": "^4.1.10"
package/src/Asset.tsrx CHANGED
@@ -1,4 +1,4 @@
1
- import { useRouter } from './context.ts';
1
+ import { useRouter } from './context';
2
2
  import { createAsset } from './assetComponent.tsrx';
3
3
 
4
4
  export const Asset = createAsset(useRouter);
package/src/Await.tsrx CHANGED
@@ -5,7 +5,7 @@
5
5
  // render-prop, so the consumer's `<Await>{(d) => …}</Await>` stays the public API).
6
6
  import { use } from 'octane';
7
7
  import type { OctaneNode } from 'octane';
8
- import { toExternalHydrationThenable } from './externalHydration.ts';
8
+ import { toExternalHydrationThenable } from './externalHydration';
9
9
 
10
10
  function AwaitInner(props: { promise: Promise<any>; render: (data: any) => OctaneNode }) {
11
11
  const data = use(toExternalHydrationThenable(props.promise));
@@ -1,4 +1,4 @@
1
- // react-router's CatchBoundary + default ErrorComponent, rebuilt on octane's
1
+ // react-router's CatchBoundary, rebuilt on octane's
2
2
  // `@try`/`@catch` (octane has no class components, so the upstream
3
3
  // CatchBoundaryImpl class becomes a catch branch). Contract preserved from
4
4
  // upstream CatchBoundary.tsx:
@@ -13,15 +13,18 @@
13
13
  // getDerivedStateFromProps; here a layout effect calls the catch reset — one
14
14
  // commit later, same observable outcome). Match passes the router's `loadedAt`
15
15
  // so navigation clears route error UI.
16
- import { useState, useRef, useLayoutEffect } from 'octane';
16
+ import { useRef, useLayoutEffect } from 'octane';
17
17
  import type { OctaneNode } from 'octane';
18
- import type { ErrorInfo, ErrorRouteComponent } from './route.ts';
18
+ import type { ErrorInfo, ErrorRouteComponent } from './route';
19
+ import { ErrorComponent } from './ErrorComponent.tsrx';
20
+
21
+ export { ErrorComponent };
19
22
 
20
23
  export function CatchBoundary(props: {
21
24
  getResetKey: () => unknown;
22
25
  children: OctaneNode;
23
- errorComponent?: ErrorRouteComponent;
24
- onCatch?: (error: Error, errorInfo: ErrorInfo) => void;
26
+ errorComponent?: ErrorRouteComponent | undefined;
27
+ onCatch?: ((error: Error, errorInfo: ErrorInfo) => void) | undefined;
25
28
  }) @{
26
29
  @try {
27
30
  <>{props.children}</>
@@ -39,9 +42,9 @@ export function CatchBoundary(props: {
39
42
  function CatchErrorView(props: {
40
43
  error: any;
41
44
  reset: () => void;
42
- getResetKey?: () => unknown;
43
- errorComponent?: ErrorRouteComponent;
44
- onCatch?: (error: Error, errorInfo: ErrorInfo) => void;
45
+ getResetKey: () => unknown;
46
+ errorComponent?: ErrorRouteComponent | undefined;
47
+ onCatch?: ((error: Error, errorInfo: ErrorInfo) => void) | undefined;
45
48
  }) @{
46
49
  // componentDidCatch parity — once per distinct caught error, during render so a
47
50
  // rethrow (the notFound-forwarding path) escapes to the parent boundary.
@@ -66,36 +69,3 @@ function CatchErrorView(props: {
66
69
 
67
70
  <ErrorComp error={props.error} reset={props.reset} info={{ componentStack: '' }} />
68
71
  }
69
-
70
- // The default error UI, markup-identical to react-router's ErrorComponent (string
71
- // styles serialize to the same inline css React produces, so the differential rig
72
- // can byte-compare it).
73
- export function ErrorComponent(props: {
74
- error: any;
75
- reset?: () => void;
76
- info?: { componentStack?: string };
77
- }) @{
78
- const [show, setShow] = useState(process.env.NODE_ENV !== 'production');
79
-
80
- <div style="padding:.5rem;max-width:100%">
81
- <div style="display:flex;align-items:center;gap:.5rem">
82
- <strong style="font-size:1rem">Something went wrong!</strong>
83
- <button
84
- style="appearance:none;font-size:.6em;border:1px solid currentColor;padding:.1rem .2rem;font-weight:bold;border-radius:.25rem"
85
- onClick={() => setShow((d) => !d)}
86
- >
87
- {show ? 'Hide Error' : 'Show Error'}
88
- </button>
89
- </div>
90
- <div style="height:.25rem"></div>
91
- @if (show) {
92
- <div>
93
- <pre style="font-size:.7em;border:1px solid red;border-radius:.25rem;padding:.3rem;color:red;overflow:auto">
94
- @if (props.error?.message) {
95
- <code>{props.error.message as string}</code>
96
- }
97
- </pre>
98
- </div>
99
- }
100
- </div>
101
- }
@@ -9,7 +9,7 @@
9
9
  // call sites).
10
10
  import { useSyncExternalStore } from 'octane';
11
11
  import type { OctaneNode } from 'octane';
12
- import { splitSlot, subSlot } from './internal.ts';
12
+ import { splitSlot, subSlot } from './internal';
13
13
 
14
14
  const subscribe = () => () => {};
15
15
 
@@ -0,0 +1,38 @@
1
+ 'use strong';
2
+
3
+ declare const process: { env: { NODE_ENV?: string } };
4
+
5
+ import { useState } from 'octane';
6
+
7
+ // The default error UI, markup-identical to react-router's ErrorComponent (string
8
+ // styles serialize to the same inline css React produces, so the differential rig
9
+ // can byte-compare it).
10
+ export function ErrorComponent(props: {
11
+ error: any;
12
+ reset?: () => void;
13
+ info?: { componentStack?: string };
14
+ }) @{
15
+ const [show, setShow] = useState(process.env.NODE_ENV !== 'production');
16
+
17
+ <div style="padding:.5rem;max-width:100%">
18
+ <div style="display:flex;align-items:center;gap:.5rem">
19
+ <strong style="font-size:1rem">Something went wrong!</strong>
20
+ <button
21
+ style="appearance:none;font-size:.6em;border:1px solid currentColor;padding:.1rem .2rem;font-weight:bold;border-radius:.25rem"
22
+ onClick={() => setShow((d) => !d)}
23
+ >
24
+ {show ? 'Hide Error' : 'Show Error'}
25
+ </button>
26
+ </div>
27
+ <div style="height:.25rem"></div>
28
+ @if (show) {
29
+ <div>
30
+ <pre style="font-size:.7em;border:1px solid red;border-radius:.25rem;padding:.3rem;color:red;overflow:auto">
31
+ @if (props.error?.message) {
32
+ <code>{props.error.message as string}</code>
33
+ }
34
+ </pre>
35
+ </div>
36
+ }
37
+ </div>
38
+ }
@@ -1,10 +1,12 @@
1
+ declare const process: { env: { NODE_ENV?: string } };
2
+
1
3
  import { createElement, useEffect, useId } from 'octane';
2
4
  import { DEV_STYLES_ATTR } from '@tanstack/router-core';
3
5
  import type { AssetCrossOriginConfig } from '@tanstack/router-core';
4
6
  import { Asset } from './Asset.tsrx';
5
7
  import type { AssetProps } from './Asset.tsrx';
6
- import { getAssetKeys } from './assetKeys.ts';
7
- import { useTags } from './headContentUtils.ts';
8
+ import { getAssetKeys } from './assetKeys';
9
+ import { useTags } from './headContentUtils';
8
10
  import { useHydrated } from './ClientOnly.tsrx';
9
11
 
10
12
  export interface HeadContentProps {
package/src/Link.tsrx CHANGED
@@ -1,4 +1,6 @@
1
- import { useLinkProps } from './link.ts';
1
+ import { useRouter } from './context';
2
+ import { createUseLinkProps } from './linkBinding';
2
3
  import { createLinkComponent } from './linkComponent.tsrx';
3
4
 
5
+ export const useLinkProps = createUseLinkProps(useRouter);
4
6
  export const Link = createLinkComponent(useLinkProps);
@@ -1,3 +1,6 @@
1
+ import type { createUseLinkProps } from './linkBinding';
1
2
  import type { LinkComponent } from './linkTypes';
2
3
 
4
+ export declare const useLinkProps: ReturnType<typeof createUseLinkProps>;
5
+
3
6
  export declare const Link: LinkComponent<'a'>;
package/src/Match.tsrx CHANGED
@@ -20,9 +20,9 @@
20
20
  import { createElement, Suspense } from 'octane';
21
21
  import { isNotFound, rootRouteId } from '@tanstack/router-core';
22
22
  import type { NotFoundError, RootRouteOptions } from '@tanstack/router-core';
23
- import type { ErrorInfo, ErrorRouteComponent } from './route.ts';
24
- import { useStore } from './useStore.ts';
25
- import { useRouter, matchContext as MatchContext } from './context.ts';
23
+ import type { ErrorInfo, ErrorRouteComponent } from './route';
24
+ import { useStore } from './useStore';
25
+ import { useRouter, matchContext as MatchContext } from './context';
26
26
  import { Outlet } from './Outlet.tsrx';
27
27
  import { RouteNotFound } from './RouteNotFound.tsrx';
28
28
  import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
@@ -5,8 +5,8 @@
5
5
  // false | matched params. The component renders its children when matched — a
6
6
  // render-prop child ALWAYS renders, receiving the params (false when unmatched).
7
7
  import { useCallback, isChildrenBlock } from 'octane';
8
- import { useRouter } from './context.ts';
9
- import { useStore } from './useStore.ts';
8
+ import { useRouter } from './context';
9
+ import { useStore } from './useStore';
10
10
 
11
11
  export function useMatchRoute() {
12
12
  const router = useRouter();
@@ -29,10 +29,10 @@ export function MatchRoute(props: Record<string, any>) @{
29
29
  const matchRoute = useMatchRoute();
30
30
  const params = matchRoute(props);
31
31
  const out =
32
- typeof props.children === 'function' && !isChildrenBlock(props.children)
33
- ? props.children(params)
32
+ typeof props['children'] === 'function' && !isChildrenBlock(props['children'])
33
+ ? props['children'](params)
34
34
  : params
35
- ? props.children
35
+ ? props['children']
36
36
  : null;
37
37
  <>{out}</>
38
38
  }
package/src/Matches.tsrx CHANGED
@@ -12,9 +12,8 @@
12
12
  // ErrorComponent for errors no route boundary caught, reset by `loadedAt`.
13
13
  import { useLayoutEffect, createElement, Suspense } from 'octane';
14
14
  import { setupScrollRestoration, rootRouteId } from '@tanstack/router-core';
15
- import { useStore } from './useStore.ts';
16
- import { useRouter, matchContext as MatchContext } from './context.ts';
17
- import { useTransitioner, settleOwner } from './Transitioner.tsrx';
15
+ import { useRouter, matchContext as MatchContext } from './context';
16
+ import { useTransitioner, usePresentedMatches } from './Transitioner.tsrx';
18
17
  import { Match } from './Match.tsrx';
19
18
  import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
20
19
  import { SafeFragment } from './SafeFragment.tsrx';
@@ -32,34 +31,22 @@ export function Matches() @{
32
31
  useLayoutEffect(() => {
33
32
  if (router.options.scrollRestoration) setupScrollRestoration(router);
34
33
  }, [router]);
35
- const expectedMatches = useTransitioner(router);
34
+ useTransitioner(router);
36
35
 
37
36
  <InnerWrap>
38
37
  <Suspense fallback={pendingElement}>
39
- <MatchesInner expectedMatches={expectedMatches} />
38
+ <MatchesInner />
40
39
  </Suspense>
41
40
  </InnerWrap>
42
41
  }
43
42
 
44
- function MatchesInner(props: { expectedMatches: unknown }) @{
43
+ function MatchesInner() @{
45
44
  const router = useRouter();
46
- // router-core 1.171.34 dropped the `firstId`/`loadedAt` stores: the root match
47
- // is `matches[0]` and the global error boundary resets on its identity. While a
48
- // navigation transition is pending, render the matches router-core expects
49
- // (`acknowledgement[0]`) so the committed tree equals what it awaits, then
50
- // settle that render acknowledgement from a layout effect once it lands.
51
- const acknowledgement: Array<any> = ((router as any)._rendered ??= []);
52
- const matches = useStore(router.stores.matches, (m: any) =>
53
- acknowledgement[0] === props.expectedMatches ? (props.expectedMatches ?? m) : m,
54
- ) as any;
45
+ const matches = usePresentedMatches(router);
55
46
  const rootMatch = matches[0];
56
47
  const routeId = rootMatch?.routeId as string | undefined;
57
48
  const CatchWrap = router.options.disableGlobalCatchBoundary ? SafeFragment : CatchBoundary;
58
49
 
59
- useLayoutEffect(() => {
60
- if (acknowledgement[0] === matches) settleOwner(acknowledgement, true);
61
- }, [acknowledgement, matches]);
62
-
63
50
  <MatchContext value={routeId}>
64
51
  <CatchWrap getResetKey={() => rootMatch} errorComponent={ErrorComponent}>
65
52
  @if (routeId) {
package/src/Navigate.tsrx CHANGED
@@ -1,7 +1,7 @@
1
1
  // Imperative navigation as a component: navigates once on mount, renders nothing.
2
2
  import { useLayoutEffect, useRef } from 'octane';
3
3
  import type { NavigateOptions } from '@tanstack/router-core';
4
- import { useNavigate } from './hooks.ts';
4
+ import { useNavigate } from './hooks';
5
5
 
6
6
  export function Navigate(props: NavigateOptions) @{
7
7
  const navigate = useNavigate();
package/src/Outlet.tsrx CHANGED
@@ -11,8 +11,8 @@
11
11
  // match — so the 404 lands inside the layout chrome.
12
12
  import { useContext, createElement, Suspense } from 'octane';
13
13
  import { rootRouteId } from '@tanstack/router-core';
14
- import { useStore } from './useStore.ts';
15
- import { useRouter, matchContext } from './context.ts';
14
+ import { useStore } from './useStore';
15
+ import { useRouter, matchContext } from './context';
16
16
  import { Match } from './Match.tsrx';
17
17
  import { RouteNotFound } from './RouteNotFound.tsrx';
18
18
  import { SafeFragment } from './SafeFragment.tsrx';
@@ -8,7 +8,7 @@
8
8
  // when the match resolved with `status === 'notFound'` (a loader threw
9
9
  // `notFound()`). Upstream spreads the NotFoundError onto the component, so a
10
10
  // `notFound({ data })` payload arrives as the `data` prop.
11
- import { useRouter } from './context.ts';
11
+ import { useRouter } from './context';
12
12
 
13
13
  export function RouteNotFound(props: { routeId: string; error?: any }) @{
14
14
  const router = useRouter();
@@ -7,7 +7,7 @@
7
7
  import { hasKeys } from '@tanstack/router-core';
8
8
  import type { AnyRouter } from '@tanstack/router-core';
9
9
  import type { OctaneNode } from 'octane';
10
- import { routerContext as RouterContext } from './context.ts';
10
+ import { routerContext as RouterContext } from './context';
11
11
  import { Matches } from './Matches.tsrx';
12
12
  import { SafeFragment } from './SafeFragment.tsrx';
13
13
 
@@ -1,12 +1,12 @@
1
- // ScriptOnce — port of react-router's ScriptOnce.tsx. The server emits the
2
- // script; upstream's client renders null because the script removes itself
3
- // (`document.currentScript.remove()`) before React hydrates. Octane hydration
4
- // claims the server's structure instead, so the hydration render adopts the
5
- // server `<script>` without comparing its content (the client never runs it, and
6
- // may not have it) and the post-hydration render drops it. A client-only mount
7
- // renders nothing, as upstream does.
1
+ 'use strong';
2
+
3
+ // The browser build has no scroll-script source. Preserve the server-owned
4
+ // range without reconciling its content, then remove it after hydration commits.
5
+ // A client-only mount has no server range and renders nothing.
8
6
  import { isServer } from '@tanstack/router-core/isServer';
9
- import { useRouter } from './context.ts';
7
+ import { Hydrate, trustHTML } from 'octane';
8
+ import { never } from 'octane/hydration';
9
+ import { useRouter } from './context';
10
10
  import { useHydrated } from './ClientOnly.tsrx';
11
11
 
12
12
  export function ScriptOnce(props: { children: string }) @{
@@ -14,13 +14,12 @@ export function ScriptOnce(props: { children: string }) @{
14
14
  const hydrated = useHydrated();
15
15
 
16
16
  @if ((isServer ?? router.isServer) || !hydrated) {
17
- <script
18
- nonce={router.options.ssr?.nonce}
19
- suppressHydrationWarning
20
- dangerouslySetInnerHTML={{
21
- __html: props.children,
22
- }}
23
- />
17
+ <Hydrate split={false} when={never()}>
18
+ <script
19
+ nonce={router.options.ssr?.nonce}
20
+ dangerouslySetInnerHTML={trustHTML(props.children)}
21
+ />
22
+ </Hydrate>
24
23
  } @else {
25
24
  <></>
26
25
  }
package/src/Scripts.tsrx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createScripts } from './scriptComponent.tsrx';
2
- import { useScripts } from './scriptContentUtils.ts';
2
+ import { useScripts } from './scriptContentUtils';
3
3
 
4
4
  // router-core 1.171.34 owns the hydration-script lifecycle: the server appends
5
5
  // the initial hydration tags (and the streaming boundary) through
@@ -5,7 +5,7 @@
5
5
  // Matches); use one or the other.
6
6
  import { useLayoutEffect } from 'octane';
7
7
  import { setupScrollRestoration } from '@tanstack/router-core';
8
- import { useRouter } from './context.ts';
8
+ import { useRouter } from './context';
9
9
 
10
10
  export function ScrollRestoration() @{
11
11
  const router = useRouter();
@@ -1,3 +1,5 @@
1
+ 'use strong';
2
+
1
3
  // The navigation engine — port of react-router's Transitioner. It runs as a hook
2
4
  // because a rendered sibling would place an SSR marker before a root route's
3
5
  // document-level <html> node and outside the #__app hydration range.
@@ -17,81 +19,97 @@
17
19
  // and kick off the initial load — or, when hydrating an already-resolved SSR
18
20
  // location, acknowledge the first render so `onRendered` consumers (scroll
19
21
  // restoration, devtools) fire.
20
- import { useLayoutEffect, useState, startTransition } from 'octane';
22
+ import { useInsertionEffect, useLayoutEffect, startTransition } from 'octane';
21
23
  import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core';
22
- import type { AnyRouter } from '@tanstack/router-core';
24
+ import type { AnyRouteMatch, AnyRouter } from '@tanstack/router-core';
25
+ import { useStore } from './useStore';
23
26
 
24
27
  // Resolve the pending render acknowledgement with whether the expected matches
25
28
  // rendered, then clear the slot. `owner` is `router._rendered`: `[expected, resolve]`.
26
- export function settleOwner(owner: Array<any>, rendered: boolean) {
27
- const settle = owner[1] as ((rendered: boolean) => void) | undefined;
29
+ function settleOwner(owner: NonNullable<AnyRouter['_rendered']>, rendered: boolean) {
30
+ const settle = owner[1];
28
31
  owner.length = 0;
29
32
  settle?.(rendered);
30
33
  }
31
34
 
32
- export function useTransitioner(routerInstance: AnyRouter) {
33
- const router = routerInstance as any;
34
- const [expectedMatches, setExpectedMatches] = useState<unknown>();
35
-
36
- const acknowledgement: Array<any> = (router._rendered ??= []);
37
-
38
- router.startTransition = (fn: () => void, expected: unknown) =>
35
+ function installTransitioner(router: AnyRouter) {
36
+ const acknowledgement = (router._rendered ??= []);
37
+ const previous = router.startTransition;
38
+ const owned: AnyRouter['startTransition'] = (fn, expected) =>
39
39
  new Promise<boolean>((resolve) => {
40
- // A newer transition supersedes an unrendered older one.
41
40
  settleOwner(acknowledgement, false);
42
- acknowledgement.push(expected, resolve);
43
- // Publish the matches this transaction needs. MatchesInner consumes this
44
- // snapshot and acknowledges it only after that exact tree commits.
45
- setExpectedMatches(expected);
41
+ acknowledgement[0] = expected;
42
+ acknowledgement[1] = resolve;
46
43
  startTransition(fn);
47
44
  });
48
-
49
- useLayoutEffect(() => {
50
- const unsub = router.history.subscribe(() => router.load());
51
-
52
- // octane has no StrictMode double-invoke, so this layout effect runs once per
53
- // mounted router and re-runs only when the router (or its history) is swapped
54
- // on a live provider — exactly when the new router needs its initial load.
55
- // Upstream guards this block with a dev-only `mounted` ref purely to absorb
56
- // React's StrictMode double-invoke; keeping that ref here would instead skip
57
- // the swapped-in router's load.
58
- router.updateLatestLocation?.();
59
- const location = router.latestLocation;
60
- const nextLocation = router.buildLocation({
61
- to: location.pathname,
62
- search: true,
63
- params: true,
64
- hash: true,
65
- state: true,
66
- _includeValidateSearch: true,
67
- } as any);
68
- if (trimPathRight(location.publicHref) !== trimPathRight(nextLocation.publicHref)) {
69
- router.commitLocation({ ...nextLocation, replace: true, ignoreBlocker: true });
70
- return unsub;
45
+ router.startTransition = owned;
46
+ return () => {
47
+ if (router.startTransition === owned) {
48
+ router.startTransition = previous;
49
+ settleOwner(acknowledgement, false);
71
50
  }
51
+ };
52
+ }
72
53
 
73
- const resolvedLocation = router.stores.resolvedLocation.get();
74
- if (
75
- resolvedLocation?.href === location.href &&
76
- resolvedLocation.state.__TSR_key === location.state.__TSR_key
77
- ) {
78
- // Already resolved (SSR hydration): emit `onRendered` for scroll
79
- // restoration and devtools. The match tree has already committed by the
80
- // time this layout effect runs (child effects fire before the parent's),
81
- // so emit directly rather than through the acknowledgement/settle path:
82
- // hydration acknowledges the matches already on screen, which — unlike a
83
- // navigation that commits the *next* matches — triggers no re-render for
84
- // `MatchesInner`'s settle effect to run on.
85
- router.emit({
86
- type: 'onRendered',
87
- ...getLocationChangeInfo(resolvedLocation, resolvedLocation),
88
- });
89
- } else if (!router._tx) {
90
- router.load({ sync: true }).catch((err: unknown) => {
91
- console.error(err);
92
- });
93
- }
54
+ function subscribeToHistory(router: AnyRouter, history: AnyRouter['history']) {
55
+ const unsub = history.subscribe(() => router.load());
56
+
57
+ // Re-run for a replacement router or history so the new instance loads.
58
+ router.updateLatestLocation?.();
59
+ const location = router.latestLocation;
60
+ const nextLocation = router.buildLocation({
61
+ to: location.pathname,
62
+ search: true,
63
+ params: true,
64
+ hash: true,
65
+ state: true,
66
+ _includeValidateSearch: true,
67
+ } as any);
68
+ if (trimPathRight(location.publicHref) !== trimPathRight(nextLocation.publicHref)) {
69
+ router.commitLocation({ ...nextLocation, replace: true, ignoreBlocker: true });
94
70
  return unsub;
95
- }, [router, router.history]);
96
- return expectedMatches;
71
+ }
72
+
73
+ const resolvedLocation = router.stores.resolvedLocation.get();
74
+ if (
75
+ resolvedLocation?.href === location.href &&
76
+ resolvedLocation.state.__TSR_key === location.state.__TSR_key
77
+ ) {
78
+ // Hydration acknowledges the already-committed tree, which does not trigger
79
+ // another render for the navigation acknowledgement effect to observe.
80
+ router.emit({
81
+ type: 'onRendered',
82
+ ...getLocationChangeInfo(resolvedLocation, resolvedLocation),
83
+ fromLocation: resolvedLocation,
84
+ });
85
+ } else if (!router._tx) {
86
+ router.load({ sync: true }).catch((err: unknown) => {
87
+ console.error(err);
88
+ });
89
+ }
90
+ return unsub;
91
+ }
92
+
93
+ export function useTransitioner(router: AnyRouter) {
94
+ const history = router.history;
95
+ // Install before descendant layout effects can navigate, without mutating the
96
+ // router during render or placing a sibling before a document-level route.
97
+ useInsertionEffect(() => installTransitioner(router));
98
+ useLayoutEffect(() => subscribeToHistory(router, history));
99
+ }
100
+
101
+ function acknowledgeRenderedMatches(router: AnyRouter, matches: Array<AnyRouteMatch>) {
102
+ const owner = router._rendered;
103
+ if (owner && owner[0] === matches) settleOwner(owner, true);
104
+ }
105
+
106
+ // The store may reconstruct its match array, so a navigation must present the
107
+ // exact snapshot offered by router-core until that tree commits. The live owner
108
+ // is observed only by the store snapshot and by the commit acknowledgement.
109
+ // Mirroring it in component state would entangle that update with an async action
110
+ // awaiting navigation, leaving both the action and its render acknowledgement pending.
111
+ export function usePresentedMatches(router: AnyRouter) {
112
+ const matches = useStore(router.stores.matches, (current) => router._rendered?.[0] ?? current);
113
+ useLayoutEffect(() => acknowledgeRenderedMatches(router, matches));
114
+ return matches;
97
115
  }
@@ -3,7 +3,7 @@
3
3
  import { useLayoutEffect, useMemo, trustHTML } from 'octane';
4
4
  import { isServer } from '@tanstack/router-core/isServer';
5
5
  import type { RouterManagedTag } from '@tanstack/router-core';
6
- import type { useRouter as UseRouter } from './context.ts';
6
+ import type { useRouter as UseRouter } from './context';
7
7
  import { useHydrated } from './ClientOnly.tsrx';
8
8
 
9
9
  export type AssetProps = RouterManagedTag & {
@@ -1,5 +1,7 @@
1
1
  'use strong';
2
2
 
3
+ declare const process: { env: { NODE_ENV?: string } };
4
+
3
5
  import { useEffect, useId, HYDRATION_RANGE_BOUNDARY, createPortal, useState } from 'octane';
4
6
  import { DEV_STYLES_ATTR } from '@tanstack/router-core';
5
7
  import type { AssetCrossOriginConfig } from '@tanstack/router-core';