@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.
Files changed (3) hide show
  1. package/README.md +47 -1
  2. package/package.json +4 -3
  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 (via `seroval`)
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.9.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
+ }