@tschk/moonshine-react-router 0.3.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 +76 -0
- package/package.json +43 -0
- package/src/aliases.ts +58 -0
- package/src/client.ts +12 -0
- package/src/index.ts +51 -0
- package/src/link.ts +210 -0
- package/src/location.ts +64 -0
- package/src/navigation.ts +138 -0
- package/src/outlet.ts +71 -0
- package/src/responses.ts +77 -0
- package/src/server.ts +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# `@tschk/moonshine-react-router`
|
|
2
|
+
|
|
3
|
+
The `react-router` / `@remix-run/react` **client API reimplemented on
|
|
4
|
+
moonshine**. It imports no react-router code and lists none as a dependency:
|
|
5
|
+
`Link`, `NavLink`, `useNavigate` and friends are ordinary React components
|
|
6
|
+
written against `@tschk/moonshine/router`.
|
|
7
|
+
|
|
8
|
+
An existing app can alias the import and drop the dependency:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// bunfig.toml / tsconfig paths / bundler alias
|
|
12
|
+
"react-router" -> "@tschk/moonshine-react-router"
|
|
13
|
+
"@remix-run/react" -> "@tschk/moonshine-react-router"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import {
|
|
18
|
+
Link,
|
|
19
|
+
NavLink,
|
|
20
|
+
Outlet,
|
|
21
|
+
useNavigate,
|
|
22
|
+
useSearchParams,
|
|
23
|
+
} from "@tschk/moonshine-react-router";
|
|
24
|
+
import { json, redirect } from "@tschk/moonshine-react-router/server";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Subpath | Contents |
|
|
28
|
+
| -------------- | ------------------------------------------------------- |
|
|
29
|
+
| `.` | full client API (components + hooks + response helpers) |
|
|
30
|
+
| `./link` | `Link`, `NavLink`, `Navigate` |
|
|
31
|
+
| `./navigation` | `useNavigate`, `useLocation`, `useParams`, … |
|
|
32
|
+
| `./outlet` | `Outlet`, `OutletProvider`, `useOutletContext` |
|
|
33
|
+
| `./responses` | `json`, `data`, `redirect`, `ErrorResponse` |
|
|
34
|
+
| `./server` | response helpers + moonshine server handling |
|
|
35
|
+
| `./client` | moonshine signals + resources |
|
|
36
|
+
|
|
37
|
+
## Supported
|
|
38
|
+
|
|
39
|
+
- `Link` — `to` as string or partial path, `replace`, `preventScrollReset`,
|
|
40
|
+
`reloadDocument`, `prefetch` (`"intent"` hover, `"render"`, `"viewport"`);
|
|
41
|
+
modifier-clicks, external URLs and non-`_self` targets fall through to the
|
|
42
|
+
browser. Navigation runs inside `startTransition`.
|
|
43
|
+
- `NavLink` — `end`, `caseSensitive`, `className`/`style`/`children` as render
|
|
44
|
+
callbacks, `aria-current="page"`, default `.active` class.
|
|
45
|
+
- `Navigate` — declarative redirect on mount.
|
|
46
|
+
- `Outlet`, `OutletProvider`, `useOutlet`, `useOutletContext`.
|
|
47
|
+
- `useNavigate` (path or numeric delta), `useLocation`, `useParams`,
|
|
48
|
+
`useSearchParams` (with setter), `useHref`, `useResolvedPath`,
|
|
49
|
+
`useNavigationType`.
|
|
50
|
+
- `json`, `data`, `redirect`, `redirectDocument`, `replace`, `ErrorResponse`,
|
|
51
|
+
`isRouteErrorResponse`.
|
|
52
|
+
|
|
53
|
+
## Not supported
|
|
54
|
+
|
|
55
|
+
These need react-router's own data router or build plugin and are **not**
|
|
56
|
+
provided — do not expect them to work by aliasing:
|
|
57
|
+
|
|
58
|
+
- **Data APIs**: `useLoaderData`, `useActionData`, `useFetcher`,
|
|
59
|
+
`useRevalidator`, `useSubmit`, `useNavigation`, `useBlocker`,
|
|
60
|
+
`unstable_usePrompt`, `defer`/`Await`. There is no loader/action lifecycle in
|
|
61
|
+
moonshine; fetch in the component or on the moonshine server route.
|
|
62
|
+
- **Route configuration**: `createBrowserRouter`, `RouterProvider`, `Routes`,
|
|
63
|
+
`Route`, `createRoutesFromElements`, nested route matching. Moonshine matches
|
|
64
|
+
a flat route table via `MoonshineRouter`; `<Outlet>` is fed explicitly by
|
|
65
|
+
`<OutletProvider>` rather than derived from nested config.
|
|
66
|
+
- **Progressive enhancement**: `<Form>`, `<Scripts>`, `<Meta>`, `<Links>`,
|
|
67
|
+
`<ScrollRestoration>`, `<Await>`, `useFormAction`.
|
|
68
|
+
- **History state**: `location.state`, `location.key` stability and
|
|
69
|
+
`useNavigationType` distinguishing PUSH/POP/REPLACE. `state` is always `null`
|
|
70
|
+
and `useNavigationType()` always returns `"POP"`.
|
|
71
|
+
- **Error boundaries**: `useRouteError`, `errorElement`. `ErrorResponse` and
|
|
72
|
+
`isRouteErrorResponse` exist as value types only.
|
|
73
|
+
- `useSearchParams` reads `window.location.search`; on the server it is empty
|
|
74
|
+
because moonshine's router signal carries the pathname only.
|
|
75
|
+
|
|
76
|
+
See [Moonshine README](../../README.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tschk/moonshine-react-router",
|
|
3
|
+
"version": "0.3.4",
|
|
4
|
+
"description": "react-router / Remix client API reimplemented on moonshine — drop react-router, keep the imports",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./client": "./src/client.ts",
|
|
12
|
+
"./server": "./src/server.ts",
|
|
13
|
+
"./navigation": "./src/navigation.ts",
|
|
14
|
+
"./link": "./src/link.ts",
|
|
15
|
+
"./outlet": "./src/outlet.ts",
|
|
16
|
+
"./responses": "./src/responses.ts",
|
|
17
|
+
"./aliases": "./src/aliases.ts"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@tschk/moonshine": "^0.3.4",
|
|
21
|
+
"@tschk/moonshine-router": "^0.3.4"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": ">=18",
|
|
25
|
+
"react-dom": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"keywords": [
|
|
32
|
+
"moonshine",
|
|
33
|
+
"react-router",
|
|
34
|
+
"remix",
|
|
35
|
+
"adapter"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"format": "bunx prettier --check .",
|
|
39
|
+
"lint": "bunx oxlint .",
|
|
40
|
+
"typecheck": "bunx tsc --noEmit",
|
|
41
|
+
"test": "bun test"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/aliases.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in mapping from the react-router specifiers onto this package.
|
|
3
|
+
*
|
|
4
|
+
* An existing app keeps its imports — the bundler rewrites the specifier, so no
|
|
5
|
+
* source changes are needed.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { moonshineReactRouterPlugin } from "@tschk/moonshine-react-router/aliases";
|
|
9
|
+
* await Bun.build({ entrypoints: ["./app.tsx"], plugins: [moonshineReactRouterPlugin()] });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { BunPlugin } from "bun";
|
|
14
|
+
|
|
15
|
+
/** Bare specifier each replaced package is served by. */
|
|
16
|
+
export const reactRouterAliases: Readonly<Record<string, string>> = {
|
|
17
|
+
"react-router": "@tschk/moonshine-react-router",
|
|
18
|
+
"react-router-dom": "@tschk/moonshine-react-router",
|
|
19
|
+
"@remix-run/react": "@tschk/moonshine-react-router",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** File in this package backing each alias, relative to `src/`. */
|
|
23
|
+
const files: Readonly<Record<string, string>> = {
|
|
24
|
+
"react-router": "index.ts",
|
|
25
|
+
"react-router-dom": "index.ts",
|
|
26
|
+
"@remix-run/react": "index.ts",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `compilerOptions.paths` block for an app's tsconfig, so the type checker
|
|
31
|
+
* follows the same mapping the bundler applies.
|
|
32
|
+
*/
|
|
33
|
+
export function tsconfigPaths(): Record<string, string[]> {
|
|
34
|
+
return Object.fromEntries(
|
|
35
|
+
Object.entries(reactRouterAliases).map(([from, to]) => [from, [to]]),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Absolute path this package serves `specifier` from, if it is aliased. */
|
|
40
|
+
export function resolveAlias(specifier: string): string | undefined {
|
|
41
|
+
const file = files[specifier];
|
|
42
|
+
return file === undefined ? undefined : join(import.meta.dir, file);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const filter = /^(react-router|react-router-dom|@remix-run\/react)$/;
|
|
46
|
+
|
|
47
|
+
/** Bun bundler/runtime plugin rewriting react-router imports to this package. */
|
|
48
|
+
export function moonshineReactRouterPlugin(): BunPlugin {
|
|
49
|
+
return {
|
|
50
|
+
name: "moonshine-react-router-aliases",
|
|
51
|
+
setup(build) {
|
|
52
|
+
build.onResolve({ filter }, (args) => {
|
|
53
|
+
const path = resolveAlias(args.path);
|
|
54
|
+
return path === undefined ? undefined : { path };
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/client.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @tschk/moonshine-react-router
|
|
5
|
+
*
|
|
6
|
+
* The `react-router` / `@remix-run/react` client API reimplemented on
|
|
7
|
+
* moonshine's router. Alias `react-router` to this package to drop the
|
|
8
|
+
* dependency.
|
|
9
|
+
*/
|
|
10
|
+
export { Link, NavLink, Navigate, default as LinkDefault } from "./link";
|
|
11
|
+
export type {
|
|
12
|
+
LinkProps,
|
|
13
|
+
NavLinkProps,
|
|
14
|
+
NavLinkRenderState,
|
|
15
|
+
NavigateProps,
|
|
16
|
+
} from "./link";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
useHref,
|
|
20
|
+
useLocation,
|
|
21
|
+
useNavigate,
|
|
22
|
+
useNavigationType,
|
|
23
|
+
useParams,
|
|
24
|
+
useResolvedPath,
|
|
25
|
+
useSearchParams,
|
|
26
|
+
} from "./navigation";
|
|
27
|
+
export type {
|
|
28
|
+
NavigateFunction,
|
|
29
|
+
NavigateOptions,
|
|
30
|
+
NavigationType,
|
|
31
|
+
SetURLSearchParams,
|
|
32
|
+
} from "./navigation";
|
|
33
|
+
|
|
34
|
+
export { Outlet, OutletProvider, useOutlet, useOutletContext } from "./outlet";
|
|
35
|
+
export type {
|
|
36
|
+
OutletContextValue,
|
|
37
|
+
OutletProps,
|
|
38
|
+
OutletProviderProps,
|
|
39
|
+
} from "./outlet";
|
|
40
|
+
|
|
41
|
+
export type { Location, To } from "./location";
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
ErrorResponse,
|
|
45
|
+
data,
|
|
46
|
+
isRouteErrorResponse,
|
|
47
|
+
json,
|
|
48
|
+
redirect,
|
|
49
|
+
redirectDocument,
|
|
50
|
+
replace,
|
|
51
|
+
} from "./responses";
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<Link>`, `<NavLink>` and `<Navigate>` on the moonshine router.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* import { Link, NavLink } from "@tschk/moonshine-react-router";
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
createElement,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useRef,
|
|
15
|
+
useTransition,
|
|
16
|
+
type AnchorHTMLAttributes,
|
|
17
|
+
type MouseEvent,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
} from "react";
|
|
20
|
+
import { navigate } from "@tschk/moonshine/router";
|
|
21
|
+
import { toHref, type To } from "./location";
|
|
22
|
+
import { useLocation } from "./navigation";
|
|
23
|
+
|
|
24
|
+
export type LinkProps = Omit<
|
|
25
|
+
AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
26
|
+
"href"
|
|
27
|
+
> & {
|
|
28
|
+
to: To;
|
|
29
|
+
replace?: boolean;
|
|
30
|
+
preventScrollReset?: boolean;
|
|
31
|
+
/** Accepted for source compatibility; history state is not tracked. */
|
|
32
|
+
state?: unknown;
|
|
33
|
+
reloadDocument?: boolean;
|
|
34
|
+
/** `"intent"` warms the URL on hover, `"render"` on mount. */
|
|
35
|
+
prefetch?: "none" | "intent" | "render" | "viewport";
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function isExternal(href: string, target?: string): boolean {
|
|
40
|
+
if (target && target !== "_self") return true;
|
|
41
|
+
return (
|
|
42
|
+
/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) ||
|
|
43
|
+
href.startsWith("//") ||
|
|
44
|
+
href.startsWith("#")
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const prefetched = new Set<string>();
|
|
49
|
+
|
|
50
|
+
function prefetchOnce(url: string): void {
|
|
51
|
+
if (prefetched.has(url) || typeof document === "undefined") return;
|
|
52
|
+
prefetched.add(url);
|
|
53
|
+
const tag = document.createElement("link");
|
|
54
|
+
tag.rel = "prefetch";
|
|
55
|
+
tag.href = url;
|
|
56
|
+
document.head.append(tag);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function Link({
|
|
60
|
+
to,
|
|
61
|
+
replace,
|
|
62
|
+
preventScrollReset,
|
|
63
|
+
state: _state,
|
|
64
|
+
reloadDocument,
|
|
65
|
+
prefetch = "intent",
|
|
66
|
+
children,
|
|
67
|
+
onClick,
|
|
68
|
+
onMouseEnter,
|
|
69
|
+
...rest
|
|
70
|
+
}: LinkProps): ReactNode {
|
|
71
|
+
const href = toHref(to);
|
|
72
|
+
const external = isExternal(href, rest.target);
|
|
73
|
+
const ref = useRef<HTMLAnchorElement>(null);
|
|
74
|
+
// Navigation runs in a transition so a slow subtree cannot block the click.
|
|
75
|
+
const [, startTransition] = useTransition();
|
|
76
|
+
const inert = external || reloadDocument === true || prefetch === "none";
|
|
77
|
+
|
|
78
|
+
const warm = useCallback(() => {
|
|
79
|
+
if (!inert) prefetchOnce(href);
|
|
80
|
+
}, [inert, href]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (inert) return;
|
|
84
|
+
if (prefetch === "render") {
|
|
85
|
+
warm();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const node = ref.current;
|
|
89
|
+
if (
|
|
90
|
+
!node ||
|
|
91
|
+
prefetch !== "viewport" ||
|
|
92
|
+
typeof IntersectionObserver === "undefined"
|
|
93
|
+
) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const observer = new IntersectionObserver((entries) => {
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.isIntersecting) {
|
|
99
|
+
warm();
|
|
100
|
+
observer.disconnect();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
observer.observe(node);
|
|
105
|
+
return () => observer.disconnect();
|
|
106
|
+
}, [warm, prefetch, inert]);
|
|
107
|
+
|
|
108
|
+
return createElement(
|
|
109
|
+
"a",
|
|
110
|
+
{
|
|
111
|
+
...rest,
|
|
112
|
+
ref,
|
|
113
|
+
href,
|
|
114
|
+
onMouseEnter: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
115
|
+
onMouseEnter?.(event);
|
|
116
|
+
if (prefetch === "intent") warm();
|
|
117
|
+
},
|
|
118
|
+
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
119
|
+
onClick?.(event);
|
|
120
|
+
if (
|
|
121
|
+
event.defaultPrevented ||
|
|
122
|
+
event.button !== 0 ||
|
|
123
|
+
event.metaKey ||
|
|
124
|
+
event.altKey ||
|
|
125
|
+
event.ctrlKey ||
|
|
126
|
+
event.shiftKey ||
|
|
127
|
+
external ||
|
|
128
|
+
reloadDocument === true
|
|
129
|
+
) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
startTransition(() => {
|
|
134
|
+
navigate(href, { replace });
|
|
135
|
+
if (preventScrollReset !== true) globalThis.scrollTo?.(0, 0);
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
children,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type NavLinkRenderState = { isActive: boolean; isPending: boolean };
|
|
144
|
+
|
|
145
|
+
export type NavLinkProps = Omit<
|
|
146
|
+
LinkProps,
|
|
147
|
+
"className" | "style" | "children"
|
|
148
|
+
> & {
|
|
149
|
+
end?: boolean;
|
|
150
|
+
caseSensitive?: boolean;
|
|
151
|
+
className?: string | ((state: NavLinkRenderState) => string | undefined);
|
|
152
|
+
style?: Record<string, unknown> | ((state: NavLinkRenderState) => never);
|
|
153
|
+
children?: ReactNode | ((state: NavLinkRenderState) => ReactNode);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function pathnameOf(href: string): string {
|
|
157
|
+
const cut = href.search(/[?#]/);
|
|
158
|
+
return cut === -1 ? href : href.slice(0, cut);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function NavLink({
|
|
162
|
+
end,
|
|
163
|
+
caseSensitive,
|
|
164
|
+
className,
|
|
165
|
+
style,
|
|
166
|
+
children,
|
|
167
|
+
...rest
|
|
168
|
+
}: NavLinkProps): ReactNode {
|
|
169
|
+
const { pathname } = useLocation();
|
|
170
|
+
const target = pathnameOf(toHref(rest.to));
|
|
171
|
+
const [a, b] = caseSensitive
|
|
172
|
+
? [pathname, target]
|
|
173
|
+
: [pathname.toLowerCase(), target.toLowerCase()];
|
|
174
|
+
const isActive = end
|
|
175
|
+
? a === b || a === `${b}/`
|
|
176
|
+
: a === b || a.startsWith(b.endsWith("/") ? b : `${b}/`);
|
|
177
|
+
// Moonshine navigations resolve synchronously, so nothing is ever pending.
|
|
178
|
+
const state: NavLinkRenderState = { isActive, isPending: false };
|
|
179
|
+
|
|
180
|
+
return createElement(
|
|
181
|
+
Link,
|
|
182
|
+
{
|
|
183
|
+
...rest,
|
|
184
|
+
"aria-current": isActive ? "page" : undefined,
|
|
185
|
+
className:
|
|
186
|
+
typeof className === "function"
|
|
187
|
+
? className(state)
|
|
188
|
+
: (className ?? (isActive ? "active" : undefined)),
|
|
189
|
+
style: (typeof style === "function" ? style(state) : style) as never,
|
|
190
|
+
} as LinkProps,
|
|
191
|
+
typeof children === "function" ? children(state) : children,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type NavigateProps = {
|
|
196
|
+
to: To;
|
|
197
|
+
replace?: boolean;
|
|
198
|
+
state?: unknown;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Redirects on mount, the declarative form of `useNavigate()`. */
|
|
202
|
+
export function Navigate({ to, replace }: NavigateProps): null {
|
|
203
|
+
const href = toHref(to);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
navigate(href, { replace });
|
|
206
|
+
}, [href, replace]);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default Link;
|
package/src/location.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Location shape shared by the react-router hooks.
|
|
5
|
+
*
|
|
6
|
+
* Moonshine's router signal carries the pathname only, so `search` and `hash`
|
|
7
|
+
* are read back from `window.location` on each render.
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo, useReducer } from "react";
|
|
10
|
+
import { useLocation as useMoonshineLocation } from "@tschk/moonshine/router";
|
|
11
|
+
|
|
12
|
+
export type Location<S = unknown> = {
|
|
13
|
+
pathname: string;
|
|
14
|
+
search: string;
|
|
15
|
+
hash: string;
|
|
16
|
+
state: S | null;
|
|
17
|
+
key: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type To = string | { pathname?: string; search?: string; hash?: string };
|
|
21
|
+
|
|
22
|
+
/** Normalises a `To` into a single URL string. */
|
|
23
|
+
export function toHref(to: To): string {
|
|
24
|
+
if (typeof to === "string") return to;
|
|
25
|
+
const search = to.search
|
|
26
|
+
? to.search.startsWith("?")
|
|
27
|
+
? to.search
|
|
28
|
+
: `?${to.search}`
|
|
29
|
+
: "";
|
|
30
|
+
const hash = to.hash
|
|
31
|
+
? to.hash.startsWith("#")
|
|
32
|
+
? to.hash
|
|
33
|
+
: `#${to.hash}`
|
|
34
|
+
: "";
|
|
35
|
+
return `${to.pathname ?? ""}${search}${hash}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function browserPart(part: "search" | "hash"): string {
|
|
39
|
+
if (typeof window === "undefined") return "";
|
|
40
|
+
return window.location[part];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Forces a re-render; used after navigations that only change the query. */
|
|
44
|
+
export function useRerender(): () => void {
|
|
45
|
+
const [, bump] = useReducer((n: number) => n + 1, 0);
|
|
46
|
+
return bump;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useLocationObject(): Location {
|
|
50
|
+
const pathname = useMoonshineLocation();
|
|
51
|
+
const search = browserPart("search");
|
|
52
|
+
const hash = browserPart("hash");
|
|
53
|
+
return useMemo(
|
|
54
|
+
() => ({
|
|
55
|
+
pathname,
|
|
56
|
+
search,
|
|
57
|
+
hash,
|
|
58
|
+
// History state is not tracked by the moonshine router.
|
|
59
|
+
state: null,
|
|
60
|
+
key: `${pathname}${search}${hash}`,
|
|
61
|
+
}),
|
|
62
|
+
[pathname, search, hash],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `react-router` navigation hooks implemented on the moonshine router.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { useNavigate, useLocation } from "@tschk/moonshine-react-router";
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useMemo } from "react";
|
|
11
|
+
import {
|
|
12
|
+
navigate,
|
|
13
|
+
useParams as useMoonshineParams,
|
|
14
|
+
} from "@tschk/moonshine/router";
|
|
15
|
+
import {
|
|
16
|
+
toHref,
|
|
17
|
+
useLocationObject,
|
|
18
|
+
useRerender,
|
|
19
|
+
type Location,
|
|
20
|
+
type To,
|
|
21
|
+
} from "./location";
|
|
22
|
+
|
|
23
|
+
export type NavigateOptions = {
|
|
24
|
+
replace?: boolean;
|
|
25
|
+
/** Accepted for source compatibility; history state is not tracked. */
|
|
26
|
+
state?: unknown;
|
|
27
|
+
preventScrollReset?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type NavigateFunction = {
|
|
31
|
+
(to: To, options?: NavigateOptions): void;
|
|
32
|
+
(delta: number): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function useLocation(): Location {
|
|
36
|
+
return useLocationObject();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useNavigate(): NavigateFunction {
|
|
40
|
+
const rerender = useRerender();
|
|
41
|
+
return useCallback(
|
|
42
|
+
(to: To | number, options?: NavigateOptions) => {
|
|
43
|
+
if (typeof to === "number") {
|
|
44
|
+
globalThis.history?.go(to);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
navigate(toHref(to), { replace: options?.replace });
|
|
48
|
+
if (options?.preventScrollReset !== true) globalThis.scrollTo?.(0, 0);
|
|
49
|
+
rerender();
|
|
50
|
+
},
|
|
51
|
+
[rerender],
|
|
52
|
+
) as NavigateFunction;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useParams<
|
|
56
|
+
T extends Record<string, string> = Record<string, string>,
|
|
57
|
+
>(): T {
|
|
58
|
+
return useMoonshineParams() as T;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SetURLSearchParams = (
|
|
62
|
+
next:
|
|
63
|
+
| URLSearchParams
|
|
64
|
+
| Record<string, string>
|
|
65
|
+
| ((current: URLSearchParams) => URLSearchParams),
|
|
66
|
+
options?: NavigateOptions,
|
|
67
|
+
) => void;
|
|
68
|
+
|
|
69
|
+
export function useSearchParams(): [URLSearchParams, SetURLSearchParams] {
|
|
70
|
+
const location = useLocationObject();
|
|
71
|
+
const rerender = useRerender();
|
|
72
|
+
const params = useMemo(
|
|
73
|
+
() => new URLSearchParams(location.search),
|
|
74
|
+
[location.search],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const setSearchParams = useCallback<SetURLSearchParams>(
|
|
78
|
+
(next, options) => {
|
|
79
|
+
const resolved =
|
|
80
|
+
typeof next === "function"
|
|
81
|
+
? next(new URLSearchParams(location.search))
|
|
82
|
+
: next instanceof URLSearchParams
|
|
83
|
+
? next
|
|
84
|
+
: new URLSearchParams(next);
|
|
85
|
+
const query = resolved.toString();
|
|
86
|
+
navigate(`${location.pathname}${query ? `?${query}` : ""}`, {
|
|
87
|
+
replace: options?.replace,
|
|
88
|
+
});
|
|
89
|
+
// A query-only navigation leaves the pathname signal unchanged.
|
|
90
|
+
rerender();
|
|
91
|
+
},
|
|
92
|
+
[location.pathname, location.search, rerender],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return [params, setSearchParams];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** react-router resolves relative `to` values against the current pathname. */
|
|
99
|
+
export function useResolvedPath(to: To): Location {
|
|
100
|
+
const { pathname } = useLocationObject();
|
|
101
|
+
const href = toHref(to);
|
|
102
|
+
return useMemo(() => {
|
|
103
|
+
const base = href.startsWith("/")
|
|
104
|
+
? href
|
|
105
|
+
: new URL(
|
|
106
|
+
href,
|
|
107
|
+
`http://x${pathname.endsWith("/") ? pathname : `${pathname}/`}`,
|
|
108
|
+
)
|
|
109
|
+
.toString()
|
|
110
|
+
.slice("http://x".length);
|
|
111
|
+
const hashAt = base.indexOf("#");
|
|
112
|
+
const withoutHash = hashAt === -1 ? base : base.slice(0, hashAt);
|
|
113
|
+
const hash = hashAt === -1 ? "" : base.slice(hashAt);
|
|
114
|
+
const queryAt = withoutHash.indexOf("?");
|
|
115
|
+
return {
|
|
116
|
+
pathname: queryAt === -1 ? withoutHash : withoutHash.slice(0, queryAt),
|
|
117
|
+
search: queryAt === -1 ? "" : withoutHash.slice(queryAt),
|
|
118
|
+
hash,
|
|
119
|
+
state: null,
|
|
120
|
+
key: base,
|
|
121
|
+
};
|
|
122
|
+
}, [href, pathname]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function useHref(to: To): string {
|
|
126
|
+
const resolved = useResolvedPath(to);
|
|
127
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type NavigationType = "POP" | "PUSH" | "REPLACE";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Moonshine does not record how the current entry was reached, so this always
|
|
134
|
+
* reports `POP`.
|
|
135
|
+
*/
|
|
136
|
+
export function useNavigationType(): NavigationType {
|
|
137
|
+
return "POP";
|
|
138
|
+
}
|
package/src/outlet.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<Outlet>` for nested layouts.
|
|
5
|
+
*
|
|
6
|
+
* Moonshine's router matches a flat route table, so the child element is
|
|
7
|
+
* supplied explicitly by the layout instead of being derived from a nested
|
|
8
|
+
* route config.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <OutletProvider value={<Page />}>
|
|
12
|
+
* <Layout />
|
|
13
|
+
* </OutletProvider>
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
createContext,
|
|
18
|
+
createElement,
|
|
19
|
+
useContext,
|
|
20
|
+
type ReactNode,
|
|
21
|
+
} from "react";
|
|
22
|
+
|
|
23
|
+
export type OutletContextValue = {
|
|
24
|
+
element: ReactNode;
|
|
25
|
+
context: unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const OutletContext = createContext<OutletContextValue>({
|
|
29
|
+
element: null,
|
|
30
|
+
context: undefined,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type OutletProviderProps = {
|
|
34
|
+
/** The child route element rendered where `<Outlet />` appears. */
|
|
35
|
+
value: ReactNode;
|
|
36
|
+
/** Value returned by `useOutletContext()` inside the child. */
|
|
37
|
+
context?: unknown;
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function OutletProvider({
|
|
42
|
+
value,
|
|
43
|
+
context,
|
|
44
|
+
children,
|
|
45
|
+
}: OutletProviderProps): ReactNode {
|
|
46
|
+
return createElement(
|
|
47
|
+
OutletContext.Provider,
|
|
48
|
+
{ value: { element: value, context } },
|
|
49
|
+
children,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type OutletProps = { context?: unknown };
|
|
54
|
+
|
|
55
|
+
export function Outlet({ context }: OutletProps = {}): ReactNode {
|
|
56
|
+
const outlet = useContext(OutletContext);
|
|
57
|
+
if (context === undefined) return outlet.element;
|
|
58
|
+
return createElement(
|
|
59
|
+
OutletContext.Provider,
|
|
60
|
+
{ value: { element: outlet.element, context } },
|
|
61
|
+
outlet.element,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useOutlet(): ReactNode {
|
|
66
|
+
return useContext(OutletContext).element;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useOutletContext<T = unknown>(): T {
|
|
70
|
+
return useContext(OutletContext).context as T;
|
|
71
|
+
}
|
package/src/responses.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loader/action return helpers. These are plain `Response` builders in
|
|
3
|
+
* react-router, so they carry over unchanged.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* export async function loader() {
|
|
7
|
+
* return json({ ok: true });
|
|
8
|
+
* }
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
/** JSON response for a loader or action. */
|
|
12
|
+
export function json<T>(data: T, init: ResponseInit | number = {}): Response {
|
|
13
|
+
const options = typeof init === "number" ? { status: init } : init;
|
|
14
|
+
const headers = new Headers(options.headers);
|
|
15
|
+
if (!headers.has("content-type")) {
|
|
16
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
17
|
+
}
|
|
18
|
+
return new Response(JSON.stringify(data), { ...options, headers });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `data()` in react-router v7 — payload plus response init, no serialisation. */
|
|
22
|
+
export function data<T>(
|
|
23
|
+
value: T,
|
|
24
|
+
init: ResponseInit | number = {},
|
|
25
|
+
): { type: "DataWithResponseInit"; data: T; init: ResponseInit } {
|
|
26
|
+
return {
|
|
27
|
+
type: "DataWithResponseInit",
|
|
28
|
+
data: value,
|
|
29
|
+
init: typeof init === "number" ? { status: init } : init,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 302 (or given status) redirect response. */
|
|
34
|
+
export function redirect(
|
|
35
|
+
url: string,
|
|
36
|
+
init: ResponseInit | number = 302,
|
|
37
|
+
): Response {
|
|
38
|
+
const options = typeof init === "number" ? { status: init } : init;
|
|
39
|
+
const headers = new Headers(options.headers);
|
|
40
|
+
headers.set("location", url);
|
|
41
|
+
return new Response(null, {
|
|
42
|
+
...options,
|
|
43
|
+
status: options.status ?? 302,
|
|
44
|
+
headers,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 301 redirect. */
|
|
49
|
+
export function redirectDocument(url: string): Response {
|
|
50
|
+
const response = redirect(url, 302);
|
|
51
|
+
response.headers.set("x-remix-reload-document", "true");
|
|
52
|
+
return response;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function replace(
|
|
56
|
+
url: string,
|
|
57
|
+
init: ResponseInit | number = 302,
|
|
58
|
+
): Response {
|
|
59
|
+
const response = redirect(url, init);
|
|
60
|
+
response.headers.set("x-remix-replace", "true");
|
|
61
|
+
return response;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class ErrorResponse extends Error {
|
|
65
|
+
constructor(
|
|
66
|
+
readonly status: number,
|
|
67
|
+
readonly statusText: string,
|
|
68
|
+
readonly data: unknown,
|
|
69
|
+
) {
|
|
70
|
+
super(`${status} ${statusText}`);
|
|
71
|
+
this.name = "ErrorResponse";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isRouteErrorResponse(error: unknown): error is ErrorResponse {
|
|
76
|
+
return error instanceof ErrorResponse;
|
|
77
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server surface for react-router apps on moonshine: the loader/action
|
|
3
|
+
* response helpers plus moonshine's request handling.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
ErrorResponse,
|
|
7
|
+
data,
|
|
8
|
+
isRouteErrorResponse,
|
|
9
|
+
json,
|
|
10
|
+
redirect,
|
|
11
|
+
redirectDocument,
|
|
12
|
+
replace,
|
|
13
|
+
} from "./responses";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
createMoonshineServer,
|
|
17
|
+
definePage,
|
|
18
|
+
handleMoonshineRequest,
|
|
19
|
+
resolvePage,
|
|
20
|
+
toMoonshineRequest,
|
|
21
|
+
} from "@tschk/moonshine/server";
|
|
22
|
+
export type {
|
|
23
|
+
MoonshinePageModule,
|
|
24
|
+
MoonshineRequest,
|
|
25
|
+
MoonshineServer,
|
|
26
|
+
} from "@tschk/moonshine/server";
|