@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/router.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `createRouter` — the only piece of `RouterCore` setup that's framework-specific.
|
|
2
|
+
// `RouterCore`'s constructor takes `(options, getStoreFactory)`; react-router
|
|
3
|
+
// passes a factory that, on the client, builds REACTIVE atoms (`createAtom`/`batch`
|
|
4
|
+
// from `@tanstack/store`) and, on the server, non-reactive snapshot stores. Those
|
|
5
|
+
// atoms are framework-agnostic — `createAtom` lives in `@tanstack/store`, not in
|
|
6
|
+
// `@tanstack/react-store` — so octane reuses the exact same factory. The reactive
|
|
7
|
+
// atoms expose `.subscribe(cb) → { unsubscribe }` + `.get()`, which `useStore` binds
|
|
8
|
+
// to octane's `useSyncExternalStore`.
|
|
9
|
+
import {
|
|
10
|
+
RouterCore,
|
|
11
|
+
createNonReactiveMutableStore,
|
|
12
|
+
createNonReactiveReadonlyStore,
|
|
13
|
+
} from '@tanstack/router-core';
|
|
14
|
+
import { createAtom, batch } from '@tanstack/store';
|
|
15
|
+
import { startTransition } from 'octane';
|
|
16
|
+
|
|
17
|
+
const isServerEnv = typeof document === 'undefined';
|
|
18
|
+
|
|
19
|
+
// Every reactive router commit funnels through the store factory's `batch`:
|
|
20
|
+
// `beforeLoad` (location + pending matches), the resolved `onReady` commit
|
|
21
|
+
// (active matches + search), `setMatches`/`setPending`/`setCached`, and
|
|
22
|
+
// `invalidate`. Wrap it in octane's `startTransition` so the resulting
|
|
23
|
+
// `__store`/per-match notifications schedule TRANSITION-priority renders.
|
|
24
|
+
//
|
|
25
|
+
// Why this is needed: router-core already wraps navigation in
|
|
26
|
+
// `router.startTransition` (Transitioner.tsrx), but the RESOLVED commit is run
|
|
27
|
+
// inside `startViewTransition(fn)`, and a real browser defers `fn` to a later
|
|
28
|
+
// task. By then octane's transition window (TRANSITION_DEPTH / the async
|
|
29
|
+
// `startTransition` window) has closed, so the commit would schedule an URGENT
|
|
30
|
+
// re-render. For a same-route `?page` change that urgent re-render lands on a
|
|
31
|
+
// suspending descendant (the route component reading `useSearch`), and an urgent
|
|
32
|
+
// suspend does NOT hold — the route's pending fallback flashes. Re-asserting the
|
|
33
|
+
// transition AT COMMIT TIME makes that re-render ride the concurrent-navigation
|
|
34
|
+
// transition, so octane keeps the current page on screen until the new one is
|
|
35
|
+
// ready (matching cross-route navigation). `startTransition` is re-entrant, so
|
|
36
|
+
// nesting (router-core's own startTransition → this batch) is a no-op extra
|
|
37
|
+
// level; non-navigation router commits ride a transition too, which is harmless
|
|
38
|
+
// (router stores are only ever mutated by router internals, never by urgent user
|
|
39
|
+
// state). SSR uses the non-reactive factory below and is unaffected.
|
|
40
|
+
const octaneStoreFactory = (opts: { isServer?: boolean }) => {
|
|
41
|
+
if (opts?.isServer ?? isServerEnv) {
|
|
42
|
+
return {
|
|
43
|
+
createMutableStore: createNonReactiveMutableStore,
|
|
44
|
+
createReadonlyStore: createNonReactiveReadonlyStore,
|
|
45
|
+
batch: (fn: () => void) => fn(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
createMutableStore: createAtom,
|
|
50
|
+
createReadonlyStore: createAtom,
|
|
51
|
+
batch: (fn: () => void) => startTransition(() => batch(fn)),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class Router extends (RouterCore as any) {
|
|
56
|
+
constructor(options: any) {
|
|
57
|
+
super(options, octaneStoreFactory);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createRouter(options: any): any {
|
|
62
|
+
return new Router(options);
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Suspend until a deferred promise resolves and return its data. react-router uses
|
|
2
|
+
// `React.use(promise)` when available; octane has the same Suspense-shaped `use`, so
|
|
3
|
+
// this is a one-liner. The promise is typically produced by router-core's `defer()`
|
|
4
|
+
// in a loader and streamed to the client.
|
|
5
|
+
import { use } from 'octane';
|
|
6
|
+
|
|
7
|
+
export function useAwaited(opts: { promise: any }): any {
|
|
8
|
+
return use(opts.promise);
|
|
9
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// useBlocker / Block — port of react-router's useBlocker.tsx. Registers a
|
|
2
|
+
// history blocker (`history.block`) whose composed blockerFn resolves the
|
|
3
|
+
// current/next locations to matched-route shapes, asks `shouldBlockFn`, and —
|
|
4
|
+
// with `withResolver` — parks the navigation in a promise the caller settles
|
|
5
|
+
// via the returned resolver's `proceed()` / `reset()`. Also supports the legacy
|
|
6
|
+
// `(blockerFn, condition)` and `{ blockerFn, condition }` signatures. Authored
|
|
7
|
+
// in .tsrx so the compiler slots the internal useState/useEffect and callers'
|
|
8
|
+
// withSlot wrapping keeps per-call-site state independent.
|
|
9
|
+
import { useState, useEffect, isChildrenBlock } from 'octane';
|
|
10
|
+
import { useRouter } from './context.ts';
|
|
11
|
+
|
|
12
|
+
const IDLE_RESOLVER = {
|
|
13
|
+
status: 'idle',
|
|
14
|
+
current: undefined,
|
|
15
|
+
next: undefined,
|
|
16
|
+
action: undefined,
|
|
17
|
+
proceed: undefined,
|
|
18
|
+
reset: undefined,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function _resolveBlockerOpts(opts, condition) {
|
|
22
|
+
if (opts === undefined) {
|
|
23
|
+
return { shouldBlockFn: () => true, withResolver: false };
|
|
24
|
+
}
|
|
25
|
+
if ('shouldBlockFn' in opts) return opts;
|
|
26
|
+
if (typeof opts === 'function') {
|
|
27
|
+
const shouldBlock = Boolean(condition ?? true);
|
|
28
|
+
const _customBlockerFn = async () => {
|
|
29
|
+
if (shouldBlock) return await opts();
|
|
30
|
+
return false;
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
shouldBlockFn: _customBlockerFn,
|
|
34
|
+
enableBeforeUnload: shouldBlock,
|
|
35
|
+
withResolver: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const shouldBlock = Boolean(opts.condition ?? true);
|
|
39
|
+
const fn = opts.blockerFn;
|
|
40
|
+
const _customBlockerFn = async () => {
|
|
41
|
+
if (shouldBlock && fn !== undefined) return await fn();
|
|
42
|
+
return shouldBlock;
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
shouldBlockFn: _customBlockerFn,
|
|
46
|
+
enableBeforeUnload: shouldBlock,
|
|
47
|
+
withResolver: fn === undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useBlocker(opts, condition) {
|
|
52
|
+
const { shouldBlockFn, enableBeforeUnload = true, disabled = false, withResolver = false } =
|
|
53
|
+
_resolveBlockerOpts(opts, condition);
|
|
54
|
+
|
|
55
|
+
const router = useRouter();
|
|
56
|
+
const { history } = router;
|
|
57
|
+
|
|
58
|
+
const [resolver, setResolver] = useState(IDLE_RESOLVER);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const blockerFnComposed = async (blockerFnArgs) => {
|
|
62
|
+
function getLocation(location) {
|
|
63
|
+
const parsedLocation = router.parseLocation(location);
|
|
64
|
+
const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname);
|
|
65
|
+
if (matchedRoutes.foundRoute === undefined) {
|
|
66
|
+
return {
|
|
67
|
+
routeId: '__notFound__',
|
|
68
|
+
fullPath: parsedLocation.pathname,
|
|
69
|
+
pathname: parsedLocation.pathname,
|
|
70
|
+
params: matchedRoutes.routeParams,
|
|
71
|
+
search: router.options.parseSearch(location.search),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
routeId: matchedRoutes.foundRoute.id,
|
|
76
|
+
fullPath: matchedRoutes.foundRoute.fullPath,
|
|
77
|
+
pathname: parsedLocation.pathname,
|
|
78
|
+
params: matchedRoutes.routeParams,
|
|
79
|
+
search: router.options.parseSearch(location.search),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const current = getLocation(blockerFnArgs.currentLocation);
|
|
84
|
+
const next = getLocation(blockerFnArgs.nextLocation);
|
|
85
|
+
|
|
86
|
+
if (current.routeId === '__notFound__' && next.routeId !== '__notFound__') {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const shouldBlock = await shouldBlockFn({
|
|
91
|
+
action: blockerFnArgs.action,
|
|
92
|
+
current,
|
|
93
|
+
next,
|
|
94
|
+
});
|
|
95
|
+
if (!withResolver) return shouldBlock;
|
|
96
|
+
if (!shouldBlock) return false;
|
|
97
|
+
|
|
98
|
+
const promise = new Promise((resolve) => {
|
|
99
|
+
setResolver({
|
|
100
|
+
status: 'blocked',
|
|
101
|
+
current,
|
|
102
|
+
next,
|
|
103
|
+
action: blockerFnArgs.action,
|
|
104
|
+
proceed: () => resolve(false),
|
|
105
|
+
reset: () => resolve(true),
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const canNavigateAsync = await promise;
|
|
110
|
+
setResolver(IDLE_RESOLVER);
|
|
111
|
+
return canNavigateAsync;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return disabled
|
|
115
|
+
? undefined
|
|
116
|
+
: history.block({ blockerFn: blockerFnComposed, enableBeforeUnload });
|
|
117
|
+
}, [shouldBlockFn, enableBeforeUnload, disabled, withResolver, history, router]);
|
|
118
|
+
|
|
119
|
+
return resolver;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Declarative blocker: registers useBlocker and renders children (optionally a
|
|
123
|
+
// render prop receiving the resolver).
|
|
124
|
+
export function Block(props) @{
|
|
125
|
+
const { children, ...rest } = props;
|
|
126
|
+
const resolver = useBlocker(rest);
|
|
127
|
+
const out =
|
|
128
|
+
children
|
|
129
|
+
? typeof children === 'function' && !isChildrenBlock(children)
|
|
130
|
+
? children(resolver)
|
|
131
|
+
: children
|
|
132
|
+
: null;
|
|
133
|
+
<>
|
|
134
|
+
{out}
|
|
135
|
+
</>
|
|
136
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// useElementScrollRestoration — restore scroll for a specific element (e.g. a
|
|
2
|
+
// virtualized list) by id or getter. Port of react-router's
|
|
3
|
+
// ScrollRestoration.tsx helper: ensures the router's scroll restoration is set
|
|
4
|
+
// up, then reads the element's stored entry from the scroll-restoration cache.
|
|
5
|
+
import { setupScrollRestoration, getElementScrollRestorationEntry } from '@tanstack/router-core';
|
|
6
|
+
import { useRouter } from './context';
|
|
7
|
+
|
|
8
|
+
export function useElementScrollRestoration(options: any): any {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
setupScrollRestoration(router, true);
|
|
11
|
+
return getElementScrollRestorationEntry(router, options);
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The reactive base every router hook reads through: subscribe to the router's
|
|
2
|
+
// canonical snapshot store (`router.stores.__store`) and select a slice.
|
|
3
|
+
import { useStore } from './useStore';
|
|
4
|
+
import { useRouter } from './context';
|
|
5
|
+
import { splitSlot, subSlot } from './internal';
|
|
6
|
+
|
|
7
|
+
export function useRouterState(...args: any[]): any {
|
|
8
|
+
const [user, slot] = splitSlot(args);
|
|
9
|
+
const opts = (user[0] ?? {}) as { select?: (s: any) => any; router?: any };
|
|
10
|
+
const router = useRouter(opts.router ? { router: opts.router } : undefined);
|
|
11
|
+
return useStore(
|
|
12
|
+
router.stores.__store,
|
|
13
|
+
opts.select ?? ((s: any) => s),
|
|
14
|
+
undefined,
|
|
15
|
+
subSlot(slot, 'rs'),
|
|
16
|
+
);
|
|
17
|
+
}
|
package/src/useStore.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The keystone adapter. Everything reactive in the router funnels through
|
|
2
|
+
// `@tanstack/react-store`'s `useStore(atom, selector)`, which is
|
|
3
|
+
// `useSyncExternalStoreWithSelector(atom.subscribe→unsubscribe, atom.get, …,
|
|
4
|
+
// selector, compare)`. Octane has no `…WithSelector`, so we fold the selector +
|
|
5
|
+
// compare into a memoized snapshot getter on top of octane's native
|
|
6
|
+
// `useSyncExternalStore`. The atoms come from router-core's client store factory
|
|
7
|
+
// (`createAtom` from `@tanstack/store`): `.subscribe(cb) → { unsubscribe }` + `.get()`.
|
|
8
|
+
import { useSyncExternalStore, useCallback, useRef } from 'octane';
|
|
9
|
+
import { subSlot, splitSlot } from './internal';
|
|
10
|
+
|
|
11
|
+
interface Atom<T> {
|
|
12
|
+
subscribe: (cb: () => void) => { unsubscribe: () => void };
|
|
13
|
+
get: () => T;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useStore<T, S = T>(...args: any[]): S {
|
|
17
|
+
const [user, slot] = splitSlot(args);
|
|
18
|
+
const atom = user[0] as Atom<T>;
|
|
19
|
+
const selector = (user[1] ?? ((s: T) => s as unknown as S)) as (s: T) => S;
|
|
20
|
+
const compare = (user[2] ?? Object.is) as (a: S, b: S) => boolean;
|
|
21
|
+
|
|
22
|
+
// Re-subscribe only when the atom identity changes (it's stable across renders).
|
|
23
|
+
const subscribe = useCallback(
|
|
24
|
+
(onChange: () => void) => atom.subscribe(onChange).unsubscribe,
|
|
25
|
+
[atom],
|
|
26
|
+
subSlot(slot, 'us:cb'),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Memoize selector output: same store input → same output; structurally-equal
|
|
30
|
+
// output keeps its previous reference (so useSyncExternalStore doesn't loop).
|
|
31
|
+
const cache = useRef<{ in: T; out: S } | null>(null, subSlot(slot, 'us:cache'));
|
|
32
|
+
const getSnapshot = (): S => {
|
|
33
|
+
const input = atom.get();
|
|
34
|
+
const prev = cache.current;
|
|
35
|
+
if (prev && Object.is(prev.in, input)) return prev.out;
|
|
36
|
+
const next = selector(input);
|
|
37
|
+
if (prev && compare(prev.out, next)) {
|
|
38
|
+
cache.current = { in: input, out: prev.out };
|
|
39
|
+
return prev.out;
|
|
40
|
+
}
|
|
41
|
+
cache.current = { in: input, out: next };
|
|
42
|
+
return next;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot, subSlot(slot, 'us:uses'));
|
|
46
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Small hook utilities ported from react-router's utils.tsx. Plain-`.ts` hooks
|
|
2
|
+
// forward the caller's compiler-injected slot (see internal.ts).
|
|
3
|
+
import { useRef } from 'octane';
|
|
4
|
+
import { splitSlot, subSlot } from './internal';
|
|
5
|
+
|
|
6
|
+
// Returns the value from the previous render (null on first render). Ported from
|
|
7
|
+
// react-router's usePrevious — the previous/current pair lives in one ref cell and
|
|
8
|
+
// rolls forward during render when the incoming value changes.
|
|
9
|
+
export function usePrevious(...args: any[]): any {
|
|
10
|
+
const [user, slot] = splitSlot(args);
|
|
11
|
+
const value = user[0];
|
|
12
|
+
const ref = useRef({ value, prev: null }, subSlot(slot, 'prev'));
|
|
13
|
+
const current = ref.current.value;
|
|
14
|
+
if (value !== current) {
|
|
15
|
+
ref.current = { value, prev: current };
|
|
16
|
+
}
|
|
17
|
+
return ref.current.prev;
|
|
18
|
+
}
|