@tschk/moonshine-tanstack 0.4.0 → 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 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**: `location.state` is always `{}`; there is no
69
- `useHistoryState`.
70
- - `useSearch` reads `window.location.search`; on the server it is empty because
71
- moonshine's router signal carries the pathname only.
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.0",
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.3.6",
20
- "@tschk/moonshine-router": "^0.3.6"
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 { isExternal, navigate } from "@tschk/moonshine/router";
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
- export function Link({
59
- to,
60
- from: _from,
61
- params,
62
- search,
63
- hash,
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 = activeOptions?.exact
118
- ? location.pathname === target
119
- : location.pathname === target ||
120
- location.pathname.startsWith(
121
- target.endsWith("/") ? target : `${target}/`,
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
- ...rest,
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
- * Moonshine's signal carries the pathname only, so `search` and `hash` are read
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 { useLocation as useMoonshineLocation } from "@tschk/moonshine/router";
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: "search" | "hash"): string {
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 pathname = useMoonshineLocation();
103
- const searchStr = browserPart("search");
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
- // History state is not tracked by the moonshine router.
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
- navigate,
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