@octanejs/tanstack-router 0.1.60 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/tanstack-router",
3
- "version": "0.1.60",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -52,22 +52,22 @@
52
52
  "./package.json": "./package.json"
53
53
  },
54
54
  "dependencies": {
55
- "@tanstack/history": "1.162.0",
56
- "@tanstack/router-core": "1.171.15",
55
+ "@tanstack/history": "1.162.4",
56
+ "@tanstack/router-core": "1.171.34",
57
57
  "@tanstack/store": "^0.9.3",
58
58
  "isbot": "^5.1.22"
59
59
  },
60
60
  "peerDependencies": {
61
- "octane": "^0.7.0"
61
+ "octane": "^0.8.0"
62
62
  },
63
63
  "devDependencies": {
64
- "@tanstack/react-router": "1.170.18",
64
+ "@tanstack/react-router": "1.170.41",
65
65
  "@tsrx/react": "^0.3.7",
66
66
  "esbuild": "^0.28.1",
67
67
  "react": "19.2.7",
68
68
  "react-dom": "19.2.7",
69
69
  "vitest": "^4.1.10",
70
- "octane": "0.7.0"
70
+ "octane": "0.8.0"
71
71
  },
72
72
  "scripts": {
73
73
  "test": "vitest run"
@@ -18,7 +18,7 @@ import type { OctaneNode } from 'octane';
18
18
  import type { ErrorInfo, ErrorRouteComponent } from './route.ts';
19
19
 
20
20
  export function CatchBoundary(props: {
21
- getResetKey: () => number | string;
21
+ getResetKey: () => unknown;
22
22
  children: OctaneNode;
23
23
  errorComponent?: ErrorRouteComponent;
24
24
  onCatch?: (error: Error, errorInfo: ErrorInfo) => void;
@@ -41,7 +41,7 @@ export function CatchBoundary(props: {
41
41
  function CatchErrorView(props: {
42
42
  error: any;
43
43
  reset: () => void;
44
- getResetKey?: () => number | string;
44
+ getResetKey?: () => unknown;
45
45
  errorComponent?: ErrorRouteComponent;
46
46
  onCatch?: (error: Error, errorInfo: ErrorInfo) => void;
47
47
  }) @{
package/src/Match.tsrx CHANGED
@@ -1,6 +1,5 @@
1
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
2
+ // MatchInner). `Match` subscribes to the match's routeId, resolves the route's
4
3
  // boundary components, and composes the pipeline exactly like upstream:
5
4
  //
6
5
  // Shell > matchContext > Suspense? > CatchBoundary? > CatchNotFound? > MatchInner
@@ -9,22 +8,18 @@
9
8
  // component for it — otherwise `SafeFragment` passes through so suspensions and
10
9
  // errors bubble to the nearest ancestor boundary (upstream's
11
10
  // ResolvedSuspenseBoundary/ResolvedCatchBoundary/ResolvedNotFoundBoundary).
12
- // `MatchInner` handles the match STATUS: it suspends (a thrown promise, as
13
- // upstream) 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, createElement, Suspense } from 'octane';
21
- import {
22
- createControlledPromise,
23
- getLocationChangeInfo,
24
- isNotFound,
25
- rootRouteId,
26
- } from '@tanstack/router-core';
27
- import type { NotFoundError, ParsedLocation, RootRouteOptions } from '@tanstack/router-core';
11
+ // `MatchInner` handles the match STATUS. router-core 1.171.34 owns the pending
12
+ // lifecycle (pendingMs/pendingMinMs, redirects, min-pending) and exposes a single
13
+ // in-flight transaction: a `pending` match suspends by throwing the transaction's
14
+ // promise (`router._tx[5]`) during a navigation, or renders the route's pending
15
+ // UI otherwise; `notFound` renders the route's not-found UI; `error` throws to the
16
+ // CatchBoundary (server renders the error component inline). Otherwise it renders
17
+ // the route component (or `<Outlet/>` for component-less layout routes) keyed by
18
+ // remountDeps. Per-match error-boundary reset keys off the match object identity
19
+ // (upstream dropped the global `loadedAt` store).
20
+ import { createElement, Suspense } from 'octane';
21
+ import { isNotFound, rootRouteId } from '@tanstack/router-core';
22
+ import type { NotFoundError, RootRouteOptions } from '@tanstack/router-core';
28
23
  import type { ErrorInfo, ErrorRouteComponent } from './route.ts';
29
24
  import { useStore } from './useStore.ts';
30
25
  import { useRouter, matchContext as MatchContext } from './context.ts';
@@ -36,20 +31,19 @@ import { SafeFragment } from './SafeFragment.tsrx';
36
31
  import { ClientOnly } from './ClientOnly.tsrx';
37
32
  import { ScrollRestorationScript } from './scroll-restoration.tsrx';
38
33
 
39
- export function Match(props: { matchId: string }) @{
34
+ export function Match(props: { routeId: string }) @{
40
35
  const router = useRouter();
41
- const matchStore = router.stores.matchStores.get(props.matchId)!;
42
- const resetKey = useStore(router.stores.loadedAt, (l: any) => l as number | string);
36
+ // router-core 1.171.34 keys match stores by routeId (`byRoute`/`getMatchStore`),
37
+ // not by match-instance id, and dropped the global `loadedAt` reset store —
38
+ // upstream now resets each route's error boundary on its match object identity.
39
+ const matchStore = router.stores.getMatchStore(props.routeId);
43
40
  const matchState = useStore(
44
41
  matchStore,
45
42
  (m: any) => ({
46
43
  routeId: m.routeId,
47
44
  ssr: m.ssr,
48
- _displayPending: m._displayPending,
49
45
  }),
50
- (previous, next) =>
51
- previous.routeId === next.routeId && previous.ssr === next.ssr &&
52
- previous._displayPending === next._displayPending,
46
+ (previous, next) => previous.routeId === next.routeId && previous.ssr === next.ssr,
53
47
  );
54
48
  const routeId = matchState.routeId as string;
55
49
  const route = (router.routesById as Record<string, any>)[routeId];
@@ -83,10 +77,10 @@ export function Match(props: { matchId: string }) @{
83
77
  const parentRouteId = route.parentRoute?.id;
84
78
 
85
79
  <ShellComponent>
86
- <MatchContext value={props.matchId}>
80
+ <MatchContext value={props.routeId}>
87
81
  <SuspenseWrap fallback={pendingElement}>
88
82
  <CatchWrap
89
- getResetKey={() => resetKey}
83
+ getResetKey={() => matchStore.get()}
90
84
  errorComponent={routeErrorComponent || ErrorComponent}
91
85
  onCatch={(error: Error, errorInfo: ErrorInfo) => {
92
86
  if (isNotFound(error)) {
@@ -109,87 +103,30 @@ export function Match(props: { matchId: string }) @{
109
103
  return createElement(routeNotFoundComponent, error as any);
110
104
  }}
111
105
  >
112
- @if (resolvedNoSsr || matchState._displayPending) {
106
+ @if (resolvedNoSsr) {
113
107
  <ClientOnly fallback={pendingElement}>
114
- <MatchInner matchId={props.matchId} />
108
+ <MatchInner routeId={props.routeId} />
115
109
  </ClientOnly>
116
110
  } @else {
117
- <MatchInner matchId={props.matchId} />
111
+ <MatchInner routeId={props.routeId} />
118
112
  }
119
113
  </NotFoundWrap>
120
114
  </CatchWrap>
121
115
  </SuspenseWrap>
122
116
  </MatchContext>
123
- @if (parentRouteId === rootRouteId) {
124
- <>
125
- <OnRendered />
126
- @if (router.options.scrollRestoration) {
127
- <ScrollRestorationScript />
128
- }
129
- </>
117
+ @if (parentRouteId === rootRouteId && router.options.scrollRestoration) {
118
+ <ScrollRestorationScript />
130
119
  }
131
120
  </ShellComponent>
132
121
  }
133
122
 
134
- function MatchInner(props: { matchId: string }) {
123
+ function MatchInner(props: { routeId: string }) {
135
124
  const router = useRouter();
136
- const matchStore = router.stores.matchStores.get(props.matchId)!;
125
+ const matchStore = router.stores.getMatchStore(props.routeId);
137
126
  const match = useStore(matchStore, (m: any) => m);
138
127
  const routeId = match.routeId as string;
139
128
  const route = (router.routesById as Record<string, any>)[routeId];
140
129
 
141
- // The live match in the router (if still mounted there) wins over the
142
- // snapshot for promise lookups, per upstream getMatchPromise.
143
- const getMatchPromise = (key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise') =>
144
- router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key];
145
-
146
- // Suspend by throwing, like upstream, not with `use()`. Each match state
147
- // waits on a different promise, and `use()` is positional: a replay that
148
- // finds the match in a new state would get the promise stored for the old
149
- // one. Router-core's promises settle to `status: 'resolved'`, which `use()`
150
- // reads as still pending, so that stored promise retries forever.
151
- if (match._displayPending) {
152
- throw getMatchPromise('displayPendingPromise')!;
153
- }
154
-
155
- if (match._forcePending) {
156
- throw getMatchPromise('minPendingPromise')!;
157
- }
158
-
159
- if (match.status === 'pending') {
160
- // Once pending UI shows, keep it up for at least pendingMinMs.
161
- const pendingMinMs = route.options.pendingMinMs ?? router.options.defaultPendingMinMs;
162
- if (pendingMinMs) {
163
- const routerMatch = router.getMatch(match.id);
164
- if (routerMatch && !routerMatch._nonReactive.minPendingPromise) {
165
- const minPendingPromise = createControlledPromise<void>();
166
- routerMatch._nonReactive.minPendingPromise = minPendingPromise;
167
- setTimeout(() => {
168
- minPendingPromise.resolve();
169
- routerMatch._nonReactive.minPendingPromise = undefined;
170
- }, pendingMinMs);
171
- }
172
- }
173
- throw getMatchPromise('loadPromise')!;
174
- }
175
-
176
- if (match.status === 'notFound') {
177
- return createElement(RouteNotFound, { routeId, error: match.error });
178
- }
179
-
180
- if (match.status === 'redirected') {
181
- // Observed mid-transition while a redirect is in flight — stay suspended so
182
- // this stale render is abandoned and the redirect completes.
183
- const atom = matchStore as unknown as Partial<MatchAtom>;
184
- throw typeof atom.subscribe === 'function'
185
- ? nextMatchUpdate(atom as MatchAtom, match)
186
- : getMatchPromise('loadPromise')!;
187
- }
188
-
189
- if (match.status === 'error') {
190
- throw match.error;
191
- }
192
-
193
130
  const Comp = route.options.component ?? router.options.defaultComponent;
194
131
  const remountFn = route.options.remountDeps ?? router.options.defaultRemountDeps;
195
132
  const remountDeps =
@@ -203,75 +140,52 @@ function MatchInner(props: { matchId: string }) {
203
140
  : undefined;
204
141
  const key =
205
142
  remountDeps ? JSON.stringify(remountDeps) : undefined;
206
- if (Comp) return createElement(Comp, key !== undefined ? { key } : {});
207
- return createElement(Outlet, {});
208
- }
209
-
210
- interface MatchAtom {
211
- get: () => object;
212
- subscribe: (cb: () => void) => { unsubscribe: () => void };
213
- }
143
+ const out =
144
+ Comp ? createElement(Comp, key !== undefined ? { key } : {}) : createElement(Outlet, {});
214
145
 
215
- const matchUpdates = new WeakMap<object, Promise<void>>();
216
-
217
- // Settles on the first store update after `snapshot`. A redirected match waits
218
- // on this, not on its loadPromise: router-core resolves that promise when it
219
- // marks the redirect, and a settled promise retries on a microtask until the
220
- // redirect target has loaded, starving the timers and network callbacks that
221
- // load waits on. React's scheduler paces those retries; Octane's retry does
222
- // not. Server atoms cannot subscribe, so the server keeps upstream's promise:
223
- // a server redirect is answered before the app renders.
224
- function nextMatchUpdate(store: MatchAtom, snapshot: object): Promise<void> {
225
- let update = matchUpdates.get(snapshot);
226
- if (update === undefined) {
227
- update = new Promise<void>((resolve) => {
228
- const subscription = store.subscribe(() => {
229
- subscription.unsubscribe();
230
- resolve();
231
- });
232
- // The store may have moved on between this render and the subscription.
233
- if (store.get() !== snapshot) {
234
- subscription.unsubscribe();
235
- resolve();
236
- }
237
- });
238
- matchUpdates.set(snapshot, update);
146
+ if (match.status === 'pending') {
147
+ // router-core owns the pending lifecycle (pendingMs, pendingMinMs, redirects
148
+ // and min-pending timing). During an in-flight navigation a pending match
149
+ // suspends on the transaction's promise so the current page holds; outside a
150
+ // transaction (e.g. an initial client load) it shows the route's pending UI.
151
+ //
152
+ // Octane-specific divergence from react-router: a client-only
153
+ // (`ssr: false`/`data-only`) pending match renders its pending UI in place
154
+ // inside <ClientOnly> rather than throwing to the outer Suspense. Octane's
155
+ // Suspense keeps the suspended subtree's DOM hidden on fallback, so throwing
156
+ // would leave the hidden <ClientOnly> fallback in the tree alongside the
157
+ // visible Suspense fallback — two pending elements. React's <ClientOnly>
158
+ // swaps fallback→children, so upstream can always throw.
159
+ const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only';
160
+ const tx = (router as any)._tx;
161
+ if (tx && !resolvedNoSsr) throw tx[5];
162
+ return pendingElementFor(router, route);
239
163
  }
240
- return update;
241
- }
242
-
243
- // Emits the router's `onRendered` event once the subtree below the root layout
244
- // has committed (this component renders as a later sibling of the match content,
245
- // so its layout effect runs after the subtree's). Tracks the previously-resolved
246
- // location in a ref because by effect time Transitioner has already advanced
247
- // `resolvedLocation` to the new location.
248
- function OnRendered() @{
249
- const router = useRouter();
250
- const prevResolvedLocationRef = useRef<ParsedLocation<any> | undefined>(undefined);
251
- const renderedLocationKey = useStore(
252
- router.stores.resolvedLocation,
253
- (loc: ParsedLocation<any> | undefined) => loc?.state.__TSR_key,
254
- );
255
164
 
256
- useLayoutEffect(() => {
257
- const currentResolvedLocation = router.stores.resolvedLocation.get();
258
- const previousResolvedLocation = prevResolvedLocationRef.current;
165
+ if (match.status === 'notFound') {
166
+ return createElement(RouteNotFound, { routeId, error: match.error });
167
+ }
259
168
 
260
- if (
261
- currentResolvedLocation &&
262
- (!previousResolvedLocation ||
263
- previousResolvedLocation.href !== currentResolvedLocation.href)
264
- ) {
265
- router.emit({
266
- type: 'onRendered',
267
- ...getLocationChangeInfo(
268
- router.stores.location.get(),
269
- previousResolvedLocation ?? currentResolvedLocation,
270
- ),
169
+ if (match.status === 'error') {
170
+ // On the server there is no CatchBoundary to catch a throw, so render the
171
+ // error component inline (upstream MatchInner does the same); on the client
172
+ // throw to the nearest CatchBoundary.
173
+ if ((router as any).isServer) {
174
+ const ErrorComp =
175
+ (route.options.errorComponent ?? router.options.defaultErrorComponent) || ErrorComponent;
176
+ return createElement(ErrorComp as any, {
177
+ error: match.error,
178
+ reset: undefined,
179
+ info: { componentStack: '' },
271
180
  });
272
181
  }
273
- prevResolvedLocationRef.current = currentResolvedLocation;
274
- }, [renderedLocationKey, router]);
182
+ throw match.error;
183
+ }
184
+
185
+ return out;
186
+ }
275
187
 
276
- <></>
188
+ function pendingElementFor(router: any, route: any) {
189
+ const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent;
190
+ return PendingComponent ? createElement(PendingComponent, {}) : null;
277
191
  }
@@ -1,16 +1,18 @@
1
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).
2
+ // router-core 1.171.34 dropped the `matchRouteDeps` store; upstream now
3
+ // subscribes to the location href, resolved-location href, and status directly
4
+ // so the matcher re-evaluates per navigation, and returns `matchRoute(opts)` →
5
+ // false | matched params. The component renders its children when matched — a
6
+ // render-prop child ALWAYS renders, receiving the params (false when unmatched).
7
7
  import { useCallback, isChildrenBlock } from 'octane';
8
8
  import { useRouter } from './context.ts';
9
9
  import { useStore } from './useStore.ts';
10
10
 
11
11
  export function useMatchRoute() {
12
12
  const router = useRouter();
13
- useStore(router.stores.matchRouteDeps, (d) => d);
13
+ useStore(router.stores.location, (l: any) => l.href as string);
14
+ useStore(router.stores.resolvedLocation, (l: any) => l?.href as string | undefined);
15
+ useStore(router.stores.status, (s: any) => s as string);
14
16
  return useCallback((opts: Record<string, any>) => {
15
17
  const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts;
16
18
  return router.matchRoute(rest as any, { pending, caseSensitive, fuzzy, includeSearch });
package/src/Matches.tsrx CHANGED
@@ -14,7 +14,7 @@ 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 as MatchContext } from './context.ts';
17
- import { useTransitioner } from './Transitioner.tsrx';
17
+ import { useTransitioner, settleOwner } 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';
@@ -44,14 +44,25 @@ export function Matches() @{
44
44
 
45
45
  function MatchesInner() @{
46
46
  const router = useRouter();
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);
47
+ // router-core 1.171.34 dropped the `firstId`/`loadedAt` stores: the root match
48
+ // is `matches[0]` and the global error boundary resets on its identity. While a
49
+ // navigation transition is pending, render the matches router-core expects
50
+ // (`acknowledgement[0]`) so the committed tree equals what it awaits, then
51
+ // settle that render acknowledgement from a layout effect once it lands.
52
+ const acknowledgement: Array<any> = (router as any)._rendered ??= [];
53
+ const matches = useStore(router.stores.matches, (m: any) => acknowledgement[0] ?? m);
54
+ const rootMatch = matches[0];
55
+ const routeId = rootMatch?.routeId as string | undefined;
49
56
  const CatchWrap = router.options.disableGlobalCatchBoundary ? SafeFragment : CatchBoundary;
50
57
 
51
- <MatchContext value={matchId}>
52
- <CatchWrap getResetKey={() => resetKey} errorComponent={ErrorComponent}>
53
- @if (matchId) {
54
- <Match matchId={matchId} />
58
+ useLayoutEffect(() => {
59
+ if (acknowledgement[0] === matches) settleOwner(acknowledgement, true);
60
+ }, [acknowledgement, matches]);
61
+
62
+ <MatchContext value={routeId}>
63
+ <CatchWrap getResetKey={() => rootMatch} errorComponent={ErrorComponent}>
64
+ @if (routeId) {
65
+ <Match routeId={routeId} />
55
66
  }
56
67
  </CatchWrap>
57
68
  </MatchContext>
package/src/Outlet.tsrx CHANGED
@@ -1,10 +1,10 @@
1
- // Renders the child match below the current one. Reads the current match id from
2
- // `matchContext`, finds the NEXT id in the match-id chain, and renders its `<Match/>`
3
- // — the pull-based descent that replaces a top-down state diff. Renders nothing at
4
- // a leaf.
1
+ // Renders the child match below the current one. Reads the current routeId from
2
+ // `matchContext`, finds the NEXT id in the route-id chain (`stores.ids`), and
3
+ // renders its `<Match/>` — the pull-based descent that replaces a top-down state
4
+ // diff. Renders nothing at a leaf.
5
5
  //
6
6
  // Not-found (react-router's Outlet, same order): when the URL matched no route,
7
- // router-core flags ONE match `globalNotFound` — with the default
7
+ // router-core flags ONE match `_notFound` — with the default
8
8
  // `notFoundMode: 'fuzzy'` the deepest fuzzy-matched route that has children, with
9
9
  // `notFoundMode: 'root'` the root. That match still renders its own component
10
10
  // (the layout), and its `<Outlet/>` renders the not-found UI INSTEAD of a child
@@ -19,14 +19,19 @@ import { SafeFragment } from './SafeFragment.tsrx';
19
19
 
20
20
  export function Outlet() @{
21
21
  const router = useRouter();
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>) => {
29
- const i = ids.indexOf(parentId);
22
+ // Outlet only renders inside a match's component, so the context routeId (and
23
+ // its store) are present — same invariant upstream asserts with invariant().
24
+ // router-core 1.171.34 keys stores by routeId (`getMatchStore`/`ids`) and
25
+ // renamed the global-not-found match flag `globalNotFound` → `_notFound`.
26
+ const routeId = useContext(matchContext)!;
27
+ const parentStore = router.stores.getMatchStore(routeId);
28
+ const notFound = useStore(
29
+ parentStore,
30
+ (m: any) => ({ global: !!m?._notFound, error: m?.error }),
31
+ (a, b) => a.global === b.global && a.error === b.error,
32
+ );
33
+ const childId = useStore(router.stores.ids, (ids: Array<string>) => {
34
+ const i = ids.indexOf(routeId);
30
35
  return i >= 0 ? ids[i + 1] : undefined;
31
36
  });
32
37
 
@@ -35,16 +40,16 @@ export function Outlet() @{
35
40
  // Outlet does the same) — the outermost pending UI for the initial load.
36
41
  const DefaultPending = router.options.defaultPendingComponent;
37
42
  const RootSuspense =
38
- parentRouteId === rootRouteId ? Suspense : SafeFragment;
43
+ routeId === rootRouteId ? Suspense : SafeFragment;
39
44
  const pendingElement =
40
45
  DefaultPending ? createElement(DefaultPending, {}) : null;
41
46
 
42
- @if (globalNotFound) {
43
- <RouteNotFound routeId={parentRouteId!} />
47
+ @if (notFound.global) {
48
+ <RouteNotFound routeId={routeId} error={notFound.error} />
44
49
  } @else {
45
50
  @if (childId) {
46
51
  <RootSuspense fallback={pendingElement}>
47
- <Match matchId={childId} />
52
+ <Match routeId={childId} />
48
53
  </RootSuspense>
49
54
  }
50
55
  }
@@ -1,17 +1,22 @@
1
- import { useEffect, useState } from 'octane';
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.
2
8
  import { isServer } from '@tanstack/router-core/isServer';
3
9
  import { useRouter } from './context.ts';
10
+ import { useHydrated } from './ClientOnly.tsrx';
4
11
 
5
12
  export function ScriptOnce(props: { children: string }) @{
6
13
  const router = useRouter();
7
- const [hydrating, setHydrating] = useState(true);
8
- useEffect(() => {
9
- setHydrating(false);
10
- }, []);
14
+ const hydrated = useHydrated();
11
15
 
12
- @if ((isServer ?? router.isServer) || hydrating) {
16
+ @if ((isServer ?? router.isServer) || !hydrated) {
13
17
  <script
14
18
  nonce={router.options.ssr?.nonce}
19
+ suppressHydrationWarning
15
20
  dangerouslySetInnerHTML={{
16
21
  __html: props.children,
17
22
  }}
package/src/Scripts.tsrx CHANGED
@@ -1,37 +1,22 @@
1
- import { createElement, useEffect, useId, useState } from 'octane';
2
- import { isServer } from '@tanstack/router-core/isServer';
1
+ import { createElement, useId } from 'octane';
3
2
  import type { RouterManagedTag } from '@tanstack/router-core';
4
3
  import { getAssetKeys } from './assetKeys.ts';
5
- import { useRouter } from './context.ts';
6
4
  import { useScripts } from './scriptContentUtils.ts';
7
5
 
6
+ // router-core 1.171.34 owns the hydration-script lifecycle: the server appends
7
+ // the initial hydration tags (and the streaming boundary) through
8
+ // `useScripts()` / `takeInitialHydrationScriptTags()`, and those tags remove
9
+ // themselves (`document.currentScript.remove()`) as they run. By the time the
10
+ // client hydrates, the DOM holds only the body scripts, which is exactly what
11
+ // the client renders — so there is no client-only barrier to prepend (the former
12
+ // `$tsr-stream-barrier` belonged to the removed buffered-script model and left a
13
+ // client <script> the server never rendered).
8
14
  export function Scripts() @{
9
15
  const owner = useId();
10
- const router = useRouter();
11
- const scripts = useScripts();
12
- const [hydrating, setHydrating] = useState(true);
13
- useEffect(() => {
14
- setHydrating(false);
15
- }, []);
16
- const renderScripts: Array<RouterManagedTag> =
17
- !(isServer ?? router.isServer) && hydrating
18
- ? [
19
- {
20
- tag: 'script',
21
- attrs: {
22
- nonce: router.options.ssr?.nonce,
23
- className: '$tsr',
24
- id: '$tsr-stream-barrier',
25
- suppressHydrationWarning: true,
26
- },
27
- children: '',
28
- },
29
- ...scripts,
30
- ]
31
- : scripts;
32
- const keys = getAssetKeys('body', owner, renderScripts);
16
+ const scripts: Array<RouterManagedTag> = useScripts();
17
+ const keys = getAssetKeys('body', owner, scripts);
33
18
  <>
34
- {renderScripts.map((script, index) => {
19
+ {scripts.map((script, index) => {
35
20
  const { assetKey, managedKey } = keys[index]!;
36
21
  return createElement('script', {
37
22
  ...script.attrs,