@octanejs/tanstack-router 0.1.9 → 0.1.11

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 (69) hide show
  1. package/README.md +24 -11
  2. package/package.json +22 -7
  3. package/src/Asset.tsrx +121 -0
  4. package/src/Asset.tsrx.d.ts +9 -0
  5. package/src/Await.tsrx +9 -3
  6. package/src/Await.tsrx.d.ts +8 -6
  7. package/src/Body.ts +31 -0
  8. package/src/CatchBoundary.tsrx +21 -4
  9. package/src/CatchBoundary.tsrx.d.ts +10 -4
  10. package/src/ClientOnly.tsrx +6 -3
  11. package/src/Head.ts +22 -0
  12. package/src/HeadContent.tsrx +41 -0
  13. package/src/HeadContent.tsrx.d.ts +8 -0
  14. package/src/Html.ts +19 -0
  15. package/src/Link.tsrx +19 -4
  16. package/src/Link.tsrx.d.ts +3 -4
  17. package/src/Match.tsrx +61 -29
  18. package/src/MatchRoute.tsrx +6 -3
  19. package/src/Matches.tsrx +7 -7
  20. package/src/Navigate.tsrx +2 -1
  21. package/src/Outlet.tsrx +8 -6
  22. package/src/RouteNotFound.tsrx +2 -2
  23. package/src/RouterProvider.tsrx +12 -2
  24. package/src/RouterProvider.tsrx.d.ts +9 -5
  25. package/src/SafeFragment.tsrx +4 -1
  26. package/src/ScriptOnce.tsrx +23 -0
  27. package/src/ScriptOnce.tsrx.d.ts +3 -0
  28. package/src/Scripts.tsrx +42 -0
  29. package/src/Scripts.tsrx.d.ts +3 -0
  30. package/src/Transitioner.tsrx +15 -13
  31. package/src/assetKeys.ts +11 -0
  32. package/src/context.ts +5 -1
  33. package/src/externalHydration.ts +77 -0
  34. package/src/fileRoute.ts +277 -0
  35. package/src/frameworkTypes.ts +42 -0
  36. package/src/generator-plugin.d.ts +20 -0
  37. package/src/generator-plugin.js +100 -0
  38. package/src/headContentUtils.ts +172 -0
  39. package/src/hooks.ts +151 -0
  40. package/src/index.ts +95 -3
  41. package/src/lazyRouteComponent.ts +2 -1
  42. package/src/link.ts +25 -4
  43. package/src/linkTypes.ts +96 -0
  44. package/src/not-found.tsrx +19 -6
  45. package/src/not-found.tsrx.d.ts +5 -2
  46. package/src/octane-compiler.d.ts +12 -0
  47. package/src/route.ts +473 -36
  48. package/src/routeHookTypes.ts +228 -0
  49. package/src/router.ts +31 -6
  50. package/src/scriptContentUtils.ts +64 -0
  51. package/src/scroll-restoration.tsrx +16 -0
  52. package/src/scroll-restoration.tsrx.d.ts +3 -0
  53. package/src/ssr/RouterClient.tsrx +36 -0
  54. package/src/ssr/RouterClient.tsrx.d.ts +4 -0
  55. package/src/ssr/RouterServer.tsrx +6 -0
  56. package/src/ssr/RouterServer.tsrx.d.ts +4 -0
  57. package/src/ssr/client.ts +4 -0
  58. package/src/ssr/defaultRenderHandler.ts +11 -0
  59. package/src/ssr/defaultStreamHandler.ts +12 -0
  60. package/src/ssr/renderRouterToStream.ts +195 -0
  61. package/src/ssr/renderRouterToString.ts +58 -0
  62. package/src/ssr/server.ts +8 -0
  63. package/src/structuralSharing.ts +41 -0
  64. package/src/typePrimitives.ts +77 -0
  65. package/src/useAwaited.ts +7 -2
  66. package/src/useBlocker.tsrx +73 -8
  67. package/src/useBlocker.tsrx.d.ts +58 -2
  68. package/src/useRouterState.ts +20 -0
  69. package/src/useStore.ts +17 -6
package/src/Match.tsrx CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  isNotFound,
25
25
  rootRouteId,
26
26
  } from '@tanstack/router-core';
27
+ import type { NotFoundError, ParsedLocation, RootRouteOptions } from '@tanstack/router-core';
28
+ import type { ErrorInfo, ErrorRouteComponent } from './route.ts';
27
29
  import { useStore } from './useStore.ts';
28
30
  import { useRouter, matchContext } from './context.ts';
29
31
  import { Outlet } from './Outlet.tsrx';
@@ -31,13 +33,26 @@ import { RouteNotFound } from './RouteNotFound.tsrx';
31
33
  import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
32
34
  import { CatchNotFound } from './not-found.tsrx';
33
35
  import { SafeFragment } from './SafeFragment.tsrx';
36
+ import { ClientOnly } from './ClientOnly.tsrx';
37
+ import { ScrollRestorationScript } from './scroll-restoration.tsrx';
34
38
 
35
- export function Match(props) @{
39
+ export function Match(props: { matchId: string }) @{
36
40
  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
+ const matchStore = router.stores.matchStores.get(props.matchId)!;
42
+ const resetKey = useStore(router.stores.loadedAt, (l: any) => l as number | string);
43
+ const matchState = useStore(
44
+ matchStore,
45
+ (m: any) => ({
46
+ routeId: m.routeId,
47
+ ssr: m.ssr,
48
+ _displayPending: m._displayPending,
49
+ }),
50
+ (previous, next) =>
51
+ previous.routeId === next.routeId && previous.ssr === next.ssr &&
52
+ previous._displayPending === next._displayPending,
53
+ );
54
+ const routeId = matchState.routeId as string;
55
+ const route = (router.routesById as Record<string, any>)[routeId];
41
56
 
42
57
  const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent;
43
58
  const routeErrorComponent = route.options.errorComponent ?? router.options.defaultErrorComponent;
@@ -45,17 +60,23 @@ export function Match(props) @{
45
60
  const routeNotFoundComponent = route.isRoot
46
61
  ? route.options.notFoundComponent ?? router.options.notFoundRoute?.options.component
47
62
  : route.options.notFoundComponent;
63
+ const resolvedNoSsr = matchState.ssr === false || matchState.ssr === 'data-only';
48
64
 
49
65
  // Boundary presence, per upstream MatchView. The root route only gets a
50
66
  // Suspense boundary when explicitly opted in via wrapInSuspense.
67
+ const suspenseSignal =
68
+ route.options.wrapInSuspense ?? PendingComponent ??
69
+ (routeErrorComponent as ErrorRouteComponent | undefined)?.preload;
51
70
  const SuspenseWrap =
52
- (!route.isRoot || route.options.wrapInSuspense) &&
53
- (route.options.wrapInSuspense ?? PendingComponent ?? routeErrorComponent?.preload)
71
+ (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) &&
72
+ (suspenseSignal || resolvedNoSsr)
54
73
  ? Suspense
55
74
  : SafeFragment;
56
75
  const CatchWrap = routeErrorComponent ? CatchBoundary : SafeFragment;
57
76
  const NotFoundWrap = routeNotFoundComponent ? CatchNotFound : SafeFragment;
58
- const ShellComponent = route.isRoot ? route.options.shellComponent ?? SafeFragment : SafeFragment;
77
+ const ShellComponent = route.isRoot
78
+ ? (route.options as RootRouteOptions).shellComponent ?? SafeFragment
79
+ : SafeFragment;
59
80
 
60
81
  const pendingElement =
61
82
  PendingComponent ? createElement(PendingComponent, {}) : null;
@@ -67,17 +88,17 @@ export function Match(props) @{
67
88
  <CatchWrap
68
89
  getResetKey={() => resetKey}
69
90
  errorComponent={routeErrorComponent || ErrorComponent}
70
- onCatch={(error, errorInfo) => {
91
+ onCatch={(error: Error, errorInfo: ErrorInfo) => {
71
92
  if (isNotFound(error)) {
72
- error.routeId ??= routeId;
93
+ (error as any).routeId ??= routeId;
73
94
  throw error;
74
95
  }
75
96
  if (routeOnCatch) routeOnCatch(error, errorInfo);
76
97
  }}
77
98
  >
78
99
  <NotFoundWrap
79
- fallback={(error) => {
80
- error.routeId ??= routeId;
100
+ fallback={(error: NotFoundError) => {
101
+ (error as any).routeId ??= routeId;
81
102
 
82
103
  if (
83
104
  !routeNotFoundComponent || error.routeId && error.routeId !== routeId ||
@@ -85,38 +106,49 @@ export function Match(props) @{
85
106
  ) {
86
107
  throw error;
87
108
  }
88
- return createElement(routeNotFoundComponent, error);
109
+ return createElement(routeNotFoundComponent, error as any);
89
110
  }}
90
111
  >
91
- <MatchInner matchId={props.matchId} />
112
+ @if (resolvedNoSsr || matchState._displayPending) {
113
+ <ClientOnly fallback={pendingElement}>
114
+ <MatchInner matchId={props.matchId} />
115
+ </ClientOnly>
116
+ } @else {
117
+ <MatchInner matchId={props.matchId} />
118
+ }
92
119
  </NotFoundWrap>
93
120
  </CatchWrap>
94
121
  </SuspenseWrap>
95
122
  </matchContext.Provider>
96
123
  @if (parentRouteId === rootRouteId) {
97
- <OnRendered />
124
+ <>
125
+ <OnRendered />
126
+ @if (router.options.scrollRestoration) {
127
+ <ScrollRestorationScript />
128
+ }
129
+ </>
98
130
  }
99
131
  </ShellComponent>
100
132
  }
101
133
 
102
- function MatchInner(props) {
134
+ function MatchInner(props: { matchId: string }) {
103
135
  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];
136
+ const matchStore = router.stores.matchStores.get(props.matchId)!;
137
+ const match = useStore(matchStore, (m: any) => m);
138
+ const routeId = match.routeId as string;
139
+ const route = (router.routesById as Record<string, any>)[routeId];
108
140
 
109
141
  // The live match in the router (if still mounted there) wins over the
110
142
  // snapshot for promise lookups, per upstream getMatchPromise.
111
- const getMatchPromise = (key) => router.getMatch(match.id)?._nonReactive[key] ??
112
- match._nonReactive[key];
143
+ const getMatchPromise = (key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise') =>
144
+ router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key];
113
145
 
114
146
  if (match._displayPending) {
115
- use(getMatchPromise('displayPendingPromise'));
147
+ use(getMatchPromise('displayPendingPromise')!);
116
148
  }
117
149
 
118
150
  if (match._forcePending) {
119
- use(getMatchPromise('minPendingPromise'));
151
+ use(getMatchPromise('minPendingPromise')!);
120
152
  }
121
153
 
122
154
  if (match.status === 'pending') {
@@ -125,7 +157,7 @@ function MatchInner(props) {
125
157
  if (pendingMinMs) {
126
158
  const routerMatch = router.getMatch(match.id);
127
159
  if (routerMatch && !routerMatch._nonReactive.minPendingPromise) {
128
- const minPendingPromise = createControlledPromise();
160
+ const minPendingPromise = createControlledPromise<void>();
129
161
  routerMatch._nonReactive.minPendingPromise = minPendingPromise;
130
162
  setTimeout(() => {
131
163
  minPendingPromise.resolve();
@@ -133,7 +165,7 @@ function MatchInner(props) {
133
165
  }, pendingMinMs);
134
166
  }
135
167
  }
136
- use(getMatchPromise('loadPromise'));
168
+ use(getMatchPromise('loadPromise')!);
137
169
  }
138
170
 
139
171
  if (match.status === 'notFound') {
@@ -143,7 +175,7 @@ function MatchInner(props) {
143
175
  if (match.status === 'redirected') {
144
176
  // Observed mid-transition while a redirect is in flight — suspend on the
145
177
  // load so this stale render is abandoned and the redirect completes.
146
- use(getMatchPromise('loadPromise'));
178
+ use(getMatchPromise('loadPromise')!);
147
179
  }
148
180
 
149
181
  if (match.status === 'error') {
@@ -174,10 +206,10 @@ function MatchInner(props) {
174
206
  // `resolvedLocation` to the new location.
175
207
  function OnRendered() @{
176
208
  const router = useRouter();
177
- const prevResolvedLocationRef = useRef(undefined);
209
+ const prevResolvedLocationRef = useRef<ParsedLocation<any> | undefined>(undefined);
178
210
  const renderedLocationKey = useStore(
179
211
  router.stores.resolvedLocation,
180
- (loc) => loc?.state.__TSR_key,
212
+ (loc: ParsedLocation<any> | undefined) => loc?.state.__TSR_key,
181
213
  );
182
214
 
183
215
  useLayoutEffect(() => {
@@ -11,13 +11,16 @@ import { useStore } from './useStore.ts';
11
11
  export function useMatchRoute() {
12
12
  const router = useRouter();
13
13
  useStore(router.stores.matchRouteDeps, (d) => d);
14
- return useCallback((opts) => {
14
+ return useCallback((opts: Record<string, any>) => {
15
15
  const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts;
16
- return router.matchRoute(rest, { pending, caseSensitive, fuzzy, includeSearch });
16
+ return router.matchRoute(rest as any, { pending, caseSensitive, fuzzy, includeSearch });
17
17
  }, [router]);
18
18
  }
19
19
 
20
- export function MatchRoute(props) @{
20
+ // Props are permissive (`Record<string, any>` + optional render-prop children)
21
+ // upstream's MakeMatchRouteOptions generics are the type-safe facade; the octane
22
+ // binding keeps the loose v1 surface (see Link).
23
+ export function MatchRoute(props: Record<string, any>) @{
21
24
  const matchRoute = useMatchRoute();
22
25
  const params = matchRoute(props);
23
26
  const out =
package/src/Matches.tsrx CHANGED
@@ -1,7 +1,7 @@
1
- // Renders the navigation engine + the match tree root — port of react-router's
1
+ // Runs the navigation engine and renders the match tree root — port of react-router's
2
2
  // Matches.tsx. Structure mirrors upstream:
3
3
  //
4
- // InnerWrap? > Suspense(root pending) > Transitioner + MatchesInner
4
+ // useTransitioner + InnerWrap? > Suspense(root pending) > MatchesInner
5
5
  // MatchesInner: matchContext(firstId) > global CatchBoundary? > Match(firstId)
6
6
  //
7
7
  // The root Suspense is ALWAYS present on the client (fallback is the root
@@ -14,14 +14,14 @@ import { useLayoutEffect, createElement, Suspense } from 'octane';
14
14
  import { setupScrollRestoration, rootRouteId } from '@tanstack/router-core';
15
15
  import { useStore } from './useStore.ts';
16
16
  import { useRouter, matchContext } from './context.ts';
17
- import { Transitioner } from './Transitioner.tsrx';
17
+ import { useTransitioner } from './Transitioner.tsrx';
18
18
  import { Match } from './Match.tsrx';
19
19
  import { CatchBoundary, ErrorComponent } from './CatchBoundary.tsrx';
20
20
  import { SafeFragment } from './SafeFragment.tsrx';
21
21
 
22
22
  export function Matches() @{
23
23
  const router = useRouter();
24
- const rootRoute = router.routesById[rootRouteId];
24
+ const rootRoute = (router.routesById as any)[rootRouteId];
25
25
  const PendingComponent =
26
26
  rootRoute.options.pendingComponent ?? router.options.defaultPendingComponent;
27
27
  const pendingElement =
@@ -33,10 +33,10 @@ export function Matches() @{
33
33
  useLayoutEffect(() => {
34
34
  if (router.options.scrollRestoration) setupScrollRestoration(router);
35
35
  }, [router]);
36
+ useTransitioner();
36
37
 
37
38
  <InnerWrap>
38
39
  <Suspense fallback={pendingElement}>
39
- <Transitioner />
40
40
  <MatchesInner />
41
41
  </Suspense>
42
42
  </InnerWrap>
@@ -44,8 +44,8 @@ export function Matches() @{
44
44
 
45
45
  function MatchesInner() @{
46
46
  const router = useRouter();
47
- const matchId = useStore(router.stores.firstId, (id) => id);
48
- const resetKey = useStore(router.stores.loadedAt, (l) => l);
47
+ const matchId = useStore(router.stores.firstId, (id: any) => id as string | undefined);
48
+ const resetKey = useStore(router.stores.loadedAt, (l: any) => l as number | string);
49
49
  const CatchWrap = router.options.disableGlobalCatchBoundary ? SafeFragment : CatchBoundary;
50
50
 
51
51
  <matchContext.Provider value={matchId}>
package/src/Navigate.tsrx CHANGED
@@ -1,8 +1,9 @@
1
1
  // Imperative navigation as a component: navigates once on mount, renders nothing.
2
2
  import { useLayoutEffect, useRef } from 'octane';
3
+ import type { NavigateOptions } from '@tanstack/router-core';
3
4
  import { useNavigate } from './hooks.ts';
4
5
 
5
- export function Navigate(props) @{
6
+ export function Navigate(props: NavigateOptions) @{
6
7
  const navigate = useNavigate();
7
8
  const done = useRef(false);
8
9
 
package/src/Outlet.tsrx CHANGED
@@ -19,11 +19,13 @@ import { SafeFragment } from './SafeFragment.tsrx';
19
19
 
20
20
  export function Outlet() @{
21
21
  const router = useRouter();
22
- const parentId = useContext(matchContext);
23
- const parentStore = router.stores.matchStores.get(parentId);
24
- const parentRouteId = useStore(parentStore, (m) => m?.routeId);
25
- const globalNotFound = useStore(parentStore, (m) => m?.globalNotFound ?? false);
26
- const childId = useStore(router.stores.matchesId, (ids) => {
22
+ // Outlet only renders inside a match's component, so the context id (and its
23
+ // pooled store) are present — same invariant upstream asserts with invariant().
24
+ const parentId = useContext(matchContext)!;
25
+ const parentStore = router.stores.matchStores.get(parentId)!;
26
+ const parentRouteId = useStore(parentStore, (m: any) => m?.routeId as string | undefined);
27
+ const globalNotFound = useStore(parentStore, (m: any) => m?.globalNotFound ?? false as boolean);
28
+ const childId = useStore(router.stores.matchesId, (ids: Array<string>) => {
27
29
  const i = ids.indexOf(parentId);
28
30
  return i >= 0 ? ids[i + 1] : undefined;
29
31
  });
@@ -38,7 +40,7 @@ export function Outlet() @{
38
40
  DefaultPending ? createElement(DefaultPending, {}) : null;
39
41
 
40
42
  @if (globalNotFound) {
41
- <RouteNotFound routeId={parentRouteId} />
43
+ <RouteNotFound routeId={parentRouteId!} />
42
44
  } @else {
43
45
  @if (childId) {
44
46
  <RootSuspense fallback={pendingElement}>
@@ -10,9 +10,9 @@
10
10
  // `notFound({ data })` payload arrives as the `data` prop.
11
11
  import { useRouter } from './context.ts';
12
12
 
13
- export function RouteNotFound(props) @{
13
+ export function RouteNotFound(props: { routeId: string; error?: any }) @{
14
14
  const router = useRouter();
15
- const route = router.routesById[props.routeId];
15
+ const route = (router.routesById as any)[props.routeId];
16
16
  const NotFound = route.options.notFoundComponent ?? router.options.defaultNotFoundComponent;
17
17
 
18
18
  @if (NotFound) {
@@ -5,11 +5,21 @@
5
5
  // instance), wraps in `router.options.Wrap` when configured, and provides the
6
6
  // router; `RouterProvider` renders `<Matches/>` inside it.
7
7
  import { hasKeys } from '@tanstack/router-core';
8
+ import type { AnyRouter } from '@tanstack/router-core';
9
+ import type { OctaneNode } from 'octane';
8
10
  import { routerContext } from './context.ts';
9
11
  import { Matches } from './Matches.tsrx';
10
12
  import { SafeFragment } from './SafeFragment.tsrx';
11
13
 
12
- export function RouterContextProvider(props) @{
14
+ // Upstream's `RouterProps` is `Omit<RouterOptions<…>, 'context'> & { router }` —
15
+ // extra props reconfigure the instance via `router.update()`. The octane binding
16
+ // keeps the router-typed member strict and the option passthrough permissive.
17
+ export type RouterProps = {
18
+ router: AnyRouter;
19
+ context?: Record<string, any>;
20
+ } & Record<string, any>;
21
+
22
+ export function RouterContextProvider(props: RouterProps & { children?: OctaneNode }) @{
13
23
  const { router, children, ...rest } = props;
14
24
  if (hasKeys(rest)) {
15
25
  router.update({
@@ -28,7 +38,7 @@ export function RouterContextProvider(props) @{
28
38
  </Wrap>
29
39
  }
30
40
 
31
- export function RouterProvider(props) @{
41
+ export function RouterProvider(props: RouterProps) @{
32
42
  const { router, ...rest } = props;
33
43
 
34
44
  <RouterContextProvider router={router} {...rest}>
@@ -1,8 +1,12 @@
1
1
  // Type declaration for the .tsrx component (resolved by relative path).
2
2
  import type { AnyRouter } from '@tanstack/router-core';
3
3
 
4
- export declare const RouterProvider: (props: { router: AnyRouter; children?: unknown }) => unknown;
5
- export declare const RouterContextProvider: (props: {
6
- router: import('@tanstack/router-core').AnyRouter;
7
- children?: unknown;
8
- }) => unknown;
4
+ export type RouterProps = {
5
+ router: AnyRouter;
6
+ context?: Record<string, any>;
7
+ } & Record<string, any>;
8
+
9
+ export declare const RouterProvider: (props: RouterProps) => unknown;
10
+ export declare const RouterContextProvider: (
11
+ props: RouterProps & { children?: unknown },
12
+ ) => unknown;
@@ -5,7 +5,10 @@
5
5
  // — crucially — means a route WITHOUT a pendingComponent/errorComponent does not
6
6
  // create a boundary at all, so suspensions and errors bubble to the nearest
7
7
  // ancestor that has one (react-router parity).
8
- export function SafeFragment(props) @{
8
+ // Props are `any` (upstream parity: react-router's `SafeFragment(props: any)`)
9
+ // it stands in for Suspense/CatchBoundary/CatchNotFound/shellComponent at dynamic
10
+ // boundary slots, so it must accept any boundary's props and render only children.
11
+ export function SafeFragment(props: any) @{
9
12
  <>
10
13
  {props.children}
11
14
  </>
@@ -0,0 +1,23 @@
1
+ import { useEffect, useState } from 'octane';
2
+ import { isServer } from '@tanstack/router-core/isServer';
3
+ import { useRouter } from './context.ts';
4
+
5
+ export function ScriptOnce(props: { children: string }) @{
6
+ const router = useRouter();
7
+ const server = isServer ?? router.isServer;
8
+ const [hydrating, setHydrating] = useState(true);
9
+ useEffect(() => {
10
+ setHydrating(false);
11
+ }, []);
12
+
13
+ @if (server || hydrating) {
14
+ <script
15
+ nonce={router.options.ssr?.nonce}
16
+ dangerouslySetInnerHTML={{
17
+ __html: props.children,
18
+ }}
19
+ />
20
+ } @else {
21
+ <></>
22
+ }
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { ComponentBody } from 'octane';
2
+
3
+ export declare const ScriptOnce: ComponentBody<{ children: string }>;
@@ -0,0 +1,42 @@
1
+ import { createElement, useEffect, useState } from 'octane';
2
+ import { isServer } from '@tanstack/router-core/isServer';
3
+ import type { RouterManagedTag } from '@tanstack/router-core';
4
+ import { getAssetKey } from './assetKeys.ts';
5
+ import { useRouter } from './context.ts';
6
+ import { useScripts } from './scriptContentUtils.ts';
7
+
8
+ export function Scripts() @{
9
+ const router = useRouter();
10
+ const scripts = useScripts();
11
+ const [hydrating, setHydrating] = useState(true);
12
+ useEffect(() => {
13
+ setHydrating(false);
14
+ }, []);
15
+ const renderScripts: Array<RouterManagedTag> =
16
+ !(isServer ?? router.isServer) && hydrating
17
+ ? [
18
+ {
19
+ tag: 'script',
20
+ attrs: {
21
+ nonce: router.options.ssr?.nonce,
22
+ className: '$tsr',
23
+ id: '$tsr-stream-barrier',
24
+ suppressHydrationWarning: true,
25
+ },
26
+ children: '',
27
+ },
28
+ ...scripts,
29
+ ]
30
+ : scripts;
31
+ <>
32
+ {renderScripts.map((script, index) => {
33
+ const assetKey = getAssetKey('body', script, index);
34
+ return createElement('script', {
35
+ ...script.attrs,
36
+ key: assetKey,
37
+ 'data-tsr-managed-key': assetKey,
38
+ dangerouslySetInnerHTML: { __html: script.children ?? '' },
39
+ });
40
+ })}
41
+ </>
42
+ }
@@ -0,0 +1,3 @@
1
+ import type { ComponentBody } from 'octane';
2
+
3
+ export declare const Scripts: ComponentBody<Record<never, never>>;
@@ -1,4 +1,6 @@
1
- // The navigation engine (renders nothing) — port of react-router's Transitioner.
1
+ // The navigation engine — port of react-router's Transitioner. It runs as a
2
+ // hook because a rendered sibling would put an SSR marker before a root route's
3
+ // document-level <html> node and outside the #__app hydration range.
2
4
  // It (1) supplies `router.startTransition` so every navigation state update rides
3
5
  // an octane transition (concurrent navigation: the current page holds while the
4
6
  // next route suspends), (2) subscribes to history so back/forward and `Link`
@@ -18,7 +20,7 @@ import { useRouter } from './context.ts';
18
20
  import { useStore } from './useStore.ts';
19
21
  import { usePrevious } from './utils.ts';
20
22
 
21
- export function Transitioner() @{
23
+ export function useTransitioner() {
22
24
  const router = useRouter();
23
25
  const mountLoadForRouter = useRef({ router, mounted: false });
24
26
  const [isTransitioning, setIsTransitioning] = useState(false);
@@ -32,7 +34,7 @@ export function Transitioner() @{
32
34
  const isPagePending = isLoading || hasPending;
33
35
  const previousIsPagePending = usePrevious(isPagePending);
34
36
 
35
- router.startTransition = (fn) => {
37
+ router.startTransition = (fn: () => void) => {
36
38
  setIsTransitioning(true);
37
39
  startTransition(() => {
38
40
  fn();
@@ -47,14 +49,16 @@ export function Transitioner() @{
47
49
  useEffect(() => {
48
50
  const unsub = router.history.subscribe(() => router.load());
49
51
 
50
- const nextLocation = router.buildLocation({
51
- to: router.latestLocation.pathname,
52
- search: true,
53
- params: true,
54
- hash: true,
55
- state: true,
56
- _includeValidateSearch: true,
57
- });
52
+ const nextLocation = router.buildLocation(
53
+ {
54
+ to: router.latestLocation.pathname,
55
+ search: true,
56
+ params: true,
57
+ hash: true,
58
+ state: true,
59
+ _includeValidateSearch: true,
60
+ } as any,
61
+ );
58
62
  if (
59
63
  trimPathRight(router.latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref)
60
64
  ) {
@@ -121,6 +125,4 @@ export function Transitioner() @{
121
125
  });
122
126
  }
123
127
  }, [isAnyPending, previousIsAnyPending, router]);
124
-
125
- <></>
126
128
  }
@@ -0,0 +1,11 @@
1
+ import type { RouterManagedTag } from '@tanstack/router-core';
2
+
3
+ export function getAssetKey(scope: 'head' | 'body', asset: RouterManagedTag, index: number) {
4
+ const inlineCss = asset.tag === 'style' && asset.inlineCss;
5
+ return `${scope}:${index}:${JSON.stringify({
6
+ tag: asset.tag,
7
+ attrs: asset.attrs,
8
+ children: inlineCss ? undefined : asset.children,
9
+ inlineCss,
10
+ })}`;
11
+ }
package/src/context.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // match id down the render tree so `Outlet` can find the NEXT match to render —
4
4
  // the pull-based chaining that replaces a top-down state diff.
5
5
  import { createContext, useContext } from 'octane';
6
- import type { AnyRouter } from '@tanstack/router-core';
6
+ import type { AnyRouter, RegisteredRouter } from '@tanstack/router-core';
7
7
 
8
8
  export const routerContext = createContext<AnyRouter | undefined>(undefined);
9
9
  export const getRouterContext = (): typeof routerContext => routerContext;
@@ -14,6 +14,10 @@ export const matchContext = createContext<string | undefined>(undefined);
14
14
  // Resolve the active router: an explicitly-passed one wins, else the context.
15
15
  // `useContext` is keyed by context identity (not a per-call-site slot), so it's
16
16
  // safe to call from this binding code without a slot.
17
+ export function useRouter<TRouter extends AnyRouter = RegisteredRouter>(opts?: {
18
+ router?: TRouter;
19
+ warn?: boolean;
20
+ }): TRouter;
17
21
  export function useRouter(...args: unknown[]): AnyRouter {
18
22
  const opts = (args.length && typeof args[0] !== 'symbol' ? args[0] : undefined) as
19
23
  | { router?: AnyRouter; warn?: boolean }
@@ -0,0 +1,77 @@
1
+ const EXTERNAL_HYDRATION_PROMISE = Symbol.for('octane.external-hydration-promise');
2
+
3
+ type ExternalHydrationThenable<T> = PromiseLike<T> & {
4
+ [EXTERNAL_HYDRATION_PROMISE]: true;
5
+ status?: ThenableStatus;
6
+ value?: T;
7
+ reason?: unknown;
8
+ };
9
+
10
+ type ThenableStatus = 'pending' | 'fulfilled' | 'rejected';
11
+
12
+ const externalHydrationThenables = new WeakMap<object, ExternalHydrationThenable<unknown>>();
13
+
14
+ /**
15
+ * Wrap a router-owned promise so Octane still schedules its suspense boundary,
16
+ * while TanStack's serializer remains the only owner of its hydration value.
17
+ */
18
+ export function toExternalHydrationThenable<T>(thenable: PromiseLike<T>): PromiseLike<T> {
19
+ const key = thenable as object;
20
+ const existing = externalHydrationThenables.get(key);
21
+ if (existing) {
22
+ return existing as ExternalHydrationThenable<T>;
23
+ }
24
+
25
+ let localStatus: ThenableStatus | undefined;
26
+ let localValue: T | undefined;
27
+ let localReason: unknown;
28
+ let hasLocalValue = false;
29
+ let hasLocalReason = false;
30
+
31
+ const externalThenable: ExternalHydrationThenable<T> = {
32
+ [EXTERNAL_HYDRATION_PROMISE]: true,
33
+ get status() {
34
+ return localStatus ?? readThenableStatus(thenable);
35
+ },
36
+ set status(status) {
37
+ localStatus = status;
38
+ },
39
+ get value(): T | undefined {
40
+ return hasLocalValue ? localValue : readThenableProperty<T>(thenable, 'value');
41
+ },
42
+ set value(value: T | undefined) {
43
+ hasLocalValue = true;
44
+ localValue = value;
45
+ },
46
+ get reason(): unknown {
47
+ return hasLocalReason ? localReason : readThenableProperty<unknown>(thenable, 'reason');
48
+ },
49
+ set reason(reason) {
50
+ hasLocalReason = true;
51
+ localReason = reason;
52
+ },
53
+ then(onfulfilled, onrejected) {
54
+ return thenable.then(onfulfilled, onrejected);
55
+ },
56
+ };
57
+ externalHydrationThenables.set(key, externalThenable);
58
+ return externalThenable;
59
+ }
60
+
61
+ function readThenableStatus(thenable: PromiseLike<unknown>) {
62
+ const status = readThenableProperty(thenable, 'status');
63
+ return status === 'pending' || status === 'fulfilled' || status === 'rejected'
64
+ ? status
65
+ : undefined;
66
+ }
67
+
68
+ function readThenableProperty<T>(
69
+ thenable: PromiseLike<unknown>,
70
+ property: 'status' | 'value' | 'reason',
71
+ ): T | undefined {
72
+ try {
73
+ return (thenable as PromiseLike<unknown> & Record<string, T>)[property];
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }