@tschk/moonshine-next 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
@@ -96,8 +96,15 @@ Behavioural notes where moonshine deliberately differs from Next:
96
96
  moonshine only scrolls when the pathname or hash actually changes, so
97
97
  query-only navigation (filters, pagination, tabs) keeps your scroll position.
98
98
  Pass `scroll` explicitly to force either behaviour.
99
- - **`router.refresh()` and `router.prefetch()` are no-ops.** Moonshine
100
- re-renders from its signal graph; there is no server round trip to invalidate.
99
+ - **`router.refresh()` reloads the browser document.** This requests fresh
100
+ server output and resets local component state. State-preserving RSC refresh
101
+ is unsupported. It is safe to call during SSR, where it does nothing.
102
+ - **`router.prefetch()` is a no-op.** Use `Link` for browser prefetch hints.
103
+ - **Browser navigation stays with the browser.** Links preserve external URLs,
104
+ non-self targets, fragments and downloads without route prefetch. External
105
+ `router.push` uses document assignment; external `replace` replaces the history
106
+ entry. Same-origin absolute URLs retain SPA navigation. The existing safe-href
107
+ policy prevents executable schemes from reaching document navigation.
101
108
  - **`redirect()` / `notFound()`** throw `RedirectError` / `NotFoundError`,
102
109
  exported from `./navigation` so your renderer can catch them.
103
110
  - **`useSearchParams()`** reads the live query string. The moonshine router
@@ -137,3 +144,8 @@ Does **not** export `MoonshineRouter` from the client entry — import it from
137
144
  `@tschk/moonshine/router` if you want the moonshine router directly.
138
145
 
139
146
  See [Moonshine README](../../README.md).
147
+
148
+ Request helpers require `requestContext: true` in Moonshine config (enabled by
149
+ Next adoption). Bun/Node deployment and preview inject the runner from the same
150
+ compiled bundle as the route modules; a separately imported context cannot scope
151
+ an already-bundled route. Edge request context is currently unsupported.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tschk/moonshine-next",
3
- "version": "0.4.0",
3
+ "version": "0.4.4",
4
4
  "description": "Next.js API surface reimplemented on moonshine — drop Next, keep the imports",
5
5
  "license": "ISC",
6
6
  "type": "module",
@@ -21,9 +21,10 @@
21
21
  "./shaders": "./src/shaders.ts"
22
22
  },
23
23
  "dependencies": {
24
- "@tschk/moonshine": "^0.3.6",
25
- "@tschk/moonshine-router": "^0.3.6",
26
- "@tschk/moonshine-shaders": "^0.3.6"
24
+ "@tschk/moonshine": "^0.4.4",
25
+ "@tschk/moonshine-router": "^0.4.4",
26
+ "@tschk/moonshine-shaders": "^0.4.4",
27
+ "@tschk/moonshine-framework": "^0.4.4"
27
28
  },
28
29
  "peerDependencies": {
29
30
  "react": ">=18",
@@ -43,5 +44,13 @@
43
44
  "lint": "bunx oxlint .",
44
45
  "typecheck": "bunx tsc --noEmit",
45
46
  "test": "bun test"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "react": {
50
+ "optional": true
51
+ },
52
+ "react-dom": {
53
+ "optional": true
54
+ }
46
55
  }
47
56
  }
package/src/headers.ts CHANGED
@@ -8,17 +8,11 @@
8
8
  * import { headers, cookies } from "@tschk/moonshine-next/headers";
9
9
  * ```
10
10
  */
11
- import { AsyncLocalStorage } from "node:async_hooks";
12
-
13
- const store = new AsyncLocalStorage<Request>();
14
-
15
- /** Runs `fn` with `request` visible to `headers()` and `cookies()`. */
16
- export function runWithRequest<T>(request: Request, fn: () => T): T {
17
- return store.run(request, fn);
18
- }
11
+ import { getRequest } from "@tschk/moonshine-framework/request";
12
+ export { runWithRequest } from "@tschk/moonshine-framework/request";
19
13
 
20
14
  function current(): Request {
21
- const request = store.getStore();
15
+ const request = getRequest();
22
16
  if (!request) {
23
17
  throw new Error(
24
18
  "headers() was called outside a request. Wrap the render in runWithRequest().",
@@ -40,7 +34,15 @@ function parseCookies(header: string | null): Map<string, string> {
40
34
  const index = part.indexOf("=");
41
35
  if (index === -1) continue;
42
36
  const name = part.slice(0, index).trim();
43
- if (name) jar.set(name, decodeURIComponent(part.slice(index + 1).trim()));
37
+ if (!name) continue;
38
+ const raw = part.slice(index + 1).trim();
39
+ let value = raw;
40
+ try {
41
+ value = decodeURIComponent(raw);
42
+ } catch {
43
+ // Cookie values may contain literal percent signs or invalid URI sequences.
44
+ }
45
+ jar.set(name, value);
44
46
  }
45
47
  return jar;
46
48
  }
package/src/link.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  type MouseEvent,
18
18
  type ReactNode,
19
19
  } from "react";
20
- import { isExternal, navigate } from "@tschk/moonshine/router";
20
+ import { isExternal, useNavigate } from "@tschk/moonshine/router";
21
21
 
22
22
  export type LinkProps = Omit<
23
23
  AnchorHTMLAttributes<HTMLAnchorElement>,
@@ -85,8 +85,9 @@ export default function Link({
85
85
  onMouseEnter,
86
86
  ...rest
87
87
  }: LinkProps): ReactNode {
88
+ const navigate = useNavigate();
88
89
  const url = hrefToString(href);
89
- const external = isExternal(url, rest.target);
90
+ const external = isExternal(url, rest.target, rest.download);
90
91
  const ref = useRef<HTMLAnchorElement>(null);
91
92
  // Navigation runs in a transition so a slow subtree cannot block the click.
92
93
  const [, startTransition] = useTransition();
package/src/navigation.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  * ```
9
9
  */
10
10
  import { useMemo } from "react";
11
+ import { safeHref } from "@tschk/moonshine-router";
11
12
  import {
12
- navigate,
13
+ useNavigate as useMoonshineNavigate,
13
14
  useLocation,
14
15
  useParams as useMoonshineParams,
15
16
  } from "@tschk/moonshine/router";
@@ -32,17 +33,9 @@ export function splitLocation(location: string): [string, string] {
32
33
  : [location.slice(0, index), location.slice(index + 1)];
33
34
  }
34
35
 
35
- /**
36
- * The location signal carries the query, so the signal is the source of truth
37
- * and a query-only navigation re-renders. The browser fallback remains for the
38
- * one case the signal cannot cover: a controlled `path` prop given without a
39
- * query while the address bar has one.
40
- */
36
+ /** The scoped location signal is the source of truth for query state. */
41
37
  function readSearch(location: string): string {
42
- const [, fromSignal] = splitLocation(location);
43
- if (fromSignal) return fromSignal;
44
- if (typeof window === "undefined") return "";
45
- return window.location.search.replace(/^\?/, "");
38
+ return splitLocation(location)[1];
46
39
  }
47
40
 
48
41
  function shouldScrollByDefault(href: string): boolean {
@@ -54,27 +47,39 @@ function shouldScrollByDefault(href: string): boolean {
54
47
  );
55
48
  }
56
49
 
50
+ function navigateExternal(href: string, replace: boolean): boolean {
51
+ if (typeof window === "undefined") return false;
52
+ const url = new URL(safeHref(href), window.location.href);
53
+ if (url.origin === window.location.origin) return false;
54
+ if (replace) window.location.replace(url.href);
55
+ else window.location.assign(url.href);
56
+ return true;
57
+ }
58
+
57
59
  export function useRouter(): AppRouterInstance {
60
+ const navigate = useMoonshineNavigate();
58
61
  return useMemo(
59
62
  () => ({
60
63
  push: (href, options) => {
64
+ if (navigateExternal(href, false)) return;
61
65
  const jump = options?.scroll ?? shouldScrollByDefault(href);
62
66
  navigate(href);
63
67
  if (jump) globalThis.scrollTo?.(0, 0);
64
68
  },
65
69
  replace: (href, options) => {
70
+ if (navigateExternal(href, true)) return;
66
71
  const jump = options?.scroll ?? shouldScrollByDefault(href);
67
72
  navigate(href, { replace: true });
68
73
  if (jump) globalThis.scrollTo?.(0, 0);
69
74
  },
70
75
  back: () => globalThis.history?.back(),
71
76
  forward: () => globalThis.history?.forward(),
72
- // Moonshine re-renders from its signal graph, so there is no server
73
- // round trip to invalidate.
74
- refresh: () => {},
77
+ // Reload server data through a document request; local component state resets.
78
+ // State-preserving RSC refresh is not supported.
79
+ refresh: () => globalThis.location?.reload(),
75
80
  prefetch: () => {},
76
81
  }),
77
- [],
82
+ [navigate],
78
83
  );
79
84
  }
80
85