@valentinkolb/ssr 0.9.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 +47 -1
- package/package.json +4 -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)
|
|
@@ -174,6 +178,48 @@ 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
|
+
|
|
177
223
|
## Rendering API
|
|
178
224
|
|
|
179
225
|
`html()` and Hono `ssr()` handlers expect a synchronous render function:
|
|
@@ -288,7 +334,7 @@ It can:
|
|
|
288
334
|
## Limitations
|
|
289
335
|
|
|
290
336
|
- islands must use default export
|
|
291
|
-
- 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
|
|
292
338
|
- nested island/client imports are not supported
|
|
293
339
|
|
|
294
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/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
|
+
}
|