@octanejs/tanstack-router 0.2.7 → 0.2.9

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/src/link.ts CHANGED
@@ -1,387 +1,13 @@
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';
1
+ import { createElement } from 'octane';
12
2
  import type { ComponentBody } from 'octane';
13
- import {
14
- deepEqual,
15
- exactPathTest,
16
- functionalUpdate,
17
- isDangerousProtocol,
18
- preloadWarning,
19
- removeTrailingSlash,
20
- } from '@tanstack/router-core';
3
+ import type { AnyRouter, RegisteredRouter } from '@tanstack/router-core';
21
4
  import { useRouter } from './context';
22
- import { useStore } from './useStore';
23
- import { splitSlot, subSlot } from './internal';
24
5
  import { Link } from './Link.tsrx';
25
- import type { AnyRouter, RegisteredRouter } from '@tanstack/router-core';
26
- import type { LinkComponent, OctaneAnchorProps, UseLinkPropsOptions } from './linkTypes';
6
+ import { createUseLinkProps } from './linkBinding';
7
+ import type { LinkComponent } from './linkTypes';
27
8
  import type { ValidateLinkOptions, ValidateLinkOptionsArray } from './typePrimitives';
28
9
 
29
- const STATIC_EMPTY_OBJECT = {};
30
- const STATIC_ACTIVE_OBJECT = { class: 'active' };
31
- const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true };
32
- const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' };
33
- const STATIC_TRANSITIONING_PROPS = { 'data-transitioning': 'transitioning' };
34
-
35
- const timeoutMap = new WeakMap<EventTarget, ReturnType<typeof setTimeout>>();
36
-
37
- const composeHandlers = (handlers: Array<undefined | ((e: any) => void)>) => (e: Event) => {
38
- for (const handler of handlers) {
39
- if (!handler) continue;
40
- if (e.defaultPrevented) return;
41
- handler(e);
42
- }
43
- };
44
-
45
- function getHrefOption(
46
- publicHref: string,
47
- external: boolean,
48
- history: any,
49
- disabled: boolean | undefined,
50
- ): { href: string; external: boolean } | undefined {
51
- if (disabled) return undefined;
52
- // Full URL means a rewrite changed the origin — treat as external-like.
53
- if (external) return { href: publicHref, external: true };
54
- return { href: history.createHref(publicHref) || '/', external: false };
55
- }
56
-
57
- function isSafeInternal(to: unknown): boolean {
58
- if (typeof to !== 'string') return false;
59
- const zero = to.charCodeAt(0);
60
- if (zero === 47) return to.charCodeAt(1) !== 47; // '/' but not '//'
61
- return zero === 46; // '.', '..', './', '../'
62
- }
63
-
64
- function isCtrlEvent(e: MouseEvent): boolean {
65
- return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
66
- }
67
-
68
- // Merge base/active/inactive styles. Objects merge like upstream; if any is a
69
- // string the parts join with ';' (octane host styles accept both forms).
70
- function mergeStyles(base: any, active: any, inactive: any): any {
71
- if (!base && !active && !inactive) return undefined;
72
- const parts = [base, active, inactive].filter(Boolean);
73
- if (parts.length === 1) return parts[0];
74
- if (parts.some((p) => typeof p === 'string')) {
75
- return parts
76
- .map((p) =>
77
- typeof p === 'string'
78
- ? p.replace(/;\s*$/, '')
79
- : Object.entries(p)
80
- .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}:${v}`)
81
- .join(';'),
82
- )
83
- .join(';');
84
- }
85
- return Object.assign({}, ...parts);
86
- }
87
-
88
- export function useLinkProps<
89
- TRouter extends AnyRouter = RegisteredRouter,
90
- const TFrom extends string = string,
91
- const TTo extends string | undefined = undefined,
92
- const TMaskFrom extends string = TFrom,
93
- const TMaskTo extends string = '',
94
- >(options: UseLinkPropsOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>): OctaneAnchorProps;
95
- export function useLinkProps(...args: any[]): Record<string, any> {
96
- const [user, slot] = splitSlot(args);
97
- const options = (user[0] ?? {}) as Record<string, any>;
98
- const router = useRouter();
99
-
100
- const {
101
- // custom props
102
- activeProps,
103
- inactiveProps,
104
- activeOptions,
105
- to,
106
- preload: userPreload,
107
- preloadDelay: userPreloadDelay,
108
- preloadIntentProximity: _preloadIntentProximity,
109
- hashScrollIntoView,
110
- replace,
111
- startTransition,
112
- resetScroll,
113
- viewTransition,
114
- // element props
115
- children: _children,
116
- target,
117
- disabled,
118
- style,
119
- class: klass,
120
- className,
121
- onClick,
122
- onBlur,
123
- onFocus,
124
- onMouseEnter,
125
- onMouseLeave,
126
- onTouchStart,
127
- ignoreBlocker,
128
- ref: userRef,
129
- // consumed by buildLocation — not spread onto the element
130
- params: _params,
131
- search: _search,
132
- hash: _hash,
133
- state: _state,
134
- mask: _mask,
135
- reloadDocument: _reloadDocument,
136
- unsafeRelative: _unsafeRelative,
137
- from: _from,
138
- _fromLocation,
139
- ...propsSafeToSpread
140
- } = options;
141
-
142
- // Subscribe to the location (by href) — active state re-derives per commit.
143
- const currentLocation: any = useStore(
144
- router.stores.location,
145
- (l: any) => l,
146
- (prev: any, next: any) => prev.href === next.href,
147
- subSlot(slot, 'lp:loc'),
148
- );
149
-
150
- const next = router.buildLocation({ _fromLocation: currentLocation, ...options } as any);
151
-
152
- const hrefOptionPublicHref = next.maskedLocation
153
- ? next.maskedLocation.publicHref
154
- : next.publicHref;
155
- const hrefOptionExternal = next.maskedLocation ? next.maskedLocation.external : next.external;
156
- const hrefOption = getHrefOption(
157
- hrefOptionPublicHref,
158
- hrefOptionExternal,
159
- router.history,
160
- disabled,
161
- );
162
-
163
- // External URL detection + dangerous-protocol blocking (javascript:, data:…).
164
- const externalLink = (() => {
165
- if (hrefOption?.external) {
166
- if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) return undefined;
167
- return hrefOption.href;
168
- }
169
- if (isSafeInternal(to)) return undefined;
170
- if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined;
171
- try {
172
- new URL(to);
173
- if (isDangerousProtocol(to, router.protocolAllowlist)) return undefined;
174
- return to;
175
- } catch {
176
- /* not an absolute URL */
177
- }
178
- return undefined;
179
- })();
180
-
181
- const isActive = (() => {
182
- if (externalLink) return false;
183
- if (activeOptions?.exact) {
184
- if (!exactPathTest(currentLocation.pathname, next.pathname, router.basepath)) {
185
- return false;
186
- }
187
- } else {
188
- const currentPathSplit = removeTrailingSlash(currentLocation.pathname, router.basepath);
189
- const nextPathSplit = removeTrailingSlash(next.pathname, router.basepath);
190
- const pathIsFuzzyEqual =
191
- currentPathSplit.startsWith(nextPathSplit) &&
192
- (currentPathSplit.length === nextPathSplit.length ||
193
- currentPathSplit[nextPathSplit.length] === '/');
194
- if (!pathIsFuzzyEqual) return false;
195
- }
196
- if (activeOptions?.includeSearch ?? true) {
197
- // router-core 1.171.34 deepEqual: positional (partial, explicitUndefined)
198
- // instead of an options object; the 4th arg is explicitUndefined directly.
199
- const searchTest = deepEqual(
200
- currentLocation.search,
201
- next.search,
202
- !activeOptions?.exact,
203
- activeOptions?.explicitUndefined,
204
- );
205
- if (!searchTest) return false;
206
- }
207
- if (activeOptions?.includeHash) return currentLocation.hash === next.hash;
208
- return true;
209
- })();
210
-
211
- const resolvedActiveProps: Record<string, any> = isActive
212
- ? (functionalUpdate(activeProps, {}) ?? STATIC_ACTIVE_OBJECT)
213
- : STATIC_EMPTY_OBJECT;
214
- const resolvedInactiveProps: Record<string, any> = isActive
215
- ? STATIC_EMPTY_OBJECT
216
- : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT);
217
-
218
- // Class composes clsx-style (octane normalizeClass folds arrays + falsy).
219
- const resolvedClass = [
220
- klass ?? className,
221
- resolvedActiveProps.class ?? resolvedActiveProps.className,
222
- resolvedInactiveProps.class ?? resolvedInactiveProps.className,
223
- ].filter(Boolean);
224
- const resolvedStyle = mergeStyles(style, resolvedActiveProps.style, resolvedInactiveProps.style);
225
-
226
- const [isTransitioning, setIsTransitioning] = useState(false, subSlot(slot, 'lp:t'));
227
- const hasRenderFetched = useRef(false, subSlot(slot, 'lp:rf'));
228
- const elRef = useRef<Element | null>(null, subSlot(slot, 'lp:el'));
229
-
230
- const preload =
231
- options.reloadDocument || externalLink ? false : (userPreload ?? router.options.defaultPreload);
232
- const preloadDelay = userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0;
233
-
234
- const doPreload = useCallback(
235
- () => {
236
- router.preloadRoute({ ...options, _builtLocation: next } as any).catch((err: unknown) => {
237
- console.warn(err);
238
- console.warn(preloadWarning);
239
- });
240
- },
241
- [router, next.href],
242
- subSlot(slot, 'lp:dp'),
243
- );
244
-
245
- // preload="viewport": preload when the anchor scrolls into view (100px margin).
246
- useEffect(
247
- () => {
248
- if (disabled || preload !== 'viewport') return;
249
- const el = elRef.current;
250
- if (!el || typeof IntersectionObserver === 'undefined') return;
251
- const io = new IntersectionObserver(
252
- (entries) => {
253
- for (const entry of entries) {
254
- if (entry.isIntersecting) doPreload();
255
- }
256
- },
257
- { rootMargin: '100px' },
258
- );
259
- io.observe(el);
260
- return () => io.disconnect();
261
- },
262
- [disabled, preload, doPreload],
263
- subSlot(slot, 'lp:io'),
264
- );
265
-
266
- // preload="render": preload once on mount.
267
- useEffect(
268
- () => {
269
- if (hasRenderFetched.current) return;
270
- if (!disabled && preload === 'render') {
271
- doPreload();
272
- hasRenderFetched.current = true;
273
- }
274
- },
275
- [disabled, doPreload, preload],
276
- subSlot(slot, 'lp:pr'),
277
- );
278
-
279
- const handleClick = (e: MouseEvent) => {
280
- const elementTarget = (e.currentTarget as Element | null)?.getAttribute?.('target');
281
- const effectiveTarget = target !== undefined ? target : elementTarget;
282
- if (
283
- !disabled &&
284
- !isCtrlEvent(e) &&
285
- !e.defaultPrevented &&
286
- (!effectiveTarget || effectiveTarget === '_self') &&
287
- e.button === 0
288
- ) {
289
- e.preventDefault();
290
-
291
- flushSync(() => {
292
- setIsTransitioning(true);
293
- });
294
- const unsub = router.subscribe('onResolved', () => {
295
- unsub();
296
- setIsTransitioning(false);
297
- });
298
-
299
- router.navigate({
300
- ...options,
301
- replace,
302
- resetScroll,
303
- hashScrollIntoView,
304
- startTransition,
305
- viewTransition,
306
- ignoreBlocker,
307
- });
308
- }
309
- };
310
-
311
- const captureRef = (el: Element | null) => {
312
- elRef.current = el;
313
- };
314
- const composedRef = userRef ? [captureRef, userRef] : captureRef;
315
-
316
- if (externalLink) {
317
- return {
318
- ...propsSafeToSpread,
319
- ref: composedRef,
320
- href: externalLink,
321
- ...(target !== undefined && { target }),
322
- ...(disabled !== undefined && { disabled }),
323
- ...(resolvedStyle !== undefined && { style: resolvedStyle }),
324
- ...(resolvedClass.length > 0 && { class: resolvedClass }),
325
- ...(onClick && { onClick }),
326
- ...(onBlur && { onBlur }),
327
- ...(onFocus && { onFocus }),
328
- ...(onMouseEnter && { onMouseEnter }),
329
- ...(onMouseLeave && { onMouseLeave }),
330
- ...(onTouchStart && { onTouchStart }),
331
- };
332
- }
333
-
334
- const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => {
335
- if (disabled || preload !== 'intent') return;
336
- if (!preloadDelay) {
337
- doPreload();
338
- return;
339
- }
340
- const eventTarget = e.currentTarget as EventTarget;
341
- if (timeoutMap.has(eventTarget)) return;
342
- const id = setTimeout(() => {
343
- timeoutMap.delete(eventTarget);
344
- doPreload();
345
- }, preloadDelay);
346
- timeoutMap.set(eventTarget, id);
347
- };
348
-
349
- const handleTouchStart = () => {
350
- if (disabled || preload !== 'intent') return;
351
- doPreload();
352
- };
353
-
354
- const handleLeave = (e: MouseEvent | FocusEvent) => {
355
- if (disabled || !preload || !preloadDelay) return;
356
- const eventTarget = e.currentTarget as EventTarget;
357
- const id = timeoutMap.get(eventTarget);
358
- if (id) {
359
- clearTimeout(id);
360
- timeoutMap.delete(eventTarget);
361
- }
362
- };
363
-
364
- return {
365
- ...propsSafeToSpread,
366
- ...resolvedActiveProps,
367
- ...resolvedInactiveProps,
368
- href: hrefOption?.href,
369
- ref: composedRef,
370
- onClick: composeHandlers([onClick, handleClick]),
371
- onBlur: composeHandlers([onBlur, handleLeave]),
372
- onFocus: composeHandlers([onFocus, enqueueIntentPreload]),
373
- onMouseEnter: composeHandlers([onMouseEnter, enqueueIntentPreload]),
374
- onMouseLeave: composeHandlers([onMouseLeave, handleLeave]),
375
- onTouchStart: composeHandlers([onTouchStart, handleTouchStart]),
376
- disabled: !!disabled,
377
- ...(target !== undefined && { target }),
378
- ...(resolvedStyle !== undefined && { style: resolvedStyle }),
379
- ...(resolvedClass.length > 0 && { class: resolvedClass }),
380
- ...(disabled && STATIC_DISABLED_PROPS),
381
- ...(isActive && STATIC_ACTIVE_PROPS),
382
- ...(isTransitioning && STATIC_TRANSITIONING_PROPS),
383
- };
384
- }
10
+ export const useLinkProps = createUseLinkProps(useRouter);
385
11
 
386
12
  // Wrap a design-system component so it navigates like <Link> — the component
387
13
  // receives the fully-built link props (href, handlers, data-status, …).