@valentinkolb/ssr 0.8.0 → 0.10.0
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 +89 -4
- package/package.json +4 -3
- package/src/adapter/bun.ts +1 -1
- package/src/adapter/elysia.ts +1 -1
- package/src/adapter/hono.ts +16 -10
- package/src/index.ts +9 -3
- package/src/nav.ts +229 -0
package/README.md
CHANGED
|
@@ -42,12 +42,16 @@ What is intentionally not included:
|
|
|
42
42
|
- no build tool wrapper around Bun
|
|
43
43
|
|
|
44
44
|
Use the libraries you already prefer. This package only handles SSR and islands hydration.
|
|
45
|
+
The optional `@valentinkolb/ssr/nav` subpath provides progressive anchor
|
|
46
|
+
enhancement for islands, but it still does not add route matching, loaders, or
|
|
47
|
+
SPA routing.
|
|
45
48
|
|
|
46
49
|
## Features
|
|
47
50
|
|
|
48
51
|
- Small SSR core with Bun-native build/plugin flow
|
|
49
52
|
- Adapters for Bun, Hono, and Elysia
|
|
50
53
|
- Type-safe Hono page helper via `createSSRHandler`
|
|
54
|
+
- Optional progressive navigation helpers via `@valentinkolb/ssr/nav`
|
|
51
55
|
- Monorepo support via `rootDir`
|
|
52
56
|
- Public path mounting via `basePath` for microfrontends
|
|
53
57
|
- Stable file-path-based island IDs (collision-safe across workspace packages)
|
|
@@ -145,7 +149,7 @@ import Counter from "../components/Counter.island";
|
|
|
145
149
|
|
|
146
150
|
export default ssr(async (c) => {
|
|
147
151
|
c.get("page").title = "Home";
|
|
148
|
-
return <Counter initial={5} />;
|
|
152
|
+
return () => <Counter initial={5} />;
|
|
149
153
|
});
|
|
150
154
|
```
|
|
151
155
|
|
|
@@ -174,6 +178,87 @@ NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
|
|
|
174
178
|
- Hono: `@valentinkolb/ssr/hono`
|
|
175
179
|
- Elysia: `@valentinkolb/ssr/elysia`
|
|
176
180
|
|
|
181
|
+
## Optional Navigation Helpers
|
|
182
|
+
|
|
183
|
+
`@valentinkolb/ssr/nav` is an opt-in browser helper for islands that want to
|
|
184
|
+
update URL history after they have already updated client state.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
import { createSignal } from "solid-js";
|
|
188
|
+
import { Link, type LinkNavigateEvent } from "@valentinkolb/ssr/nav";
|
|
189
|
+
|
|
190
|
+
export default function Tabs() {
|
|
191
|
+
const [tab, setTab] = createSignal("alpha");
|
|
192
|
+
|
|
193
|
+
const openTab = (nav: LinkNavigateEvent) => {
|
|
194
|
+
const next = nav.url.searchParams.get("tab") ?? "alpha";
|
|
195
|
+
setTab(next);
|
|
196
|
+
nav.replaceWith(`/demo?tab=${next}`, { scroll: "preserve" });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<Link href="/demo?tab=beta" scroll="preserve" onNavigate={openTab}>
|
|
201
|
+
Open beta
|
|
202
|
+
</Link>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`Link` renders a real `<a href>` during SSR. Enhanced clicks only run in the
|
|
208
|
+
browser for same-origin, left-click navigation without modifier keys. Without
|
|
209
|
+
`onNavigate`, `Link` calls `navigate()` directly and only updates browser
|
|
210
|
+
history. With `onNavigate`, the island owns data loading and state updates, then
|
|
211
|
+
calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
|
|
212
|
+
|
|
213
|
+
Available exports:
|
|
214
|
+
|
|
215
|
+
- `Link`
|
|
216
|
+
- `navigate()`, `navigateTo()`, `documentNavigate()`, `refreshCurrentPath()`
|
|
217
|
+
- `captureScroll()`, `restoreScroll()`, `startViewTransition()`
|
|
218
|
+
- `LinkNavigateEvent`, `LinkProps`, `EnhancedNavigateOptions`, `NavigationScrollMode`, `ScrollSnapshot`
|
|
219
|
+
|
|
220
|
+
Use `data-scroll-preserve="stable-key"` on scroll containers that should keep
|
|
221
|
+
their scroll position across enhanced navigation.
|
|
222
|
+
|
|
223
|
+
## Rendering API
|
|
224
|
+
|
|
225
|
+
`html()` and Hono `ssr()` handlers expect a synchronous render function:
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
export default ssr(async (c) => {
|
|
229
|
+
const data = await loadData();
|
|
230
|
+
c.get("page").title = data.title;
|
|
231
|
+
|
|
232
|
+
return () => <Page data={data} />;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
app.get("/", () => html(() => <Page />));
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Do async work in the handler before returning the render function. Do not make the render function itself `async`; Solid SSR expects synchronous JSX evaluation.
|
|
239
|
+
|
|
240
|
+
### v0.9.0 migration
|
|
241
|
+
|
|
242
|
+
This is a breaking change in v0.9.0. In v0.8.x and earlier, examples often returned already-created JSX:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
// v0.8.x and earlier
|
|
246
|
+
export default ssr(async () => <Page />);
|
|
247
|
+
|
|
248
|
+
app.get("/", () => html(<Page />));
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
In v0.9.0, wrap JSX creation in a render function:
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
// v0.9.0+
|
|
255
|
+
export default ssr(async () => () => <Page />);
|
|
256
|
+
|
|
257
|
+
app.get("/", () => html(() => <Page />));
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
This ensures Solid primitives such as `createUniqueId()` run inside `renderToString()`, where the SSR context exists.
|
|
261
|
+
|
|
177
262
|
## `createConfig` options
|
|
178
263
|
|
|
179
264
|
```ts
|
|
@@ -206,7 +291,7 @@ export const { config, html } = createConfig({
|
|
|
206
291
|
// docs-app.ts
|
|
207
292
|
const docsApp = new Hono()
|
|
208
293
|
.route("/_ssr", routes(config))
|
|
209
|
-
.get("/", () => html(<DocsHome />));
|
|
294
|
+
.get("/", () => html(() => <DocsHome />));
|
|
210
295
|
|
|
211
296
|
// host-app.ts
|
|
212
297
|
export default new Hono().route("/docs", docsApp);
|
|
@@ -234,7 +319,7 @@ await Bun.build({
|
|
|
234
319
|
|
|
235
320
|
- initializes `c.get("page")` as typed page options
|
|
236
321
|
- accepts middlewares/validators before final handler
|
|
237
|
-
- lets handlers return either
|
|
322
|
+
- lets handlers return either a synchronous render function or `Response`
|
|
238
323
|
|
|
239
324
|
## Dev mode tools
|
|
240
325
|
|
|
@@ -249,7 +334,7 @@ It can:
|
|
|
249
334
|
## Limitations
|
|
250
335
|
|
|
251
336
|
- islands must use default export
|
|
252
|
-
- props must be serializable
|
|
337
|
+
- props must be serializable via `seroval`; do not pass functions, callbacks, event handlers, Solid signals/stores, DOM nodes, or class instances as island/client props
|
|
253
338
|
- nested island/client imports are not supported
|
|
254
339
|
|
|
255
340
|
## Local monorepo example
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Minimal SSR framework for SolidJS and Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./bun": "./src/adapter/bun.ts",
|
|
11
11
|
"./elysia": "./src/adapter/elysia.ts",
|
|
12
|
-
"./hono": "./src/adapter/hono.ts"
|
|
12
|
+
"./hono": "./src/adapter/hono.ts",
|
|
13
|
+
"./nav": "./src/nav.ts"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
15
16
|
"test": "bun test"
|
|
@@ -34,12 +35,12 @@
|
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@babel/core": "^7.24.0",
|
|
36
37
|
"@babel/preset-typescript": "^7.24.0",
|
|
38
|
+
"@types/babel__core": "^7.20.5",
|
|
37
39
|
"babel-preset-solid": "^1.8.0",
|
|
38
40
|
"seroval": "^1.0.0"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
43
|
"@elysiajs/static": "^1.2.0",
|
|
42
|
-
"@types/babel__core": "^7.20.5",
|
|
43
44
|
"@types/bun": "latest",
|
|
44
45
|
"elysia": "^1.2.0",
|
|
45
46
|
"hono": "^4.6.14",
|
package/src/adapter/bun.ts
CHANGED
package/src/adapter/elysia.ts
CHANGED
package/src/adapter/hono.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { createFactory } from "hono/factory";
|
|
7
7
|
import type { Context, Env, Handler, MiddlewareHandler, TypedResponse } from "hono";
|
|
8
|
-
import type {
|
|
9
|
-
import type { SsrConfig, HtmlFn } from "../index";
|
|
8
|
+
import type { SsrConfig, HtmlFn, RenderFn } from "../index";
|
|
10
9
|
import { getSsrDir, getCacheHeaders, createReloadResponse, safePath } from "./utils";
|
|
11
10
|
|
|
12
11
|
// ============================================================================
|
|
@@ -20,8 +19,8 @@ type PageEnv<T extends object> = {
|
|
|
20
19
|
};
|
|
21
20
|
};
|
|
22
21
|
|
|
23
|
-
/** SSR handler return type -
|
|
24
|
-
type SsrHandlerResult =
|
|
22
|
+
/** SSR handler return type - render function or Response (for redirects etc.) */
|
|
23
|
+
type SsrHandlerResult = RenderFn | Response | TypedResponse;
|
|
25
24
|
|
|
26
25
|
/** SSR handler function signature */
|
|
27
26
|
type SsrHandler<E extends Env, T extends object> = (
|
|
@@ -41,7 +40,7 @@ type SsrHandlers = [MiddlewareHandler, ...MiddlewareHandler[], Handler];
|
|
|
41
40
|
* Features:
|
|
42
41
|
* - Full compatibility with Hono middlewares/validators
|
|
43
42
|
* - Type-safe page options via `c.get("page")`
|
|
44
|
-
* - Return
|
|
43
|
+
* - Return a render function or Response for redirects
|
|
45
44
|
* - No manual `html()` call needed
|
|
46
45
|
*
|
|
47
46
|
* @example
|
|
@@ -88,7 +87,7 @@ type SsrHandlers = [MiddlewareHandler, ...MiddlewareHandler[], Handler];
|
|
|
88
87
|
* c.get("page").title = room.name;
|
|
89
88
|
* c.get("page").description = `Welcome to ${room.name}`;
|
|
90
89
|
*
|
|
91
|
-
* return <RoomView room={room} />;
|
|
90
|
+
* return () => <RoomView room={room} />;
|
|
92
91
|
* }
|
|
93
92
|
* );
|
|
94
93
|
* ```
|
|
@@ -125,7 +124,7 @@ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
|
|
|
125
124
|
await next();
|
|
126
125
|
});
|
|
127
126
|
|
|
128
|
-
// Wrapped handler:
|
|
127
|
+
// Wrapped handler: render function → html(), Response → passthrough
|
|
129
128
|
const wrappedHandler: Handler = async (c) => {
|
|
130
129
|
const result = await finalHandler(c as Context<E & PageEnv<T>>);
|
|
131
130
|
|
|
@@ -134,8 +133,15 @@ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
|
|
|
134
133
|
return result;
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
if (typeof result !== "function") {
|
|
137
|
+
throw new Error("[ssr] ssr() handlers must return a render function: return () => <Page />");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (result.constructor.name === "AsyncFunction") {
|
|
141
|
+
throw new Error("[ssr] ssr() render functions must be synchronous: return () => <Page />");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return html(result as RenderFn, c.get("page") as T);
|
|
139
145
|
};
|
|
140
146
|
|
|
141
147
|
// Return tuple for spread: .get('/path', ...ssr(handler))
|
|
@@ -155,7 +161,7 @@ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
|
|
|
155
161
|
* import { routes } from "@valentinkolb/ssr/adapter/hono";
|
|
156
162
|
* const app = new Hono()
|
|
157
163
|
* .route("/_ssr", routes(config))
|
|
158
|
-
* .get("/", () => html(<Home />));
|
|
164
|
+
* .get("/", () => html(() => <Home />));
|
|
159
165
|
* ```
|
|
160
166
|
*/
|
|
161
167
|
export const routes = (config: SsrConfig) => {
|
package/src/index.ts
CHANGED
|
@@ -68,7 +68,9 @@ export type SsrConfig = {
|
|
|
68
68
|
ssrPath: string;
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
export type
|
|
71
|
+
export type RenderFn = () => JSX.Element;
|
|
72
|
+
|
|
73
|
+
export type HtmlFn<T extends object> = (render: RenderFn, options?: T) => Promise<Response>;
|
|
72
74
|
|
|
73
75
|
type PluginFn = () => BunPlugin;
|
|
74
76
|
|
|
@@ -151,8 +153,12 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
151
153
|
const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath })}</script>`;
|
|
152
154
|
|
|
153
155
|
// HTML renderer
|
|
154
|
-
const html: HtmlFn<T> = async (
|
|
155
|
-
|
|
156
|
+
const html: HtmlFn<T> = async (render, opts = {} as T) => {
|
|
157
|
+
if (render.constructor.name === "AsyncFunction") {
|
|
158
|
+
throw new Error("[ssr] html() expects a synchronous render function: html(() => <Page />)");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const body = renderToString(render);
|
|
156
162
|
|
|
157
163
|
// Framework-injected assets
|
|
158
164
|
let scripts = `${islandDisplayStyle}\n${hydrationScript}`;
|
package/src/nav.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in browser navigation helpers for SSR islands.
|
|
3
|
+
*
|
|
4
|
+
* This is not a router. Links remain real anchors and apps decide whether an
|
|
5
|
+
* enhanced click can update client state before committing browser history.
|
|
6
|
+
*/
|
|
7
|
+
import type { JSX } from "solid-js";
|
|
8
|
+
import { createDynamic } from "solid-js/web";
|
|
9
|
+
|
|
10
|
+
type AnchorProps = JSX.AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
11
|
+
|
|
12
|
+
export type NavigationScrollMode = "top" | "preserve" | "manual";
|
|
13
|
+
|
|
14
|
+
export type ScrollSnapshot = {
|
|
15
|
+
window: { x: number; y: number };
|
|
16
|
+
regions: Array<{
|
|
17
|
+
key: string;
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type EnhancedNavigateOptions = {
|
|
24
|
+
replace?: boolean;
|
|
25
|
+
scroll?: NavigationScrollMode;
|
|
26
|
+
scrollSnapshot?: ScrollSnapshot;
|
|
27
|
+
viewTransition?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type LinkNavigateEvent = {
|
|
31
|
+
event: MouseEvent;
|
|
32
|
+
href: string;
|
|
33
|
+
url: URL;
|
|
34
|
+
replace: boolean;
|
|
35
|
+
scroll: NavigationScrollMode;
|
|
36
|
+
push: (href?: string, options?: EnhancedNavigateOptions) => void;
|
|
37
|
+
replaceWith: (href?: string, options?: Omit<EnhancedNavigateOptions, "replace">) => void;
|
|
38
|
+
fallback: (href?: string) => void;
|
|
39
|
+
scrollSnapshot: ScrollSnapshot;
|
|
40
|
+
captureScroll: (selector?: string) => ScrollSnapshot;
|
|
41
|
+
restoreScroll: typeof restoreScroll;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type LinkProps = Omit<AnchorProps, "href" | "onClick"> & {
|
|
45
|
+
href: string;
|
|
46
|
+
replace?: boolean;
|
|
47
|
+
scroll?: NavigationScrollMode;
|
|
48
|
+
onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
|
|
49
|
+
onNavigate?: (event: LinkNavigateEvent) => void | Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const SCROLL_PRESERVE_SELECTOR = "[data-scroll-preserve]";
|
|
53
|
+
|
|
54
|
+
type ViewTransitionDocument = Document & {
|
|
55
|
+
startViewTransition?: (callback: () => void | Promise<void>) => unknown;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns the current URL path + query, without the hash.
|
|
60
|
+
*/
|
|
61
|
+
export const currentPathWithQuery = (): string => {
|
|
62
|
+
const url = new URL(window.location.href);
|
|
63
|
+
return `${url.pathname}${url.search}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Navigates to the current path + query with a full document navigation.
|
|
68
|
+
*/
|
|
69
|
+
export const refreshCurrentPath = (): void => {
|
|
70
|
+
window.location.assign(currentPathWithQuery());
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Navigates with a full document navigation.
|
|
75
|
+
*/
|
|
76
|
+
export const navigateTo = (href: string): void => {
|
|
77
|
+
window.location.assign(href);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const startViewTransition = (callback: () => void | Promise<void>): void => {
|
|
81
|
+
const doc = document as ViewTransitionDocument;
|
|
82
|
+
if (!doc.startViewTransition) {
|
|
83
|
+
void callback();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
doc.startViewTransition(callback);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const restoreRegionScroll = (snapshot: ScrollSnapshot): void => {
|
|
90
|
+
for (const region of snapshot.regions) {
|
|
91
|
+
const selector = `[data-scroll-preserve="${CSS.escape(region.key)}"]`;
|
|
92
|
+
const el = document.querySelector<HTMLElement>(selector);
|
|
93
|
+
if (!el) continue;
|
|
94
|
+
el.scrollLeft = region.x;
|
|
95
|
+
el.scrollTop = region.y;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Captures window scroll and keyed `[data-scroll-preserve]` regions.
|
|
101
|
+
*/
|
|
102
|
+
export const captureScroll = (selector = SCROLL_PRESERVE_SELECTOR): ScrollSnapshot => ({
|
|
103
|
+
window: { x: window.scrollX, y: window.scrollY },
|
|
104
|
+
regions: Array.from(document.querySelectorAll<HTMLElement>(selector))
|
|
105
|
+
.map((el) => ({
|
|
106
|
+
key: el.dataset.scrollPreserve ?? "",
|
|
107
|
+
x: el.scrollLeft,
|
|
108
|
+
y: el.scrollTop,
|
|
109
|
+
}))
|
|
110
|
+
.filter((region) => region.key.length > 0),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Restores a captured scroll snapshot.
|
|
115
|
+
*/
|
|
116
|
+
export const restoreScroll = (snapshot: ScrollSnapshot, options: { window?: boolean } = {}): void => {
|
|
117
|
+
restoreRegionScroll(snapshot);
|
|
118
|
+
if (options.window === false) return;
|
|
119
|
+
window.scrollTo(snapshot.window.x, snapshot.window.y);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Updates browser history without a document reload.
|
|
124
|
+
*
|
|
125
|
+
* Use this after the current island has already updated its UI or when it is
|
|
126
|
+
* intentionally preserving the current DOM. It does not match routes, load
|
|
127
|
+
* data, or re-render server pages.
|
|
128
|
+
*/
|
|
129
|
+
export const navigate = (href: string, options: EnhancedNavigateOptions = {}): void => {
|
|
130
|
+
const scroll = options.scroll ?? "top";
|
|
131
|
+
const snapshot = scroll === "manual" ? null : (options.scrollSnapshot ?? captureScroll());
|
|
132
|
+
const url = new URL(href, window.location.href);
|
|
133
|
+
const target = `${url.pathname}${url.search}${url.hash}`;
|
|
134
|
+
|
|
135
|
+
const commit = () => {
|
|
136
|
+
if (options.replace) window.history.replaceState(null, "", target);
|
|
137
|
+
else window.history.pushState(null, "", target);
|
|
138
|
+
|
|
139
|
+
if (!snapshot) return;
|
|
140
|
+
restoreRegionScroll(snapshot);
|
|
141
|
+
if (scroll === "preserve") {
|
|
142
|
+
window.scrollTo(snapshot.window.x, snapshot.window.y);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
window.scrollTo(0, 0);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (options.viewTransition === false) {
|
|
149
|
+
commit();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
startViewTransition(commit);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const documentNavigate = (href: string, options: { replace?: boolean } = {}): void => {
|
|
156
|
+
if (options.replace) window.location.replace(href);
|
|
157
|
+
else window.location.assign(href);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const shouldEnhanceClick = (event: MouseEvent, anchor: HTMLAnchorElement): boolean => {
|
|
161
|
+
if (event.defaultPrevented || event.button !== 0) return false;
|
|
162
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
|
|
163
|
+
if (anchor.target && anchor.target !== "_self") return false;
|
|
164
|
+
if (anchor.hasAttribute("download")) return false;
|
|
165
|
+
|
|
166
|
+
const url = new URL(anchor.href, window.location.href);
|
|
167
|
+
return url.origin === window.location.origin;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const callUserClick = (handler: LinkProps["onClick"], event: MouseEvent, anchor: HTMLAnchorElement): void => {
|
|
171
|
+
if (!handler) return;
|
|
172
|
+
if (typeof handler === "function") {
|
|
173
|
+
handler(event as MouseEvent & { currentTarget: HTMLAnchorElement; target: Element });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
(handler as unknown as EventListenerObject).handleEvent(event);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* SSR-safe anchor with opt-in progressive navigation.
|
|
181
|
+
*/
|
|
182
|
+
export function Link(props: LinkProps) {
|
|
183
|
+
const anchorProps = () => {
|
|
184
|
+
const { href: _href, replace: _replace, scroll: _scroll, onNavigate: _onNavigate, onClick: _onClick, ...rest } = props;
|
|
185
|
+
return rest;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const handleClick: JSX.EventHandler<HTMLAnchorElement, MouseEvent> = (event) => {
|
|
189
|
+
callUserClick(props.onClick, event, event.currentTarget);
|
|
190
|
+
if (!shouldEnhanceClick(event, event.currentTarget)) return;
|
|
191
|
+
|
|
192
|
+
const href = props.href;
|
|
193
|
+
const url = new URL(href, window.location.href);
|
|
194
|
+
const scroll = props.scroll ?? "top";
|
|
195
|
+
const replace = Boolean(props.replace);
|
|
196
|
+
const scrollSnapshot = captureScroll();
|
|
197
|
+
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
|
|
200
|
+
if (!props.onNavigate) {
|
|
201
|
+
navigate(href, { replace, scroll, scrollSnapshot });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
startViewTransition(() =>
|
|
206
|
+
props.onNavigate!({
|
|
207
|
+
event,
|
|
208
|
+
href,
|
|
209
|
+
url,
|
|
210
|
+
replace,
|
|
211
|
+
scroll,
|
|
212
|
+
push: (nextHref = href, options = {}) =>
|
|
213
|
+
navigate(nextHref, { replace: false, scroll, scrollSnapshot, viewTransition: false, ...options }),
|
|
214
|
+
replaceWith: (nextHref = href, options = {}) =>
|
|
215
|
+
navigate(nextHref, { replace: true, scroll, scrollSnapshot, viewTransition: false, ...options }),
|
|
216
|
+
fallback: (nextHref = href) => documentNavigate(nextHref, { replace }),
|
|
217
|
+
scrollSnapshot,
|
|
218
|
+
captureScroll,
|
|
219
|
+
restoreScroll,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return createDynamic(() => "a", {
|
|
225
|
+
...anchorProps(),
|
|
226
|
+
href: props.href,
|
|
227
|
+
onClick: handleClick,
|
|
228
|
+
});
|
|
229
|
+
}
|