@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 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 JSX or `Response`
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 (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
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.8.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",
@@ -23,7 +23,7 @@ type Routes = Record<string, RouteHandler>;
23
23
  * serve({
24
24
  * routes: {
25
25
  * ...routes(config),
26
- * "/": () => html(<Home />),
26
+ * "/": () => html(() => <Home />),
27
27
  * },
28
28
  * });
29
29
  * ```
@@ -20,7 +20,7 @@ import {
20
20
  * import { routes } from "@valentinkolb/ssr/adapter/elysia";
21
21
  * new Elysia()
22
22
  * .use(routes(config))
23
- * .get("/", () => html(<Home />))
23
+ * .get("/", () => html(() => <Home />))
24
24
  * .listen(3000);
25
25
  * ```
26
26
  */
@@ -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 { JSX } from "solid-js";
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 - JSX element or Response (for redirects etc.) */
24
- type SsrHandlerResult = JSX.Element | Response | TypedResponse;
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 JSX directly or Response for redirects
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: JSX → html(), Response → passthrough
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
- // Otherwise treat as JSX element and render with html()
138
- return html(result as JSX.Element, c.get("page") as T);
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 HtmlFn<T extends object> = (element: JSX.Element, options?: T) => Promise<Response>;
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 (element, opts = {} as T) => {
155
- const body = renderToString(() => element);
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
+ }