@octanejs/tanstack-router 0.1.2
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/LICENSE +21 -0
- package/README.md +94 -0
- package/package.json +47 -0
- package/src/Await.tsrx +21 -0
- package/src/Await.tsrx.d.ts +6 -0
- package/src/CatchBoundary.tsrx +91 -0
- package/src/CatchBoundary.tsrx.d.ts +8 -0
- package/src/ClientOnly.tsrx +21 -0
- package/src/ClientOnly.tsrx.d.ts +3 -0
- package/src/Link.tsrx +32 -0
- package/src/Link.tsrx.d.ts +4 -0
- package/src/Match.tsrx +204 -0
- package/src/Match.tsrx.d.ts +2 -0
- package/src/MatchRoute.tsrx +32 -0
- package/src/MatchRoute.tsrx.d.ts +3 -0
- package/src/Matches.tsrx +58 -0
- package/src/Matches.tsrx.d.ts +2 -0
- package/src/Navigate.tsrx +14 -0
- package/src/Navigate.tsrx.d.ts +2 -0
- package/src/Outlet.tsrx +49 -0
- package/src/Outlet.tsrx.d.ts +2 -0
- package/src/RouteNotFound.tsrx +23 -0
- package/src/RouterProvider.tsrx +37 -0
- package/src/RouterProvider.tsrx.d.ts +8 -0
- package/src/SafeFragment.tsrx +12 -0
- package/src/SafeFragment.tsrx.d.ts +2 -0
- package/src/ScrollRestoration.tsrx +16 -0
- package/src/ScrollRestoration.tsrx.d.ts +2 -0
- package/src/Transitioner.tsrx +126 -0
- package/src/context.ts +27 -0
- package/src/history.ts +10 -0
- package/src/hooks.ts +225 -0
- package/src/index.ts +91 -0
- package/src/internal.ts +31 -0
- package/src/lazyRouteComponent.ts +56 -0
- package/src/link.ts +383 -0
- package/src/not-found.tsrx +39 -0
- package/src/not-found.tsrx.d.ts +7 -0
- package/src/route.ts +147 -0
- package/src/router.ts +63 -0
- package/src/useAwaited.ts +9 -0
- package/src/useBlocker.tsrx +136 -0
- package/src/useBlocker.tsrx.d.ts +3 -0
- package/src/useElementScrollRestoration.ts +12 -0
- package/src/useRouterState.ts +17 -0
- package/src/useStore.ts +46 -0
- package/src/utils.ts +18 -0
package/src/internal.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Slot mechanics shared by the binding's plain-`.ts` hooks (copied from
|
|
2
|
+
// @octanejs/tanstack-query). The octane compiler injects a per-call-site Symbol slot into
|
|
3
|
+
// every hook call in `.tsrx`/`.tsx`, but these binding files are NOT compiled — so
|
|
4
|
+
// a hook here receives the caller's slot as its trailing argument and derives a
|
|
5
|
+
// distinct sub-slot for each base hook it composes.
|
|
6
|
+
|
|
7
|
+
// Derive a stable, distinct sub-slot from a wrapper's slot, namespaced per hook so
|
|
8
|
+
// composing multiple base hooks gives each its own identity.
|
|
9
|
+
// Memoized: subSlot runs on EVERY hook call every render, and the naive form
|
|
10
|
+
// pays a string concat + global symbol-registry lookup each time. The cache is
|
|
11
|
+
// keyed by the slot symbol itself; the minted value is byte-identical to the
|
|
12
|
+
// uncached Symbol.for result, so identity is preserved across HMR re-evals and
|
|
13
|
+
// the per-package copies of this helper. Key universe is bounded: slots are
|
|
14
|
+
// per-call-site module constants (never minted per render).
|
|
15
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
16
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
|
|
17
|
+
if (slot === undefined) return undefined;
|
|
18
|
+
let byTag = subSlotCache.get(slot);
|
|
19
|
+
if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
|
|
20
|
+
let sym = byTag.get(tag);
|
|
21
|
+
if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
|
|
22
|
+
return sym;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Split the compiler-injected (or .ts-forwarded) trailing slot off a hook's args,
|
|
26
|
+
// returning the user args (everything before it) and the slot.
|
|
27
|
+
export function splitSlot(args: any[]): [any[], symbol | undefined] {
|
|
28
|
+
const tail = args[args.length - 1];
|
|
29
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
30
|
+
return [slot !== undefined ? args.slice(0, -1) : args, slot];
|
|
31
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Code-split a route's component: wrap a dynamic import as a component that suspends
|
|
2
|
+
// (via octane's `use`) until the module loads, then renders it. Carries `.preload()`
|
|
3
|
+
// for hover/intent preloading, and reloads the page once on a stale-chunk
|
|
4
|
+
// module-not-found error (the same recovery react-router does).
|
|
5
|
+
//
|
|
6
|
+
// createRoute({ path: 'item/$id', component: lazyRouteComponent(() => import('./Item')) })
|
|
7
|
+
import { use, createElement } from 'octane';
|
|
8
|
+
import { isModuleNotFoundError } from '@tanstack/router-core';
|
|
9
|
+
|
|
10
|
+
export function lazyRouteComponent(
|
|
11
|
+
importer: () => Promise<any>,
|
|
12
|
+
exportName?: string,
|
|
13
|
+
): ((props: any) => any) & { preload: () => Promise<any> | undefined } {
|
|
14
|
+
let loadPromise: Promise<any> | undefined;
|
|
15
|
+
let comp: any;
|
|
16
|
+
let error: any;
|
|
17
|
+
let reload = false;
|
|
18
|
+
|
|
19
|
+
const load = () => {
|
|
20
|
+
if (!loadPromise) {
|
|
21
|
+
loadPromise = importer()
|
|
22
|
+
.then((res) => {
|
|
23
|
+
loadPromise = undefined;
|
|
24
|
+
comp = res[exportName ?? 'default'];
|
|
25
|
+
})
|
|
26
|
+
.catch((err) => {
|
|
27
|
+
error = err;
|
|
28
|
+
if (
|
|
29
|
+
isModuleNotFoundError(error) &&
|
|
30
|
+
error instanceof Error &&
|
|
31
|
+
typeof window !== 'undefined' &&
|
|
32
|
+
typeof sessionStorage !== 'undefined'
|
|
33
|
+
) {
|
|
34
|
+
const key = `tanstack_router_reload:${error.message}`;
|
|
35
|
+
if (!sessionStorage.getItem(key)) {
|
|
36
|
+
sessionStorage.setItem(key, '1');
|
|
37
|
+
reload = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return loadPromise;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const Lazy = (props: any) => {
|
|
46
|
+
if (reload) {
|
|
47
|
+
window.location.reload();
|
|
48
|
+
throw new Promise(() => {});
|
|
49
|
+
}
|
|
50
|
+
if (error) throw error;
|
|
51
|
+
if (!comp) use(load());
|
|
52
|
+
return createElement(comp, props);
|
|
53
|
+
};
|
|
54
|
+
Lazy.preload = load;
|
|
55
|
+
return Lazy;
|
|
56
|
+
}
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// useLinkProps / createLink / linkOptions — port of react-router's link.tsx
|
|
2
|
+
// (client path; SSR link rendering arrives with the SSR entries). All of Link's
|
|
3
|
+
// behavior lives here so both `<Link>` and custom `createLink` components share
|
|
4
|
+
// it: href building (mask-aware via publicHref), external-URL detection with
|
|
5
|
+
// dangerous-protocol blocking, active-state detection (exactPathTest /
|
|
6
|
+
// trailing-slash-aware fuzzy prefix + deepEqual partial search match),
|
|
7
|
+
// preloading ('intent' with delay + touchstart/focus, 'viewport' via
|
|
8
|
+
// IntersectionObserver, 'render' once on mount), the click handler (navigate
|
|
9
|
+
// with replace/resetScroll/hashScrollIntoView/viewTransition/startTransition/
|
|
10
|
+
// ignoreBlocker forwarded), and data-status/data-transitioning reflection.
|
|
11
|
+
import { useRef, useState, useEffect, useCallback, flushSync, createElement } from 'octane';
|
|
12
|
+
import {
|
|
13
|
+
deepEqual,
|
|
14
|
+
exactPathTest,
|
|
15
|
+
functionalUpdate,
|
|
16
|
+
hasKeys,
|
|
17
|
+
isDangerousProtocol,
|
|
18
|
+
preloadWarning,
|
|
19
|
+
removeTrailingSlash,
|
|
20
|
+
} from '@tanstack/router-core';
|
|
21
|
+
import { useRouter } from './context';
|
|
22
|
+
import { useStore } from './useStore';
|
|
23
|
+
import { splitSlot, subSlot } from './internal';
|
|
24
|
+
import { Link } from './Link.tsrx';
|
|
25
|
+
|
|
26
|
+
const STATIC_EMPTY_OBJECT = {};
|
|
27
|
+
const STATIC_ACTIVE_OBJECT = { class: 'active' };
|
|
28
|
+
const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true };
|
|
29
|
+
const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' };
|
|
30
|
+
const STATIC_TRANSITIONING_PROPS = { 'data-transitioning': 'transitioning' };
|
|
31
|
+
|
|
32
|
+
const timeoutMap = new WeakMap<EventTarget, ReturnType<typeof setTimeout>>();
|
|
33
|
+
|
|
34
|
+
const composeHandlers = (handlers: Array<undefined | ((e: any) => void)>) => (e: Event) => {
|
|
35
|
+
for (const handler of handlers) {
|
|
36
|
+
if (!handler) continue;
|
|
37
|
+
if (e.defaultPrevented) return;
|
|
38
|
+
handler(e);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function getHrefOption(
|
|
43
|
+
publicHref: string,
|
|
44
|
+
external: boolean,
|
|
45
|
+
history: any,
|
|
46
|
+
disabled: boolean | undefined,
|
|
47
|
+
): { href: string; external: boolean } | undefined {
|
|
48
|
+
if (disabled) return undefined;
|
|
49
|
+
// Full URL means a rewrite changed the origin — treat as external-like.
|
|
50
|
+
if (external) return { href: publicHref, external: true };
|
|
51
|
+
return { href: history.createHref(publicHref) || '/', external: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isSafeInternal(to: unknown): boolean {
|
|
55
|
+
if (typeof to !== 'string') return false;
|
|
56
|
+
const zero = to.charCodeAt(0);
|
|
57
|
+
if (zero === 47) return to.charCodeAt(1) !== 47; // '/' but not '//'
|
|
58
|
+
return zero === 46; // '.', '..', './', '../'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isCtrlEvent(e: MouseEvent): boolean {
|
|
62
|
+
return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Merge base/active/inactive styles. Objects merge like upstream; if any is a
|
|
66
|
+
// string the parts join with ';' (octane host styles accept both forms).
|
|
67
|
+
function mergeStyles(base: any, active: any, inactive: any): any {
|
|
68
|
+
if (!base && !active && !inactive) return undefined;
|
|
69
|
+
const parts = [base, active, inactive].filter(Boolean);
|
|
70
|
+
if (parts.length === 1) return parts[0];
|
|
71
|
+
if (parts.some((p) => typeof p === 'string')) {
|
|
72
|
+
return parts
|
|
73
|
+
.map((p) =>
|
|
74
|
+
typeof p === 'string'
|
|
75
|
+
? p.replace(/;\s*$/, '')
|
|
76
|
+
: Object.entries(p)
|
|
77
|
+
.map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}:${v}`)
|
|
78
|
+
.join(';'),
|
|
79
|
+
)
|
|
80
|
+
.join(';');
|
|
81
|
+
}
|
|
82
|
+
return Object.assign({}, ...parts);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useLinkProps(...args: any[]): Record<string, any> {
|
|
86
|
+
const [user, slot] = splitSlot(args);
|
|
87
|
+
const options = (user[0] ?? {}) as Record<string, any>;
|
|
88
|
+
const router = useRouter();
|
|
89
|
+
|
|
90
|
+
const {
|
|
91
|
+
// custom props
|
|
92
|
+
activeProps,
|
|
93
|
+
inactiveProps,
|
|
94
|
+
activeOptions,
|
|
95
|
+
to,
|
|
96
|
+
preload: userPreload,
|
|
97
|
+
preloadDelay: userPreloadDelay,
|
|
98
|
+
preloadIntentProximity: _preloadIntentProximity,
|
|
99
|
+
hashScrollIntoView,
|
|
100
|
+
replace,
|
|
101
|
+
startTransition,
|
|
102
|
+
resetScroll,
|
|
103
|
+
viewTransition,
|
|
104
|
+
// element props
|
|
105
|
+
children: _children,
|
|
106
|
+
target,
|
|
107
|
+
disabled,
|
|
108
|
+
style,
|
|
109
|
+
class: klass,
|
|
110
|
+
className,
|
|
111
|
+
onClick,
|
|
112
|
+
onBlur,
|
|
113
|
+
onFocus,
|
|
114
|
+
onMouseEnter,
|
|
115
|
+
onMouseLeave,
|
|
116
|
+
onTouchStart,
|
|
117
|
+
ignoreBlocker,
|
|
118
|
+
ref: userRef,
|
|
119
|
+
// consumed by buildLocation — not spread onto the element
|
|
120
|
+
params: _params,
|
|
121
|
+
search: _search,
|
|
122
|
+
hash: _hash,
|
|
123
|
+
state: _state,
|
|
124
|
+
mask: _mask,
|
|
125
|
+
reloadDocument: _reloadDocument,
|
|
126
|
+
unsafeRelative: _unsafeRelative,
|
|
127
|
+
from: _from,
|
|
128
|
+
_fromLocation,
|
|
129
|
+
...propsSafeToSpread
|
|
130
|
+
} = options;
|
|
131
|
+
|
|
132
|
+
// Subscribe to the location (by href) — active state re-derives per commit.
|
|
133
|
+
const currentLocation: any = useStore(
|
|
134
|
+
router.stores.location,
|
|
135
|
+
(l: any) => l,
|
|
136
|
+
(prev: any, next: any) => prev.href === next.href,
|
|
137
|
+
subSlot(slot, 'lp:loc'),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const next = router.buildLocation({ _fromLocation: currentLocation, ...options } as any);
|
|
141
|
+
|
|
142
|
+
const hrefOptionPublicHref = next.maskedLocation
|
|
143
|
+
? next.maskedLocation.publicHref
|
|
144
|
+
: next.publicHref;
|
|
145
|
+
const hrefOptionExternal = next.maskedLocation ? next.maskedLocation.external : next.external;
|
|
146
|
+
const hrefOption = getHrefOption(
|
|
147
|
+
hrefOptionPublicHref,
|
|
148
|
+
hrefOptionExternal,
|
|
149
|
+
router.history,
|
|
150
|
+
disabled,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// External URL detection + dangerous-protocol blocking (javascript:, data:…).
|
|
154
|
+
const externalLink = (() => {
|
|
155
|
+
if (hrefOption?.external) {
|
|
156
|
+
if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) return undefined;
|
|
157
|
+
return hrefOption.href;
|
|
158
|
+
}
|
|
159
|
+
if (isSafeInternal(to)) return undefined;
|
|
160
|
+
if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined;
|
|
161
|
+
try {
|
|
162
|
+
new URL(to);
|
|
163
|
+
if (isDangerousProtocol(to, router.protocolAllowlist)) return undefined;
|
|
164
|
+
return to;
|
|
165
|
+
} catch {
|
|
166
|
+
/* not an absolute URL */
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
})();
|
|
170
|
+
|
|
171
|
+
const isActive = (() => {
|
|
172
|
+
if (externalLink) return false;
|
|
173
|
+
if (activeOptions?.exact) {
|
|
174
|
+
if (!exactPathTest(currentLocation.pathname, next.pathname, router.basepath)) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
const currentPathSplit = removeTrailingSlash(currentLocation.pathname, router.basepath);
|
|
179
|
+
const nextPathSplit = removeTrailingSlash(next.pathname, router.basepath);
|
|
180
|
+
const pathIsFuzzyEqual =
|
|
181
|
+
currentPathSplit.startsWith(nextPathSplit) &&
|
|
182
|
+
(currentPathSplit.length === nextPathSplit.length ||
|
|
183
|
+
currentPathSplit[nextPathSplit.length] === '/');
|
|
184
|
+
if (!pathIsFuzzyEqual) return false;
|
|
185
|
+
}
|
|
186
|
+
if (activeOptions?.includeSearch ?? true) {
|
|
187
|
+
const searchTest = deepEqual(currentLocation.search, next.search, {
|
|
188
|
+
partial: !activeOptions?.exact,
|
|
189
|
+
ignoreUndefined: !activeOptions?.explicitUndefined,
|
|
190
|
+
});
|
|
191
|
+
if (!searchTest) return false;
|
|
192
|
+
}
|
|
193
|
+
if (activeOptions?.includeHash) return currentLocation.hash === next.hash;
|
|
194
|
+
return true;
|
|
195
|
+
})();
|
|
196
|
+
|
|
197
|
+
const resolvedActiveProps: Record<string, any> = isActive
|
|
198
|
+
? (functionalUpdate(activeProps, {}) ?? STATIC_ACTIVE_OBJECT)
|
|
199
|
+
: STATIC_EMPTY_OBJECT;
|
|
200
|
+
const resolvedInactiveProps: Record<string, any> = isActive
|
|
201
|
+
? STATIC_EMPTY_OBJECT
|
|
202
|
+
: (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT);
|
|
203
|
+
|
|
204
|
+
// Class composes clsx-style (octane normalizeClass folds arrays + falsy).
|
|
205
|
+
const resolvedClass = [
|
|
206
|
+
klass ?? className,
|
|
207
|
+
resolvedActiveProps.class ?? resolvedActiveProps.className,
|
|
208
|
+
resolvedInactiveProps.class ?? resolvedInactiveProps.className,
|
|
209
|
+
].filter(Boolean);
|
|
210
|
+
const resolvedStyle = mergeStyles(style, resolvedActiveProps.style, resolvedInactiveProps.style);
|
|
211
|
+
|
|
212
|
+
const [isTransitioning, setIsTransitioning] = useState(false, subSlot(slot, 'lp:t'));
|
|
213
|
+
const hasRenderFetched = useRef(false, subSlot(slot, 'lp:rf'));
|
|
214
|
+
const elRef = useRef<Element | null>(null, subSlot(slot, 'lp:el'));
|
|
215
|
+
|
|
216
|
+
const preload =
|
|
217
|
+
options.reloadDocument || externalLink ? false : (userPreload ?? router.options.defaultPreload);
|
|
218
|
+
const preloadDelay = userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0;
|
|
219
|
+
|
|
220
|
+
const doPreload = useCallback(
|
|
221
|
+
() => {
|
|
222
|
+
router.preloadRoute({ ...options, _builtLocation: next } as any).catch((err: unknown) => {
|
|
223
|
+
console.warn(err);
|
|
224
|
+
console.warn(preloadWarning);
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
[router, next.href],
|
|
228
|
+
subSlot(slot, 'lp:dp'),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// preload="viewport": preload when the anchor scrolls into view (100px margin).
|
|
232
|
+
useEffect(
|
|
233
|
+
() => {
|
|
234
|
+
if (disabled || preload !== 'viewport') return;
|
|
235
|
+
const el = elRef.current;
|
|
236
|
+
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
237
|
+
const io = new IntersectionObserver(
|
|
238
|
+
(entries) => {
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
if (entry.isIntersecting) doPreload();
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
{ rootMargin: '100px' },
|
|
244
|
+
);
|
|
245
|
+
io.observe(el);
|
|
246
|
+
return () => io.disconnect();
|
|
247
|
+
},
|
|
248
|
+
[disabled, preload, doPreload],
|
|
249
|
+
subSlot(slot, 'lp:io'),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// preload="render": preload once on mount.
|
|
253
|
+
useEffect(
|
|
254
|
+
() => {
|
|
255
|
+
if (hasRenderFetched.current) return;
|
|
256
|
+
if (!disabled && preload === 'render') {
|
|
257
|
+
doPreload();
|
|
258
|
+
hasRenderFetched.current = true;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
[disabled, doPreload, preload],
|
|
262
|
+
subSlot(slot, 'lp:pr'),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const handleClick = (e: MouseEvent) => {
|
|
266
|
+
const elementTarget = (e.currentTarget as Element | null)?.getAttribute?.('target');
|
|
267
|
+
const effectiveTarget = target !== undefined ? target : elementTarget;
|
|
268
|
+
if (
|
|
269
|
+
!disabled &&
|
|
270
|
+
!isCtrlEvent(e) &&
|
|
271
|
+
!e.defaultPrevented &&
|
|
272
|
+
(!effectiveTarget || effectiveTarget === '_self') &&
|
|
273
|
+
e.button === 0
|
|
274
|
+
) {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
|
|
277
|
+
flushSync(() => {
|
|
278
|
+
setIsTransitioning(true);
|
|
279
|
+
});
|
|
280
|
+
const unsub = router.subscribe('onResolved', () => {
|
|
281
|
+
unsub();
|
|
282
|
+
setIsTransitioning(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
router.navigate({
|
|
286
|
+
...options,
|
|
287
|
+
replace,
|
|
288
|
+
resetScroll,
|
|
289
|
+
hashScrollIntoView,
|
|
290
|
+
startTransition,
|
|
291
|
+
viewTransition,
|
|
292
|
+
ignoreBlocker,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const captureRef = (el: Element | null) => {
|
|
298
|
+
elRef.current = el;
|
|
299
|
+
};
|
|
300
|
+
const composedRef = userRef ? [captureRef, userRef] : captureRef;
|
|
301
|
+
|
|
302
|
+
if (externalLink) {
|
|
303
|
+
return {
|
|
304
|
+
...propsSafeToSpread,
|
|
305
|
+
ref: composedRef,
|
|
306
|
+
href: externalLink,
|
|
307
|
+
...(target !== undefined && { target }),
|
|
308
|
+
...(disabled !== undefined && { disabled }),
|
|
309
|
+
...(resolvedStyle !== undefined && { style: resolvedStyle }),
|
|
310
|
+
...(resolvedClass.length > 0 && { class: resolvedClass }),
|
|
311
|
+
...(onClick && { onClick }),
|
|
312
|
+
...(onBlur && { onBlur }),
|
|
313
|
+
...(onFocus && { onFocus }),
|
|
314
|
+
...(onMouseEnter && { onMouseEnter }),
|
|
315
|
+
...(onMouseLeave && { onMouseLeave }),
|
|
316
|
+
...(onTouchStart && { onTouchStart }),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => {
|
|
321
|
+
if (disabled || preload !== 'intent') return;
|
|
322
|
+
if (!preloadDelay) {
|
|
323
|
+
doPreload();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const eventTarget = e.currentTarget as EventTarget;
|
|
327
|
+
if (timeoutMap.has(eventTarget)) return;
|
|
328
|
+
const id = setTimeout(() => {
|
|
329
|
+
timeoutMap.delete(eventTarget);
|
|
330
|
+
doPreload();
|
|
331
|
+
}, preloadDelay);
|
|
332
|
+
timeoutMap.set(eventTarget, id);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const handleTouchStart = () => {
|
|
336
|
+
if (disabled || preload !== 'intent') return;
|
|
337
|
+
doPreload();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const handleLeave = (e: MouseEvent | FocusEvent) => {
|
|
341
|
+
if (disabled || !preload || !preloadDelay) return;
|
|
342
|
+
const eventTarget = e.currentTarget as EventTarget;
|
|
343
|
+
const id = timeoutMap.get(eventTarget);
|
|
344
|
+
if (id) {
|
|
345
|
+
clearTimeout(id);
|
|
346
|
+
timeoutMap.delete(eventTarget);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
...propsSafeToSpread,
|
|
352
|
+
...resolvedActiveProps,
|
|
353
|
+
...resolvedInactiveProps,
|
|
354
|
+
href: hrefOption?.href,
|
|
355
|
+
ref: composedRef,
|
|
356
|
+
onClick: composeHandlers([onClick, handleClick]),
|
|
357
|
+
onBlur: composeHandlers([onBlur, handleLeave]),
|
|
358
|
+
onFocus: composeHandlers([onFocus, enqueueIntentPreload]),
|
|
359
|
+
onMouseEnter: composeHandlers([onMouseEnter, enqueueIntentPreload]),
|
|
360
|
+
onMouseLeave: composeHandlers([onMouseLeave, handleLeave]),
|
|
361
|
+
onTouchStart: composeHandlers([onTouchStart, handleTouchStart]),
|
|
362
|
+
disabled: !!disabled,
|
|
363
|
+
...(target !== undefined && { target }),
|
|
364
|
+
...(resolvedStyle !== undefined && { style: resolvedStyle }),
|
|
365
|
+
...(resolvedClass.length > 0 && { class: resolvedClass }),
|
|
366
|
+
...(disabled && STATIC_DISABLED_PROPS),
|
|
367
|
+
...(isActive && STATIC_ACTIVE_PROPS),
|
|
368
|
+
...(isTransitioning && STATIC_TRANSITIONING_PROPS),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Wrap a design-system component so it navigates like <Link> — the component
|
|
373
|
+
// receives the fully-built link props (href, handlers, data-status, …).
|
|
374
|
+
export function createLink(Comp: any): any {
|
|
375
|
+
return function CreatedLink(props: any) {
|
|
376
|
+
return createElement(Link as any, { ...props, _asChild: Comp });
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Identity helper for pre-validating navigation options (type-level upstream).
|
|
381
|
+
export function linkOptions(options: any): any {
|
|
382
|
+
return options;
|
|
383
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// CatchNotFound — a CatchBoundary specialized to `notFound()` errors (port of
|
|
2
|
+
// react-router's not-found.tsx). Anything that isn't a NotFoundError is rethrown
|
|
3
|
+
// (from onCatch during the catch render) so it reaches the next error boundary
|
|
4
|
+
// up; the reset key is `not-found-${pathname}-${status}` so navigating away (or a
|
|
5
|
+
// new load settling) clears the not-found UI.
|
|
6
|
+
import { isNotFound } from '@tanstack/router-core';
|
|
7
|
+
import { useRouter } from './context.ts';
|
|
8
|
+
import { useStore } from './useStore.ts';
|
|
9
|
+
import { CatchBoundary } from './CatchBoundary.tsrx';
|
|
10
|
+
|
|
11
|
+
function NotFoundFallback(props) {
|
|
12
|
+
if (isNotFound(props.error)) {
|
|
13
|
+
return props.render ? props.render(props.error) : null;
|
|
14
|
+
}
|
|
15
|
+
throw props.error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CatchNotFound(props) @{
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const pathname = useStore(router.stores.location, (l) => l.pathname);
|
|
21
|
+
const status = useStore(router.stores.status, (s) => s);
|
|
22
|
+
const resetKey = `not-found-${pathname}-${status}`;
|
|
23
|
+
|
|
24
|
+
<CatchBoundary
|
|
25
|
+
getResetKey={() => resetKey}
|
|
26
|
+
onCatch={(error, errorInfo) => {
|
|
27
|
+
if (isNotFound(error)) {
|
|
28
|
+
if (props.onCatch) props.onCatch(error, errorInfo);
|
|
29
|
+
} else {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}}
|
|
33
|
+
errorComponent={(p) => NotFoundFallback({ error: p.error, render: props.fallback })}
|
|
34
|
+
>{props.children}</CatchBoundary>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function DefaultGlobalNotFound() @{
|
|
38
|
+
<p>Not Found</p>
|
|
39
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Type declaration for the .tsrx components (resolved by relative path).
|
|
2
|
+
export declare const CatchNotFound: (props: {
|
|
3
|
+
fallback?: (error: any) => unknown;
|
|
4
|
+
onCatch?: (error: any, errorInfo: { componentStack: string }) => void;
|
|
5
|
+
children?: unknown;
|
|
6
|
+
}) => unknown;
|
|
7
|
+
export declare const DefaultGlobalNotFound: () => unknown;
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Route factories + the route-bound hook accessors. `@tanstack/router-core`
|
|
2
|
+
// ships the `BaseRoute`/`BaseRootRoute`/`BaseRouteApi` classes but NOT the
|
|
3
|
+
// ergonomic factories or the React-side hook accessors (`Route.useLoaderData()`,
|
|
4
|
+
// `getRouteApi(id).useParams()`, …) — those live in react-router's route.tsx and
|
|
5
|
+
// are ported here 1:1 on octane's hooks. The accessors are this-bound instance
|
|
6
|
+
// closures (upstream uses arrow-function class fields) and follow the binding's
|
|
7
|
+
// slot convention: the octane compiler wraps method-style `use*()` calls in
|
|
8
|
+
// `withSlot` and passes the call-site symbol as the trailing argument, which
|
|
9
|
+
// each accessor forwards to the hooks it composes.
|
|
10
|
+
import { BaseRoute, BaseRootRoute, BaseRouteApi, notFound } from '@tanstack/router-core';
|
|
11
|
+
import { createElement } from 'octane';
|
|
12
|
+
import {
|
|
13
|
+
useMatch,
|
|
14
|
+
useParams,
|
|
15
|
+
useSearch,
|
|
16
|
+
useLoaderData,
|
|
17
|
+
useLoaderDeps,
|
|
18
|
+
useRouteContext,
|
|
19
|
+
useNavigate,
|
|
20
|
+
} from './hooks';
|
|
21
|
+
import { useRouter } from './context';
|
|
22
|
+
import { splitSlot, subSlot } from './internal';
|
|
23
|
+
import { Link } from './Link.tsrx';
|
|
24
|
+
|
|
25
|
+
// Attach the hook accessors to a route-shaped instance (Route / RootRoute /
|
|
26
|
+
// RouteApi). `strictLoaderHooks: false` is RouteApi's mode — its loader hooks
|
|
27
|
+
// pass `strict: false` upstream (the api may be read from ancestor layouts).
|
|
28
|
+
function attachRouteHooks(self: any, strictLoaderHooks: boolean): void {
|
|
29
|
+
self.useMatch = (...args: any[]) => {
|
|
30
|
+
const [user, slot] = splitSlot(args);
|
|
31
|
+
const opts = user[0] ?? {};
|
|
32
|
+
return useMatch(
|
|
33
|
+
{ select: opts.select, structuralSharing: opts.structuralSharing, from: self.id },
|
|
34
|
+
subSlot(slot, 'r:m'),
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
self.useRouteContext = (...args: any[]) => {
|
|
38
|
+
const [user, slot] = splitSlot(args);
|
|
39
|
+
const opts = user[0] ?? {};
|
|
40
|
+
return useRouteContext({ ...opts, from: self.id }, subSlot(slot, 'r:ctx'));
|
|
41
|
+
};
|
|
42
|
+
self.useSearch = (...args: any[]) => {
|
|
43
|
+
const [user, slot] = splitSlot(args);
|
|
44
|
+
const opts = user[0] ?? {};
|
|
45
|
+
return useSearch(
|
|
46
|
+
{ select: opts.select, structuralSharing: opts.structuralSharing, from: self.id },
|
|
47
|
+
subSlot(slot, 'r:s'),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
self.useParams = (...args: any[]) => {
|
|
51
|
+
const [user, slot] = splitSlot(args);
|
|
52
|
+
const opts = user[0] ?? {};
|
|
53
|
+
return useParams(
|
|
54
|
+
{ select: opts.select, structuralSharing: opts.structuralSharing, from: self.id },
|
|
55
|
+
subSlot(slot, 'r:p'),
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
self.useLoaderDeps = (...args: any[]) => {
|
|
59
|
+
const [user, slot] = splitSlot(args);
|
|
60
|
+
const opts = user[0] ?? {};
|
|
61
|
+
return useLoaderDeps(
|
|
62
|
+
strictLoaderHooks ? { ...opts, from: self.id } : { ...opts, from: self.id, strict: false },
|
|
63
|
+
subSlot(slot, 'r:d'),
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
self.useLoaderData = (...args: any[]) => {
|
|
67
|
+
const [user, slot] = splitSlot(args);
|
|
68
|
+
const opts = user[0] ?? {};
|
|
69
|
+
return useLoaderData(
|
|
70
|
+
strictLoaderHooks ? { ...opts, from: self.id } : { ...opts, from: self.id, strict: false },
|
|
71
|
+
subSlot(slot, 'r:l'),
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class Route extends (BaseRoute as any) {
|
|
77
|
+
constructor(options?: any) {
|
|
78
|
+
super(options);
|
|
79
|
+
attachRouteHooks(this, true);
|
|
80
|
+
(this as any).useNavigate = (...args: any[]) => {
|
|
81
|
+
const [, slot] = splitSlot(args);
|
|
82
|
+
return useNavigate({ from: (this as any).fullPath }, subSlot(slot, 'r:n'));
|
|
83
|
+
};
|
|
84
|
+
(this as any).Link = (props: any) =>
|
|
85
|
+
createElement(Link as any, { from: (this as any).fullPath, ...props });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class RootRoute extends (BaseRootRoute as any) {
|
|
90
|
+
constructor(options?: any) {
|
|
91
|
+
super(options);
|
|
92
|
+
attachRouteHooks(this, true);
|
|
93
|
+
(this as any).useNavigate = (...args: any[]) => {
|
|
94
|
+
const [, slot] = splitSlot(args);
|
|
95
|
+
return useNavigate({ from: (this as any).fullPath }, subSlot(slot, 'r:n'));
|
|
96
|
+
};
|
|
97
|
+
(this as any).Link = (props: any) =>
|
|
98
|
+
createElement(Link as any, { from: (this as any).fullPath, ...props });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createRoute(options: any): any {
|
|
103
|
+
return new (Route as any)(options);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createRootRoute(options?: any): any {
|
|
107
|
+
return new (RootRoute as any)(options);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// `createRootRouteWithContext<TContext>()` is a curried type-only helper that just
|
|
111
|
+
// returns `createRootRoute` — the context is a compile-time concern.
|
|
112
|
+
export function createRootRouteWithContext<_TContext>() {
|
|
113
|
+
return (options?: any) => createRootRoute(options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// getRouteApi — route-bound hooks without importing the route (avoids circular
|
|
117
|
+
// imports in code-split files). Port of react-router's RouteApi class.
|
|
118
|
+
export class RouteApi extends (BaseRouteApi as any) {
|
|
119
|
+
/** @deprecated Use the `getRouteApi` function instead. */
|
|
120
|
+
constructor({ id }: { id: any }) {
|
|
121
|
+
super({ id });
|
|
122
|
+
attachRouteHooks(this, false);
|
|
123
|
+
(this as any).useNavigate = (...args: any[]) => {
|
|
124
|
+
const [, slot] = splitSlot(args);
|
|
125
|
+
const router = useRouter();
|
|
126
|
+
return useNavigate(
|
|
127
|
+
{ from: router.routesById[(this as any).id].fullPath },
|
|
128
|
+
subSlot(slot, 'r:n'),
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
(this as any).notFound = (opts?: any) => notFound({ routeId: (this as any).id, ...opts });
|
|
132
|
+
(this as any).Link = (props: any) => {
|
|
133
|
+
const router = useRouter();
|
|
134
|
+
const fullPath = router.routesById[(this as any).id].fullPath;
|
|
135
|
+
return createElement(Link as any, { from: fullPath, ...props });
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getRouteApi(id: any): any {
|
|
141
|
+
return new (RouteApi as any)({ id });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Identity at runtime (the shape is a RouteMask; types constrain it upstream).
|
|
145
|
+
export function createRouteMask(opts: any): any {
|
|
146
|
+
return opts;
|
|
147
|
+
}
|