@tschk/moonshine-tanstack 0.4.1 → 0.4.4
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/README.md +4 -4
- package/package.json +11 -3
- package/src/link.ts +89 -52
- package/src/location.ts +15 -9
- package/src/navigation.ts +4 -4
package/README.md
CHANGED
|
@@ -65,9 +65,9 @@ own loader/RPC runtime, and are **not** provided:
|
|
|
65
65
|
- **Transitions/blocking**: `useBlocker`, `Block`, `isTransitioning`
|
|
66
66
|
(always `false`), `useRouterState().status` (always `"idle"`),
|
|
67
67
|
`matches` (always empty).
|
|
68
|
-
- **History state**:
|
|
69
|
-
`useHistoryState
|
|
70
|
-
- `useSearch` reads
|
|
71
|
-
|
|
68
|
+
- **History state hook**: navigation state is available in `location.state`;
|
|
69
|
+
there is no separate `useHistoryState` hook.
|
|
70
|
+
- `useSearch` reads the owning router's pathname-plus-query signal, including
|
|
71
|
+
its scoped server snapshot.
|
|
72
72
|
|
|
73
73
|
See [Moonshine README](../../README.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-tanstack",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "TanStack Router / Start client API reimplemented on moonshine — drop @tanstack/react-router, keep the imports",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"./aliases": "./src/aliases.ts"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@tschk/moonshine": "^0.4.
|
|
20
|
-
"@tschk/moonshine-router": "^0.4.
|
|
19
|
+
"@tschk/moonshine": "^0.4.4",
|
|
20
|
+
"@tschk/moonshine-router": "^0.4.4"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": ">=18",
|
|
@@ -38,5 +38,13 @@
|
|
|
38
38
|
"lint": "bunx oxlint .",
|
|
39
39
|
"typecheck": "bunx tsc --noEmit",
|
|
40
40
|
"test": "bun test"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"react": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"react-dom": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
41
49
|
}
|
|
42
50
|
}
|
package/src/link.ts
CHANGED
|
@@ -16,8 +16,12 @@ import {
|
|
|
16
16
|
type AnchorHTMLAttributes,
|
|
17
17
|
type MouseEvent,
|
|
18
18
|
type ReactNode,
|
|
19
|
+
type RefObject,
|
|
19
20
|
} from "react";
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
isExternal,
|
|
23
|
+
useNavigate as useMoonshineNavigate,
|
|
24
|
+
} from "@tschk/moonshine/router";
|
|
21
25
|
import { buildHref, type ToOptions } from "./location";
|
|
22
26
|
import { useParsedLocation } from "./location";
|
|
23
27
|
|
|
@@ -55,34 +59,12 @@ function pathnameOf(href: string): string {
|
|
|
55
59
|
return cut === -1 ? href : href.slice(0, cut);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
replace,
|
|
65
|
-
resetScroll,
|
|
66
|
-
activeOptions,
|
|
67
|
-
preload = "intent",
|
|
68
|
-
disabled,
|
|
69
|
-
activeProps,
|
|
70
|
-
inactiveProps,
|
|
71
|
-
className,
|
|
72
|
-
style,
|
|
73
|
-
children,
|
|
74
|
-
onClick,
|
|
75
|
-
onMouseEnter,
|
|
76
|
-
...rest
|
|
77
|
-
}: LinkProps): ReactNode {
|
|
78
|
-
const location = useParsedLocation();
|
|
79
|
-
const href = buildHref({ to, params, search, hash }, location.search);
|
|
80
|
-
const external = isExternal(href, rest.target);
|
|
81
|
-
const ref = useRef<HTMLAnchorElement>(null);
|
|
82
|
-
// Navigation runs in a transition so a slow subtree cannot block the click.
|
|
83
|
-
const [, startTransition] = useTransition();
|
|
84
|
-
const inert = external || disabled === true || preload === false;
|
|
85
|
-
|
|
62
|
+
function useLinkPreload(
|
|
63
|
+
ref: RefObject<HTMLAnchorElement | null>,
|
|
64
|
+
href: string,
|
|
65
|
+
preload: LinkProps["preload"],
|
|
66
|
+
inert: boolean,
|
|
67
|
+
) {
|
|
86
68
|
const warm = useCallback(() => {
|
|
87
69
|
if (!inert) preloadOnce(href);
|
|
88
70
|
}, [inert, href]);
|
|
@@ -113,20 +95,85 @@ export function Link({
|
|
|
113
95
|
return () => observer.disconnect();
|
|
114
96
|
}, [warm, preload, inert]);
|
|
115
97
|
|
|
98
|
+
return warm;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isLinkActive(
|
|
102
|
+
locationPath: string,
|
|
103
|
+
target: string,
|
|
104
|
+
exact?: boolean,
|
|
105
|
+
): boolean {
|
|
106
|
+
if (exact) return locationPath === target;
|
|
107
|
+
return (
|
|
108
|
+
locationPath === target ||
|
|
109
|
+
locationPath.startsWith(target.endsWith("/") ? target : `${target}/`)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function shouldNavigate(
|
|
114
|
+
event: MouseEvent<HTMLAnchorElement>,
|
|
115
|
+
external: boolean,
|
|
116
|
+
disabled?: boolean,
|
|
117
|
+
): boolean {
|
|
118
|
+
return (
|
|
119
|
+
!event.defaultPrevented &&
|
|
120
|
+
event.button === 0 &&
|
|
121
|
+
!event.metaKey &&
|
|
122
|
+
!event.altKey &&
|
|
123
|
+
!event.ctrlKey &&
|
|
124
|
+
!event.shiftKey &&
|
|
125
|
+
!external &&
|
|
126
|
+
disabled !== true
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function Link({
|
|
131
|
+
to,
|
|
132
|
+
from: _from,
|
|
133
|
+
params,
|
|
134
|
+
search,
|
|
135
|
+
hash,
|
|
136
|
+
replace,
|
|
137
|
+
state: historyState,
|
|
138
|
+
resetScroll,
|
|
139
|
+
activeOptions,
|
|
140
|
+
preload = "intent",
|
|
141
|
+
disabled,
|
|
142
|
+
activeProps,
|
|
143
|
+
inactiveProps,
|
|
144
|
+
className,
|
|
145
|
+
style,
|
|
146
|
+
children,
|
|
147
|
+
onClick,
|
|
148
|
+
onMouseEnter,
|
|
149
|
+
...rest
|
|
150
|
+
}: LinkProps): ReactNode {
|
|
151
|
+
const navigate = useMoonshineNavigate();
|
|
152
|
+
const location = useParsedLocation();
|
|
153
|
+
const href = buildHref({ to, params, search, hash }, location.search);
|
|
154
|
+
const ref = useRef<HTMLAnchorElement>(null);
|
|
155
|
+
// Navigation runs in a transition so a slow subtree cannot block the click.
|
|
156
|
+
const [, startTransition] = useTransition();
|
|
116
157
|
const target = pathnameOf(href);
|
|
117
|
-
const isActive =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
const isActive = isLinkActive(
|
|
159
|
+
location.pathname,
|
|
160
|
+
target,
|
|
161
|
+
activeOptions?.exact,
|
|
162
|
+
);
|
|
163
|
+
const anchorProps = { ...rest, ...(isActive ? activeProps : inactiveProps) };
|
|
164
|
+
const external = isExternal(
|
|
165
|
+
href,
|
|
166
|
+
anchorProps.target as string | undefined,
|
|
167
|
+
anchorProps.download,
|
|
168
|
+
);
|
|
169
|
+
const inert = external || disabled === true || preload === false;
|
|
170
|
+
const warm = useLinkPreload(ref, href, preload, inert);
|
|
123
171
|
const state: LinkRenderState = { isActive, isTransitioning: false };
|
|
124
172
|
|
|
125
173
|
return createElement(
|
|
126
174
|
"a",
|
|
127
175
|
{
|
|
128
|
-
...
|
|
129
|
-
...(isActive ? activeProps : inactiveProps),
|
|
176
|
+
...anchorProps,
|
|
130
177
|
ref,
|
|
131
178
|
href,
|
|
132
179
|
style,
|
|
@@ -139,21 +186,10 @@ export function Link({
|
|
|
139
186
|
},
|
|
140
187
|
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
141
188
|
onClick?.(event);
|
|
142
|
-
if (
|
|
143
|
-
event.defaultPrevented ||
|
|
144
|
-
event.button !== 0 ||
|
|
145
|
-
event.metaKey ||
|
|
146
|
-
event.altKey ||
|
|
147
|
-
event.ctrlKey ||
|
|
148
|
-
event.shiftKey ||
|
|
149
|
-
external ||
|
|
150
|
-
disabled === true
|
|
151
|
-
) {
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
189
|
+
if (!shouldNavigate(event, external, disabled)) return;
|
|
154
190
|
event.preventDefault();
|
|
155
191
|
startTransition(() => {
|
|
156
|
-
navigate(href, { replace });
|
|
192
|
+
navigate(href, { replace, state: historyState });
|
|
157
193
|
if (resetScroll !== false) globalThis.scrollTo?.(0, 0);
|
|
158
194
|
});
|
|
159
195
|
},
|
|
@@ -166,10 +202,11 @@ export type NavigateProps = ToOptions;
|
|
|
166
202
|
|
|
167
203
|
/** Redirects on mount. */
|
|
168
204
|
export function Navigate(props: NavigateProps): null {
|
|
205
|
+
const navigate = useMoonshineNavigate();
|
|
169
206
|
const location = useParsedLocation();
|
|
170
207
|
const href = buildHref(props, location.search);
|
|
171
208
|
useEffect(() => {
|
|
172
|
-
navigate(href, { replace: props.replace });
|
|
173
|
-
}, [href, props.replace]);
|
|
209
|
+
navigate(href, { replace: props.replace, state: props.state });
|
|
210
|
+
}, [navigate, href, props.replace, props.state]);
|
|
174
211
|
return null;
|
|
175
212
|
}
|
package/src/location.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* TanStack Router's location shape, derived from the moonshine router signal.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* back from `window.location` on each render.
|
|
6
|
+
* History state is subscribed independently so same-path navigations update.
|
|
8
7
|
*/
|
|
9
8
|
import { useMemo, useReducer } from "react";
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
useLocation as useMoonshineLocation,
|
|
11
|
+
useHistory as useMoonshineHistory,
|
|
12
|
+
} from "@tschk/moonshine/router";
|
|
11
13
|
|
|
12
14
|
export type SearchRecord = Record<string, unknown>;
|
|
13
15
|
|
|
@@ -27,6 +29,7 @@ export type ToOptions = {
|
|
|
27
29
|
search?: SearchRecord | ((current: SearchRecord) => SearchRecord);
|
|
28
30
|
hash?: string;
|
|
29
31
|
replace?: boolean;
|
|
32
|
+
state?: Record<string, unknown>;
|
|
30
33
|
resetScroll?: boolean;
|
|
31
34
|
};
|
|
32
35
|
|
|
@@ -87,7 +90,7 @@ export function buildHref(
|
|
|
87
90
|
return `${pathname}${stringifySearch(search)}${hash}`;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
function browserPart(part: "
|
|
93
|
+
function browserPart(part: "hash"): string {
|
|
91
94
|
if (typeof window === "undefined") return "";
|
|
92
95
|
return window.location[part];
|
|
93
96
|
}
|
|
@@ -99,8 +102,12 @@ export function useRerender(): () => void {
|
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
export function useParsedLocation(): ParsedLocation {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
105
|
+
const source = useMoonshineLocation();
|
|
106
|
+
const pathname = source.split("?", 1)[0]!;
|
|
107
|
+
const history = useMoonshineHistory();
|
|
108
|
+
const searchStr = source.includes("?")
|
|
109
|
+
? source.slice(source.indexOf("?"))
|
|
110
|
+
: "";
|
|
104
111
|
const hash = browserPart("hash");
|
|
105
112
|
return useMemo(
|
|
106
113
|
() => ({
|
|
@@ -109,9 +116,8 @@ export function useParsedLocation(): ParsedLocation {
|
|
|
109
116
|
search: parseSearch(searchStr),
|
|
110
117
|
searchStr,
|
|
111
118
|
hash,
|
|
112
|
-
|
|
113
|
-
state: {},
|
|
119
|
+
state: (history.state ?? {}) as Record<string, unknown>,
|
|
114
120
|
}),
|
|
115
|
-
[pathname, searchStr, hash],
|
|
121
|
+
[pathname, searchStr, hash, history],
|
|
116
122
|
);
|
|
117
123
|
}
|
package/src/navigation.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback, useMemo } from "react";
|
|
11
11
|
import {
|
|
12
|
-
|
|
12
|
+
useNavigate as useMoonshineNavigate,
|
|
13
13
|
useParams as useMoonshineParams,
|
|
14
14
|
} from "@tschk/moonshine/router";
|
|
15
15
|
import {
|
|
@@ -36,6 +36,7 @@ export type RouterInstance = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
export function useNavigate(): NavigateFn {
|
|
39
|
+
const navigate = useMoonshineNavigate();
|
|
39
40
|
const location = useParsedLocation();
|
|
40
41
|
const rerender = useRerender();
|
|
41
42
|
return useCallback(
|
|
@@ -44,12 +45,11 @@ export function useNavigate(): NavigateFn {
|
|
|
44
45
|
{ ...options, to: options.to ?? location.pathname },
|
|
45
46
|
location.search,
|
|
46
47
|
);
|
|
47
|
-
navigate(href, { replace: options.replace });
|
|
48
|
+
navigate(href, { replace: options.replace, state: options.state });
|
|
48
49
|
if (options.resetScroll !== false) globalThis.scrollTo?.(0, 0);
|
|
49
|
-
// A query-only navigation leaves the pathname signal unchanged.
|
|
50
50
|
rerender();
|
|
51
51
|
},
|
|
52
|
-
[location.pathname, location.search, rerender],
|
|
52
|
+
[navigate, location.pathname, location.search, rerender],
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|