@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.
@@ -1,57 +1,66 @@
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
1
+ // The navigation engine — port of react-router's Transitioner. It runs as a hook
2
+ // because a rendered sibling would place an SSR marker before a root route's
3
3
  // document-level <html> node and outside the #__app hydration range.
4
- // It (1) supplies `router.startTransition` so every navigation state update rides
5
- // an octane transition (concurrent navigation: the current page holds while the
6
- // next route suspends), (2) subscribes to history so back/forward and `Link`
7
- // clicks reload the matches, (3) kicks off the initial `router.load()` (skipped
8
- // when hydrating SSR — ssr-client triggers it) and commits a canonical-URL
9
- // replace when the mounted URL isn't in canonical form, and (4) drives the router
10
- // EVENT LIFECYCLE: `onLoad` when a load settles, `onBeforeRouteMount` /
11
- // `onResolved` when all pending work (load + transition + pending matches)
12
- // drains, then commits `status: 'idle'` + `resolvedLocation`. router-core's
13
- // scroll restoration, devtools, and `router.subscribe` consumers all key off
14
- // these events, and `useRouterState` selectors on `resolvedLocation` depend on
15
- // the commit.
16
- import { useEffect, useLayoutEffect, useRef, useState, startTransition } from 'octane';
17
- import { batch } from '@tanstack/store';
4
+ //
5
+ // router-core 1.171.34 owns the navigation lifecycle end to end: `load` drives
6
+ // `startViewTransition` → `startTransition(commit, matches)`, awaits the render
7
+ // acknowledgement, then emits `onLoad`/`onBeforeRouteMount`/`onResolved`/
8
+ // `onRendered` and commits `status`/`resolvedLocation` itself. The binding's only
9
+ // remaining jobs are:
10
+ // 1. Supply `router.startTransition(fn, expected)` as that render
11
+ // ACKNOWLEDGEMENT — a promise router-core awaits that resolves `true` only
12
+ // once `MatchesInner` has rendered the `expected` matches (via `settleOwner`).
13
+ // Running `fn` inside an octane transition is what lets the current page hold
14
+ // while the next route suspends.
15
+ // 2. Subscribe to history so back/forward and `Link` clicks reload; replace a
16
+ // non-canonical mounted URL (mirrors the server-side check in beforeLoad);
17
+ // and kick off the initial load — or, when hydrating an already-resolved SSR
18
+ // location, acknowledge the first render so `onRendered` consumers (scroll
19
+ // restoration, devtools) fire.
20
+ import { useLayoutEffect, useState, startTransition } from 'octane';
18
21
  import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core';
19
22
  import { useRouter } from './context.ts';
20
- import { useStore } from './useStore.ts';
21
- import { usePrevious } from './utils.ts';
22
23
 
23
- export function useTransitioner() {
24
- const router = useRouter();
25
- const mountLoadForRouter = useRef({ router, mounted: false });
26
- const [isTransitioning, setIsTransitioning] = useState(false);
24
+ // Resolve the pending render acknowledgement with whether the expected matches
25
+ // 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;
28
+ owner.length = 0;
29
+ settle?.(rendered);
30
+ }
27
31
 
28
- const isLoading = useStore(router.stores.isLoading, (v) => v);
29
- const hasPending = useStore(router.stores.hasPending, (v) => v);
32
+ export function useTransitioner() {
33
+ const router = useRouter() as any;
34
+ const [, forceRender] = useState<object>();
30
35
 
31
- const previousIsLoading = usePrevious(isLoading);
32
- const isAnyPending = isLoading || isTransitioning || hasPending;
33
- const previousIsAnyPending = usePrevious(isAnyPending);
34
- const isPagePending = isLoading || hasPending;
35
- const previousIsPagePending = usePrevious(isPagePending);
36
+ const acknowledgement: Array<any> = router._rendered ??= [];
36
37
 
37
- router.startTransition = (fn: () => void) => {
38
- setIsTransitioning(true);
39
- startTransition(() => {
40
- fn();
41
- setIsTransitioning(false);
42
- });
43
- };
38
+ router.startTransition = (fn: () => void, expected: unknown) => new Promise<boolean>((
39
+ resolve,
40
+ ) => {
41
+ // A newer transition supersedes an unrendered older one.
42
+ settleOwner(acknowledgement, false);
43
+ acknowledgement.push(expected, resolve);
44
+ // Force `MatchesInner` to re-read: it renders `acknowledgement[0]` and
45
+ // settles this promise from a layout effect once that commit lands.
46
+ forceRender({});
47
+ startTransition(fn);
48
+ });
44
49
 
45
- // Subscribe to location changes and load the new location. On mount, also
46
- // verify the current URL is in canonical form (search serialization,
47
- // trailing slash) and replace it if not — mirroring the server-side redirect
48
- // check in router.beforeLoad.
49
- useEffect(() => {
50
+ useLayoutEffect(() => {
50
51
  const unsub = router.history.subscribe(() => router.load());
51
52
 
53
+ // octane has no StrictMode double-invoke, so this layout effect runs once per
54
+ // mounted router and re-runs only when the router (or its history) is swapped
55
+ // on a live provider — exactly when the new router needs its initial load.
56
+ // Upstream guards this block with a dev-only `mounted` ref purely to absorb
57
+ // React's StrictMode double-invoke; keeping that ref here would instead skip
58
+ // the swapped-in router's load.
59
+ router.updateLatestLocation?.();
60
+ const location = router.latestLocation;
52
61
  const nextLocation = router.buildLocation(
53
62
  {
54
- to: router.latestLocation.pathname,
63
+ to: location.pathname,
55
64
  search: true,
56
65
  params: true,
57
66
  hash: true,
@@ -59,70 +68,32 @@ export function useTransitioner() {
59
68
  _includeValidateSearch: true,
60
69
  } as any,
61
70
  );
62
- if (
63
- trimPathRight(router.latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref)
64
- ) {
65
- router.commitLocation({ ...nextLocation, replace: true });
71
+ if (trimPathRight(location.publicHref) !== trimPathRight(nextLocation.publicHref)) {
72
+ router.commitLocation({ ...nextLocation, replace: true, ignoreBlocker: true });
73
+ return unsub;
66
74
  }
67
75
 
68
- return () => unsub();
69
- }, [router, router.history]);
70
-
71
- // Initial load. Skipped when hydrating from SSR (ssr-client triggers it) and
72
- // on re-runs for the same router instance.
73
- useLayoutEffect(() => {
76
+ const resolvedLocation = router.stores.resolvedLocation.get();
74
77
  if (
75
- typeof window !== 'undefined' && router.ssr ||
76
- mountLoadForRouter.current.router === router && mountLoadForRouter.current.mounted
78
+ resolvedLocation?.href === location.href &&
79
+ resolvedLocation.state.__TSR_key === location.state.__TSR_key
77
80
  ) {
78
- return;
79
- }
80
- mountLoadForRouter.current = { router, mounted: true };
81
- router.load().catch((err: unknown) => {
82
- console.error(err);
83
- });
84
- }, [router]);
85
-
86
- // The router was loading and now it's not — the new matches are in state.
87
- useLayoutEffect(() => {
88
- if (previousIsLoading && !isLoading) {
81
+ // Already resolved (SSR hydration): emit `onRendered` for scroll
82
+ // restoration and devtools. The match tree has already committed by the
83
+ // time this layout effect runs (child effects fire before the parent's),
84
+ // so emit directly rather than through the acknowledgement/settle path:
85
+ // hydration acknowledges the matches already on screen, which — unlike a
86
+ // navigation that commits the *next* matches — triggers no re-render for
87
+ // `MatchesInner`'s settle effect to run on.
89
88
  router.emit({
90
- type: 'onLoad',
91
- ...getLocationChangeInfo(
92
- router.stores.location.get(),
93
- router.stores.resolvedLocation.get(),
94
- ),
89
+ type: 'onRendered',
90
+ ...getLocationChangeInfo(resolvedLocation, resolvedLocation),
95
91
  });
96
- }
97
- }, [previousIsLoading, router, isLoading]);
98
-
99
- useLayoutEffect(() => {
100
- if (previousIsPagePending && !isPagePending) {
101
- router.emit({
102
- type: 'onBeforeRouteMount',
103
- ...getLocationChangeInfo(
104
- router.stores.location.get(),
105
- router.stores.resolvedLocation.get(),
106
- ),
92
+ } else if (!router._tx) {
93
+ router.load({ sync: true }).catch((err: unknown) => {
94
+ console.error(err);
107
95
  });
108
96
  }
109
- }, [isPagePending, previousIsPagePending, router]);
110
-
111
- // Everything pending has drained — resolve the navigation: emit onResolved and
112
- // commit status idle + resolvedLocation (what `onRendered` and scroll
113
- // restoration key off).
114
- useLayoutEffect(() => {
115
- if (previousIsAnyPending && !isAnyPending) {
116
- const changeInfo = getLocationChangeInfo(
117
- router.stores.location.get(),
118
- router.stores.resolvedLocation.get(),
119
- );
120
- router.emit({ type: 'onResolved', ...changeInfo });
121
-
122
- batch(() => {
123
- router.stores.status.set('idle');
124
- router.stores.resolvedLocation.set(router.stores.location.get());
125
- });
126
- }
127
- }, [isAnyPending, previousIsAnyPending, router]);
97
+ return unsub;
98
+ }, [router, router.history]);
128
99
  }
package/src/hooks.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // The read hooks — ports of react-router's useMatch.tsx / useParams / useSearch /
2
2
  // useLoaderData / useLoaderDeps / useRouteContext / useNavigate / useCanGoBack /
3
3
  // Matches.tsx (useMatches / useParentMatches / useChildMatches). Everything match-
4
- // shaped funnels through `useMatch`, which subscribes to ONE match store:
5
- // - `from` given → `router.stores.getRouteMatchStore(from)` (a cached computed
6
- // that resolves a routeId to its current match);
4
+ // shaped funnels through `useMatch`, which subscribes to ONE match store via
5
+ // `router.stores.getMatchStore(routeId)`:
6
+ // - `from` given → that routeId's match store;
7
7
  // - no `from` → the NEAREST match via `matchContext` (the match id the enclosing
8
8
  // `<Match>` provided) — NOT the leaf match.
9
9
  // A missing match throws unless `shouldThrow: false` (upstream invariant).
@@ -89,9 +89,8 @@ export function useMatch(...args: any[]): any {
89
89
  // octane has no rules of hooks, so the nearest-match context is read
90
90
  // unconditionally (upstream reads a dummy context when `from` is given).
91
91
  const nearestMatchId = useContext(matchContext);
92
- const matchStore = opts.from
93
- ? router.stores.getRouteMatchStore(opts.from)
94
- : router.stores.matchStores.get(nearestMatchId as string);
92
+ const routeId = opts.from ?? (nearestMatchId as string);
93
+ const matchStore = router.stores.getMatchStore(routeId);
95
94
 
96
95
  const selector = useStructuralSharing(opts, router, subSlot(slot, 'm'));
97
96
  const matchSelection = useStore(
@@ -302,13 +301,13 @@ export function useParentMatches(opts: any, slot: symbol | undefined): any;
302
301
  export function useParentMatches(...args: any[]): any {
303
302
  const [user, slot] = splitSlot(args);
304
303
  const opts = user[0] ?? {};
305
- const contextMatchId = useContext(matchContext);
304
+ const contextRouteId = useContext(matchContext);
306
305
  return useMatches(
307
306
  {
308
307
  select: (matches: any[]) => {
309
308
  matches = matches.slice(
310
309
  0,
311
- matches.findIndex((d: any) => d.id === contextMatchId),
310
+ matches.findIndex((d: any) => d.routeId === contextRouteId),
312
311
  );
313
312
  return opts.select ? opts.select(matches) : matches;
314
313
  },
@@ -330,11 +329,11 @@ export function useChildMatches(opts: any, slot: symbol | undefined): any;
330
329
  export function useChildMatches(...args: any[]): any {
331
330
  const [user, slot] = splitSlot(args);
332
331
  const opts = user[0] ?? {};
333
- const contextMatchId = useContext(matchContext);
332
+ const contextRouteId = useContext(matchContext);
334
333
  return useMatches(
335
334
  {
336
335
  select: (matches: any[]) => {
337
- matches = matches.slice(matches.findIndex((d: any) => d.id === contextMatchId) + 1);
336
+ matches = matches.slice(matches.findIndex((d: any) => d.routeId === contextRouteId) + 1);
338
337
  return opts.select ? opts.select(matches) : matches;
339
338
  },
340
339
  structuralSharing: opts.structuralSharing,
package/src/link.ts CHANGED
@@ -195,10 +195,14 @@ export function useLinkProps(...args: any[]): Record<string, any> {
195
195
  if (!pathIsFuzzyEqual) return false;
196
196
  }
197
197
  if (activeOptions?.includeSearch ?? true) {
198
- const searchTest = deepEqual(currentLocation.search, next.search, {
199
- partial: !activeOptions?.exact,
200
- ignoreUndefined: !activeOptions?.explicitUndefined,
201
- });
198
+ // router-core 1.171.34 deepEqual: positional (partial, explicitUndefined)
199
+ // instead of an options object; the 4th arg is explicitUndefined directly.
200
+ const searchTest = deepEqual(
201
+ currentLocation.search,
202
+ next.search,
203
+ !activeOptions?.exact,
204
+ activeOptions?.explicitUndefined,
205
+ );
202
206
  if (!searchTest) return false;
203
207
  }
204
208
  if (activeOptions?.includeHash) return currentLocation.hash === next.hash;
package/src/router.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  createNonReactiveReadonlyStore,
13
13
  } from '@tanstack/router-core';
14
14
  import { createAtom, batch } from '@tanstack/store';
15
- import { startTransition } from 'octane';
16
15
  import type { RouterHistory } from '@tanstack/history';
17
16
  import type {
18
17
  AnyRoute,
@@ -23,11 +22,12 @@ import type {
23
22
 
24
23
  const isServerEnv = typeof document === 'undefined';
25
24
 
26
- // Batch router mutations atomically, including resolved commits delivered by a
27
- // later View Transition callback. The transition scope preserves navigation
28
- // bookkeeping; useSyncExternalStore notifications still render urgently. Routes
29
- // that keep stale content while new data loads should defer their render input
30
- // with useDeferredValue. Server snapshots use the non-reactive factory below.
25
+ // Store factory for RouterCore — the only framework-specific wiring. Client
26
+ // stores are reactive @tanstack/store atoms (framework-agnostic; `createAtom`
27
+ // lives in @tanstack/store, not @tanstack/react-store); server snapshots use the
28
+ // non-reactive factory. router-core 1.171.34 drives concurrent navigation through
29
+ // `router.startTransition` (see Transitioner), so `batch` is a plain atomic batch
30
+ // rather than one wrapped in an octane transition.
31
31
  const octaneStoreFactory = (opts: { isServer?: boolean }) => {
32
32
  if (opts?.isServer ?? isServerEnv) {
33
33
  return {
@@ -39,7 +39,7 @@ const octaneStoreFactory = (opts: { isServer?: boolean }) => {
39
39
  return {
40
40
  createMutableStore: createAtom,
41
41
  createReadonlyStore: createAtom,
42
- batch: (fn: () => void) => startTransition(() => batch(fn)),
42
+ batch,
43
43
  };
44
44
  };
45
45
 
@@ -65,102 +65,15 @@ export class Router<
65
65
  TDehydrated
66
66
  >,
67
67
  ) {
68
+ // router-core 1.171.34 owns the navigation lifecycle end to end: it drives
69
+ // `startViewTransition` → `startTransition(commit, matches)` and awaits the
70
+ // render acknowledgement (supplied by `useTransitioner`), then emits
71
+ // onLoad/onBeforeRouteMount/onResolved/onRendered and commits
72
+ // `status`/`resolvedLocation` itself. So `await router.load()` is already a
73
+ // render-readiness boundary and the previous view-transition/load wrapper is
74
+ // no longer needed. HTTP status is derived on demand by the SSR layer
75
+ // (`getSsrStatus`), not stored on the router.
68
76
  super(options, octaneStoreFactory);
69
-
70
- // router-core starts the resolved-match commit through startViewTransition,
71
- // whose browser callback may run after router-core's load promise resolves.
72
- // Track those callbacks so `await router.load()` is a real render-readiness
73
- // boundary: the active match tree is committed before a consumer's first
74
- // render or hydration pass.
75
- const coreLoad = this.load.bind(this);
76
- const coreStartViewTransition = this.startViewTransition.bind(this);
77
- const pendingViewCommits = new Set<Promise<void>>();
78
- const activeLoadScopes = new Set<Set<Promise<void>>>();
79
-
80
- this.startViewTransition = (fn: () => Promise<void>) => {
81
- let resolveCommit!: () => void;
82
- let rejectCommit!: (error: unknown) => void;
83
- const commit = new Promise<void>((resolve, reject) => {
84
- resolveCommit = resolve;
85
- rejectCommit = reject;
86
- });
87
- pendingViewCommits.add(commit);
88
- for (const scope of activeLoadScopes) scope.add(commit);
89
- // Keep only unsettled callbacks globally. A later load waits for a prior
90
- // callback so no mutation can land after its readiness boundary, but only
91
- // the active scopes above own (and therefore propagate) this failure.
92
- void commit.then(
93
- () => pendingViewCommits.delete(commit),
94
- () => pendingViewCommits.delete(commit),
95
- );
96
-
97
- const runCommit = async () => {
98
- try {
99
- await fn();
100
- resolveCommit();
101
- } catch (error) {
102
- rejectCommit(error);
103
- }
104
- };
105
-
106
- try {
107
- coreStartViewTransition(runCommit);
108
- } catch (error) {
109
- rejectCommit(error);
110
- throw error;
111
- }
112
- };
113
-
114
- this.load = async (...args: any[]) => {
115
- const prerequisiteCommits = new Set(pendingViewCommits);
116
- const viewCommits = new Set<Promise<void>>();
117
- activeLoadScopes.add(viewCommits);
118
- let hasLoadError = false;
119
- let loadError: unknown;
120
- let result: void;
121
- try {
122
- result = await coreLoad(...args);
123
- } catch (error) {
124
- hasLoadError = true;
125
- loadError = error;
126
- } finally {
127
- // All commits started by this core load are now registered. Stop
128
- // accepting commits from later navigations before awaiting this scope.
129
- activeLoadScopes.delete(viewCommits);
130
- }
131
-
132
- let hasCommitError = false;
133
- let commitError: unknown;
134
- const [, outcomes] = await Promise.all([
135
- Promise.allSettled(prerequisiteCommits),
136
- Promise.allSettled(viewCommits),
137
- ]);
138
- const rejected = outcomes.find((outcome) => outcome.status === 'rejected');
139
- if (rejected?.status === 'rejected') {
140
- hasCommitError = true;
141
- commitError = rejected.reason;
142
- }
143
- if (hasLoadError) throw loadError;
144
- if (hasCommitError) throw commitError;
145
-
146
- // RouterCore derives the final HTTP status immediately after its internal
147
- // load promise resolves. A platform-deferred View Transition can commit the
148
- // new tree only after that point, so finalize every branch once the
149
- // render-ready tree is present. RouterCore has already committed a
150
- // redirect and its HTTP status together, so preserve that authoritative
151
- // status; otherwise a successful tree must also clear a stale 404/500.
152
- const state = this.state;
153
- const statusCode =
154
- state.redirect != null
155
- ? state.statusCode
156
- : this.hasNotFoundMatch()
157
- ? 404
158
- : state.matches.some((match: any) => match.status === 'error')
159
- ? 500
160
- : 200;
161
- this.stores.statusCode.set(statusCode);
162
- return result;
163
- };
164
77
  }
165
78
  }
166
79
 
@@ -1,64 +1,37 @@
1
- import { deepEqual } from '@tanstack/router-core';
1
+ import { composeSsrBodyScripts, deepEqual, getSsrBodyScriptParts } from '@tanstack/router-core';
2
2
  import { isServer } from '@tanstack/router-core/isServer';
3
3
  import { useRouter } from './context';
4
4
  import { splitSlot, subSlot } from './internal';
5
5
  import { useStore } from './useStore';
6
- import type { AnyRouteMatch, AnyRouter, RouterManagedTag } from '@tanstack/router-core';
6
+ import type { AnyRouteMatch, RouterManagedTag } from '@tanstack/router-core';
7
7
 
8
- function getScripts(router: AnyRouter, matches: Array<AnyRouteMatch>) {
9
- const nonce = router.options.ssr?.nonce;
10
- const scripts: Array<RouterManagedTag> = matches
11
- .flatMap((match) => match.scripts ?? [])
12
- .filter((script) => script !== undefined)
13
- .map(({ children, ...attrs }) => ({
14
- tag: 'script',
15
- attrs: { ...attrs, nonce },
16
- children,
17
- }));
18
-
19
- const manifest = router.ssr?.manifest;
20
- if (manifest) {
21
- for (const match of matches) {
22
- for (const asset of manifest.routes[match.routeId]?.scripts ?? []) {
23
- scripts.push({
24
- tag: 'script',
25
- attrs: { ...asset.attrs, nonce },
26
- children: asset.children,
27
- });
28
- }
29
- }
30
- }
31
-
32
- return scripts;
33
- }
8
+ // router-core 1.171.34 composes body scripts through `getSsrBodyScriptParts` +
9
+ // `composeSsrBodyScripts`, and the initial hydration `<Scripts>` take moved from
10
+ // the removed `serverSsr.takeBufferedScripts()` (a single buffered tag) to
11
+ // `serverSsr.takeInitialHydrationScriptTags()` (hydration tags plus the streaming
12
+ // boundary), which `composeSsrBodyScripts` interleaves.
13
+ const routeScriptAttrs = { suppressHydrationWarning: true };
34
14
 
35
15
  export function useScripts(...args: Array<unknown>): Array<RouterManagedTag> {
36
16
  const [, slot] = splitSlot(args);
37
17
  const router = useRouter();
18
+ const nonce = router.options.ssr?.nonce;
19
+ const getScripts = (matches: Array<AnyRouteMatch>) =>
20
+ composeSsrBodyScripts(
21
+ getSsrBodyScriptParts(matches, router.ssr?.manifest, nonce, routeScriptAttrs),
22
+ );
38
23
 
39
24
  if (isServer ?? router.isServer) {
40
- const scripts = getScripts(router, router.stores.matches.get());
41
- const buffered = router.serverSsr?.takeBufferedScripts();
42
- if (!buffered || buffered.tag !== 'script') {
43
- return scripts;
44
- }
45
- return [
46
- {
47
- tag: 'script',
48
- attrs: buffered.attrs,
49
- children:
50
- typeof buffered.children === 'string'
51
- ? buffered.children.replace(/;document\.currentScript\.remove\(\)$/, '')
52
- : buffered.children,
53
- },
54
- ...scripts,
55
- ];
25
+ return composeSsrBodyScripts(
26
+ getSsrBodyScriptParts(
27
+ router.stores.matches.get(),
28
+ router.ssr?.manifest,
29
+ nonce,
30
+ routeScriptAttrs,
31
+ ),
32
+ router.serverSsr?.takeInitialHydrationScriptTags(),
33
+ );
56
34
  }
57
35
 
58
- return useStore(
59
- router.stores.matches,
60
- (matches: Array<AnyRouteMatch>) => getScripts(router, matches),
61
- deepEqual,
62
- subSlot(slot, 'body:scripts'),
63
- );
36
+ return useStore(router.stores.matches, getScripts, deepEqual, subSlot(slot, 'body:scripts'));
64
37
  }
@@ -1,15 +1,28 @@
1
+ // Port of react-router's scroll-restoration.tsx. router-core's server build of
2
+ // `getScrollRestorationScriptForRouter` returns the inline restore script unless
3
+ // a `scrollRestoration` function declines the location; its client build always
4
+ // returns null. Upstream's client renders null because its ScriptOnce removes
5
+ // itself before React hydrates. Octane hydration claims the server's arm, so the
6
+ // client takes the arm the server took and ScriptOnce adopts, then drops, the
7
+ // server's script.
1
8
  import {
2
9
  getScrollRestorationScriptForRouter,
3
10
  } from '@tanstack/router-core/scroll-restoration-script';
11
+ import type { AnyRouter } from '@tanstack/router-core';
4
12
  import { useRouter } from './context.ts';
5
13
  import { ScriptOnce } from './ScriptOnce.tsrx';
6
14
 
15
+ function rendersScript(router: AnyRouter): boolean {
16
+ const option = router.options.scrollRestoration;
17
+ return typeof option !== 'function' || option({ location: router.latestLocation });
18
+ }
19
+
7
20
  export function ScrollRestorationScript() @{
8
21
  const router = useRouter();
9
22
  const script = getScrollRestorationScriptForRouter(router);
10
23
 
11
- @if (script) {
12
- <ScriptOnce children={script} />
24
+ @if (script !== null || rendersScript(router)) {
25
+ <ScriptOnce children={script ?? ''} />
13
26
  } @else {
14
27
  <></>
15
28
  }
@@ -16,7 +16,7 @@ const ready = {
16
16
  function getHydrationPromise(router: AnyRouter) {
17
17
  let promise = hydrationPromises.get(router);
18
18
  if (!promise) {
19
- promise = router.stores.matchesId.get().length ? ready as PromiseLike<void> : hydrate(router);
19
+ promise = router.stores.ids.get().length ? ready as PromiseLike<void> : hydrate(router);
20
20
  hydrationPromises.set(router, promise);
21
21
  }
22
22
  return promise;