@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 +6 -6
- package/src/CatchBoundary.tsrx +2 -2
- package/src/Match.tsrx +69 -155
- package/src/MatchRoute.tsrx +8 -6
- package/src/Matches.tsrx +18 -7
- package/src/Outlet.tsrx +22 -17
- package/src/ScriptOnce.tsrx +11 -6
- package/src/Scripts.tsrx +12 -27
- package/src/Transitioner.tsrx +71 -100
- package/src/hooks.ts +9 -10
- package/src/link.ts +8 -4
- package/src/router.ts +15 -102
- package/src/scriptContentUtils.ts +23 -50
- package/src/scroll-restoration.tsrx +15 -2
- package/src/ssr/RouterClient.tsrx +1 -1
- package/src/ssr/renderRouterToStream.ts +108 -138
- package/src/ssr/renderRouterToString.ts +27 -39
- package/src/useBlocker.tsrx +8 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/tanstack-router",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
56
|
-
"@tanstack/router-core": "1.171.
|
|
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.
|
|
61
|
+
"octane": "^0.8.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@tanstack/react-router": "1.170.
|
|
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.
|
|
70
|
+
"octane": "0.8.0"
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
73
73
|
"test": "vitest run"
|
package/src/CatchBoundary.tsrx
CHANGED
|
@@ -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: () =>
|
|
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?: () =>
|
|
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
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
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: {
|
|
34
|
+
export function Match(props: { routeId: string }) @{
|
|
40
35
|
const router = useRouter();
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
80
|
+
<MatchContext value={props.routeId}>
|
|
87
81
|
<SuspenseWrap fallback={pendingElement}>
|
|
88
82
|
<CatchWrap
|
|
89
|
-
getResetKey={() =>
|
|
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
|
|
106
|
+
@if (resolvedNoSsr) {
|
|
113
107
|
<ClientOnly fallback={pendingElement}>
|
|
114
|
-
<MatchInner
|
|
108
|
+
<MatchInner routeId={props.routeId} />
|
|
115
109
|
</ClientOnly>
|
|
116
110
|
} @else {
|
|
117
|
-
<MatchInner
|
|
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: {
|
|
123
|
+
function MatchInner(props: { routeId: string }) {
|
|
135
124
|
const router = useRouter();
|
|
136
|
-
const matchStore = router.stores.
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
// on
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
165
|
+
if (match.status === 'notFound') {
|
|
166
|
+
return createElement(RouteNotFound, { routeId, error: match.error });
|
|
167
|
+
}
|
|
259
168
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
}
|
|
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
|
}
|
package/src/MatchRoute.tsrx
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// useMatchRoute / MatchRoute — port of react-router's Matches.tsx matcher pair.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
2
|
-
// `matchContext`, finds the NEXT id in the
|
|
3
|
-
// — the pull-based descent that replaces a top-down state
|
|
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 `
|
|
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
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
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
|
-
|
|
43
|
+
routeId === rootRouteId ? Suspense : SafeFragment;
|
|
39
44
|
const pendingElement =
|
|
40
45
|
DefaultPending ? createElement(DefaultPending, {}) : null;
|
|
41
46
|
|
|
42
|
-
@if (
|
|
43
|
-
<RouteNotFound routeId={
|
|
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
|
|
52
|
+
<Match routeId={childId} />
|
|
48
53
|
</RootSuspense>
|
|
49
54
|
}
|
|
50
55
|
}
|
package/src/ScriptOnce.tsrx
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
|
|
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
|
|
8
|
-
useEffect(() => {
|
|
9
|
-
setHydrating(false);
|
|
10
|
-
}, []);
|
|
14
|
+
const hydrated = useHydrated();
|
|
11
15
|
|
|
12
|
-
@if ((isServer ?? router.isServer) ||
|
|
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,
|
|
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
|
|
11
|
-
const
|
|
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
|
-
{
|
|
19
|
+
{scripts.map((script, index) => {
|
|
35
20
|
const { assetKey, managedKey } = keys[index]!;
|
|
36
21
|
return createElement('script', {
|
|
37
22
|
...script.attrs,
|