@valentinkolb/ssr 0.10.0 → 0.11.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
@@ -65,7 +65,7 @@ bun add @valentinkolb/ssr solid-js
65
65
  # choose adapter deps you need
66
66
  bun add hono
67
67
  # or
68
- bun add elysia @elysiajs/static
68
+ bun add elysia
69
69
  ```
70
70
 
71
71
  ## Required TypeScript settings
@@ -184,16 +184,24 @@ NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
184
184
  update URL history after they have already updated client state.
185
185
 
186
186
  ```tsx
187
- import { createSignal } from "solid-js";
188
- import { Link, type LinkNavigateEvent } from "@valentinkolb/ssr/nav";
187
+ import { createSignal, onCleanup, onMount } from "solid-js";
188
+ import { Link, listenPopState, type LinkNavigateEvent } from "@valentinkolb/ssr/nav";
189
189
 
190
190
  export default function Tabs() {
191
191
  const [tab, setTab] = createSignal("alpha");
192
192
 
193
+ onMount(() => {
194
+ onCleanup(
195
+ listenPopState(({ url }) => {
196
+ setTab(url.searchParams.get("tab") ?? "alpha");
197
+ }),
198
+ );
199
+ });
200
+
193
201
  const openTab = (nav: LinkNavigateEvent) => {
194
202
  const next = nav.url.searchParams.get("tab") ?? "alpha";
195
203
  setTab(next);
196
- nav.replaceWith(`/demo?tab=${next}`, { scroll: "preserve" });
204
+ nav.push(`/demo?tab=${next}`, { scroll: "preserve", state: { tab: next } });
197
205
  };
198
206
 
199
207
  return (
@@ -210,12 +218,28 @@ browser for same-origin, left-click navigation without modifier keys. Without
210
218
  history. With `onNavigate`, the island owns data loading and state updates, then
211
219
  calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
212
220
 
221
+ Use `listenPopState()` whenever `nav.push()` represents client state. Browser
222
+ Back/Forward changes history but cannot infer how an island maps the URL back to
223
+ signals or stores. The helper reports the current `URL`, native `PopStateEvent`,
224
+ and history state without adding route matching or data loading.
225
+
226
+ Navigation behavior:
227
+
228
+ - reactive anchor props remain reactive after `Link` renders
229
+ - same-document hash links retain native target scrolling unless `onNavigate`
230
+ or `scroll` explicitly takes ownership
231
+ - relative URLs follow `document.baseURI`
232
+ - cross-origin `navigate()` calls use full document navigation
233
+ - replace navigation preserves existing `history.state` unless `state` is set
234
+ - rejected async `onNavigate` callbacks log the error and fall back to a full
235
+ document navigation
236
+
213
237
  Available exports:
214
238
 
215
239
  - `Link`
216
240
  - `navigate()`, `navigateTo()`, `documentNavigate()`, `refreshCurrentPath()`
217
- - `captureScroll()`, `restoreScroll()`, `startViewTransition()`
218
- - `LinkNavigateEvent`, `LinkProps`, `EnhancedNavigateOptions`, `NavigationScrollMode`, `ScrollSnapshot`
241
+ - `captureScroll()`, `restoreScroll()`, `listenPopState()`, `startViewTransition()`
242
+ - `LinkNavigateEvent`, `LinkProps`, `EnhancedNavigateOptions`, `NavigationScrollMode`, `PopStateNavigationEvent`, `ScrollSnapshot`
219
243
 
220
244
  Use `data-scroll-preserve="stable-key"` on scroll containers that should keep
221
245
  their scroll position across enhanced navigation.
@@ -268,6 +292,7 @@ createConfig({
268
292
  rootDir?: string; // default: process.cwd()
269
293
  basePath?: string; // default: "", example: "/docs"
270
294
  external?: string[]; // passed to Bun.build for island bundle
295
+ devSourcemap?: "none" | "linked" | "inline"; // default: "linked"
271
296
  template?: ({ body, scripts, ...custom }) => string | Promise<string>;
272
297
  })
273
298
  ```
@@ -276,7 +301,9 @@ createConfig({
276
301
 
277
302
  - `rootDir` is important in monorepos where server entrypoint and island files live in different packages.
278
303
  - `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
304
+ - Development builds emit linked source maps by default. Use `"inline"` only when a tool requires embedded maps, or `"none"` to disable them.
279
305
  - In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
306
+ - All adapters stream island assets from `Bun.file`. Production assets and content-hashed development chunks are immutable; stable development entries and source maps use validators for inexpensive freshness checks.
280
307
 
281
308
  ## Microfrontend mount example
282
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/ssr",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Minimal SSR framework for SolidJS and Bun",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -13,39 +13,36 @@
13
13
  "./nav": "./src/nav.ts"
14
14
  },
15
15
  "scripts": {
16
- "test": "bun test"
16
+ "test": "bunx tsc -p test/tsconfig.json && bun test test/unit && bun test --conditions=browser --preload ./test/browser/setup.ts test/browser"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "solid-js": "^1.9.0",
20
20
  "elysia": "^1.0.0",
21
- "@elysiajs/static": "^1.0.0",
22
21
  "hono": "^4.0.0"
23
22
  },
24
23
  "peerDependenciesMeta": {
25
24
  "elysia": {
26
25
  "optional": true
27
26
  },
28
- "@elysiajs/static": {
29
- "optional": true
30
- },
31
27
  "hono": {
32
28
  "optional": true
33
29
  }
34
30
  },
35
31
  "dependencies": {
36
- "@babel/core": "^7.24.0",
37
- "@babel/preset-typescript": "^7.24.0",
32
+ "@babel/core": "^7.29.7",
33
+ "@babel/preset-typescript": "^7.29.7",
38
34
  "@types/babel__core": "^7.20.5",
39
- "babel-preset-solid": "^1.8.0",
40
- "seroval": "^1.0.0"
35
+ "babel-preset-solid": "^1.9.12",
36
+ "seroval": "^1.5.5"
41
37
  },
42
38
  "devDependencies": {
43
- "@elysiajs/static": "^1.2.0",
44
- "@types/bun": "latest",
45
- "elysia": "^1.2.0",
46
- "hono": "^4.6.14",
47
- "solid-js": "^1.9.0",
48
- "typescript": "^5.0.0"
39
+ "@happy-dom/global-registrator": "^20.10.6",
40
+ "@types/bun": "^1.3.14",
41
+ "elysia": "^1.4.29",
42
+ "file-type": "^21.3.1",
43
+ "hono": "^4.12.30",
44
+ "solid-js": "^1.9.14",
45
+ "typescript": "^5.9.3"
49
46
  },
50
47
  "license": "MIT",
51
48
  "author": "Valentin Kolb",
@@ -4,11 +4,9 @@
4
4
  */
5
5
  import type { SsrConfig } from "../index";
6
6
  import {
7
+ createAssetResponse,
7
8
  getSsrDir,
8
- getCacheHeaders,
9
9
  createReloadResponse,
10
- notFound,
11
- safePath,
12
10
  } from "./utils";
13
11
 
14
12
  type RouteHandler = (req: Request) => Response | Promise<Response>;
@@ -39,21 +37,14 @@ export const routes = (config: SsrConfig): Routes => {
39
37
  }
40
38
  : {};
41
39
 
40
+ const serveAsset: RouteHandler = (req) => {
41
+ const filename = new URL(req.url).pathname.split("/").pop()!;
42
+ return createAssetResponse(req, ssrDir, filename, dev);
43
+ };
44
+
42
45
  return {
43
46
  ...devRoutes,
44
-
45
- [`${ssrPath}/*.js`]: async (req) => {
46
- const filename = new URL(req.url).pathname.split("/").pop()!;
47
- const path = safePath(ssrDir, filename);
48
- if (!path) return notFound();
49
- const file = Bun.file(path);
50
- if (!(await file.exists())) return notFound();
51
- return new Response(file, {
52
- headers: {
53
- "Content-Type": file.type,
54
- "Cache-Control": getCacheHeaders(dev),
55
- },
56
- });
57
- },
47
+ [`${ssrPath}/*.js`]: serveAsset,
48
+ [`${ssrPath}/*.js.map`]: serveAsset,
58
49
  };
59
50
  };
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Elysia adapter - provides Elysia plugin with SSR routes.
3
- * Uses staticPlugin for serving island chunks.
3
+ * Uses the shared BunFile-backed asset response path.
4
4
  */
5
5
  import { Elysia } from "elysia";
6
- import { staticPlugin } from "@elysiajs/static";
7
6
  import type { SsrConfig } from "../index";
8
7
  import {
8
+ createAssetResponse,
9
9
  getSsrDir,
10
- getCacheHeaders,
11
10
  createReloadResponse,
12
11
  notFound,
13
12
  } from "./utils";
@@ -29,13 +28,9 @@ export const routes = (config: SsrConfig) => {
29
28
  const ssrDir = getSsrDir(config);
30
29
 
31
30
  return new Elysia({ name: "ssr" })
32
- .use(
33
- staticPlugin({
34
- assets: ssrDir,
35
- prefix: ssrPath,
36
- headers: { "Cache-Control": getCacheHeaders(dev) },
37
- }),
38
- )
39
31
  .get(`${ssrPath}/_reload`, () => (dev ? createReloadResponse() : notFound()))
40
- .get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()));
32
+ .get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()))
33
+ .get(`${ssrPath}/*`, ({ request, params }) =>
34
+ createAssetResponse(request, ssrDir, params["*"], dev),
35
+ );
41
36
  };
@@ -6,7 +6,7 @@ import { Hono } from "hono";
6
6
  import { createFactory } from "hono/factory";
7
7
  import type { Context, Env, Handler, MiddlewareHandler, TypedResponse } from "hono";
8
8
  import type { SsrConfig, HtmlFn, RenderFn } from "../index";
9
- import { getSsrDir, getCacheHeaders, createReloadResponse, safePath } from "./utils";
9
+ import { createAssetResponse, getSsrDir, createReloadResponse } from "./utils";
10
10
 
11
11
  // ============================================================================
12
12
  // Types
@@ -176,19 +176,10 @@ export const routes = (config: SsrConfig) => {
176
176
  app.get("/_ping", (c) => c.text("ok"));
177
177
  }
178
178
 
179
- // Serve island chunks
180
- app.get("/:filename{.+\\.js$}", async (c) => {
181
- const path = safePath(ssrDir, c.req.param("filename"));
182
- if (!path) return c.notFound();
183
- const file = Bun.file(path);
184
- if (!(await file.exists())) return c.notFound();
185
- return c.body(await file.arrayBuffer(), {
186
- headers: {
187
- "Content-Type": "application/javascript",
188
- "Cache-Control": getCacheHeaders(dev),
189
- },
190
- });
191
- });
179
+ const serveAsset = (c: Context) => createAssetResponse(c.req.raw, ssrDir, c.req.param("filename"), dev);
180
+
181
+ app.get("/:filename{.+\\.js$}", serveAsset);
182
+ app.get("/:filename{.+\\.js\\.map$}", serveAsset);
192
183
 
193
184
  return app;
194
185
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared utilities for SSR adapters - path helpers, cache headers,
3
- * SSE stream for hot reload, and security utilities.
2
+ * Shared utilities for SSR adapters - path helpers, asset responses,
3
+ * SSE live reload, and security utilities.
4
4
  */
5
5
  import { dirname, join, resolve } from "path";
6
6
  import type { SsrConfig } from "../index";
@@ -34,11 +34,78 @@ export const toSsrPath = (basePath: string): string =>
34
34
  export const getSsrDir = (config: SsrConfig): string =>
35
35
  join(config.dev ? config.rootDir ?? process.cwd() : dirname(Bun.main), "_ssr");
36
36
 
37
+ const HASHED_CHUNK = /^chunk-[a-z0-9]+\.js$/i;
38
+ const ASSET_FILE = /^[a-z0-9._-]+\.js(?:\.map)?$/i;
39
+
40
+ /**
41
+ * Stable entry names can change during development. Content-hashed chunks
42
+ * cannot, so the browser may retain them across page navigations.
43
+ */
44
+ export const getCacheHeaders = (dev: boolean, filename = "") =>
45
+ !dev || HASHED_CHUNK.test(filename) ? "public, max-age=31536000, immutable" : "no-cache";
46
+
47
+ const matchesEtag = (header: string, etag: string): boolean => {
48
+ const normalized = etag.replace(/^W\//, "");
49
+ return header.split(",").some((candidate) => {
50
+ const value = candidate.trim();
51
+ return value === "*" || value.replace(/^W\//, "") === normalized;
52
+ });
53
+ };
54
+
55
+ const isNotModified = (request: Request, etag: string, lastModified: number): boolean => {
56
+ const ifNoneMatch = request.headers.get("If-None-Match");
57
+ if (ifNoneMatch) return matchesEtag(ifNoneMatch, etag);
58
+
59
+ const ifModifiedSince = request.headers.get("If-Modified-Since");
60
+ if (!ifModifiedSince) return false;
61
+ const since = Date.parse(ifModifiedSince);
62
+ return Number.isFinite(since) && Math.floor(lastModified / 1000) <= Math.floor(since / 1000);
63
+ };
64
+
37
65
  /**
38
- * Cache headers for static assets
66
+ * Serves generated island assets without eagerly buffering their contents.
67
+ * Dev validators make stable entries cheap to revalidate while preserving
68
+ * immediate rebuild visibility.
39
69
  */
40
- export const getCacheHeaders = (dev: boolean) =>
41
- dev ? "no-cache" : "public, max-age=31536000, immutable";
70
+ export const createAssetResponse = async (
71
+ request: Request,
72
+ directory: string,
73
+ filename: string,
74
+ dev: boolean,
75
+ ): Promise<Response> => {
76
+ if (!ASSET_FILE.test(filename)) return notFound();
77
+ const path = safePath(directory, filename);
78
+ if (!path) return notFound();
79
+
80
+ const contentType = filename.endsWith(".map")
81
+ ? "application/json; charset=utf-8"
82
+ : "application/javascript";
83
+ const file = Bun.file(path, { type: contentType });
84
+ if (!(await file.exists())) return notFound();
85
+
86
+ const cacheControl = getCacheHeaders(dev, filename);
87
+ if (!dev) {
88
+ return new Response(file, {
89
+ headers: { "Content-Type": contentType, "Cache-Control": cacheControl },
90
+ });
91
+ }
92
+
93
+ const lastModified = file.lastModified;
94
+ const etag = `W/"${file.size.toString(16)}-${Math.trunc(lastModified).toString(16)}"`;
95
+ const validatorHeaders = {
96
+ "Cache-Control": cacheControl,
97
+ ETag: etag,
98
+ "Last-Modified": new Date(lastModified).toUTCString(),
99
+ };
100
+
101
+ if (isNotModified(request, etag, lastModified)) {
102
+ return new Response(null, { status: 304, headers: validatorHeaders });
103
+ }
104
+
105
+ return new Response(file, {
106
+ headers: { "Content-Type": contentType, ...validatorHeaders },
107
+ });
108
+ };
42
109
 
43
110
  /**
44
111
  * SSE headers for live reload
package/src/build.ts CHANGED
@@ -4,17 +4,35 @@
4
4
  */
5
5
  import { relative, resolve } from "path";
6
6
  import { Glob } from "bun";
7
+ import { unlink } from "fs/promises";
7
8
  import { transform } from "./transform";
8
9
  import { ISLAND_ID_LENGTH, islandIdFromFile, toStableKey } from "./island-id";
9
10
 
10
11
  type ComponentType = "island" | "client";
11
12
 
13
+ export type DevSourcemap = "none" | "linked" | "inline";
14
+
12
15
  const getComponentType = (path: string): ComponentType => (path.includes(".client.") ? "client" : "island");
13
16
 
14
17
  const getSelector = (type: ComponentType, id: string) =>
15
18
  type === "island" ? `solid-island[data-id="${id}"]` : `solid-client[data-id="${id}"]`;
16
19
 
17
20
  const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`);
21
+ const GENERATED_ASSET = /^(?:[a-f0-9]{12}|chunk-[a-z0-9]+)\.js(?:\.map)?$/i;
22
+
23
+ const removeStaleBuildAssets = async (outdir: string, outputs: readonly Bun.BuildArtifact[]): Promise<number> => {
24
+ const current = new Set(outputs.map((output) => resolve(output.path)));
25
+ let removed = 0;
26
+
27
+ for await (const file of new Glob("*.js*").scan({ cwd: outdir, absolute: true })) {
28
+ const filename = file.slice(file.lastIndexOf("/") + 1);
29
+ if (!GENERATED_ASSET.test(filename) || current.has(resolve(file))) continue;
30
+ await unlink(file);
31
+ removed += 1;
32
+ }
33
+
34
+ return removed;
35
+ };
18
36
 
19
37
  /**
20
38
  * Workaround for Bun bundler bug: duplicate export statements in shared chunks
@@ -73,9 +91,10 @@ export const buildIslands = async (options: {
73
91
  cwd: string;
74
92
  verbose: boolean;
75
93
  dev?: boolean;
94
+ devSourcemap?: DevSourcemap;
76
95
  external?: string[];
77
96
  }): Promise<void> => {
78
- const { pattern, outdir, cwd, verbose, dev = false, external } = options;
97
+ const { pattern, outdir, cwd, verbose, dev = false, devSourcemap = "linked", external } = options;
79
98
  const resolvedCwd = resolve(cwd);
80
99
 
81
100
  const totalStart = performance.now();
@@ -137,7 +156,7 @@ export const buildIslands = async (options: {
137
156
  external,
138
157
  minify: !dev,
139
158
  splitting: true,
140
- sourcemap: dev ? "inline" : "none",
159
+ sourcemap: dev ? devSourcemap : "none",
141
160
  plugins: [
142
161
  {
143
162
  name: "solid-islands",
@@ -181,10 +200,15 @@ export const buildIslands = async (options: {
181
200
  ],
182
201
  });
183
202
 
184
- if (result.success && !dev) {
185
- await dedupeSharedChunkExports(outdir, verbose);
186
- } else if (result.success && dev && verbose) {
187
- console.log("Skipped shared-chunk export rewrite in dev mode.");
203
+ if (result.success) {
204
+ const removed = await removeStaleBuildAssets(outdir, result.outputs);
205
+ if (verbose && removed > 0) console.log(`Removed ${removed} stale build asset(s).`);
206
+
207
+ if (!dev) {
208
+ await dedupeSharedChunkExports(outdir, verbose);
209
+ } else if (verbose) {
210
+ console.log("Skipped shared-chunk export rewrite in dev mode.");
211
+ }
188
212
  }
189
213
 
190
214
  if (verbose) {
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import type { JSX } from "solid-js";
9
9
  import type { BunPlugin } from "bun";
10
10
  import { statSync } from "fs";
11
11
  import { transform } from "./transform";
12
- import { buildIslands } from "./build";
12
+ import { buildIslands, type DevSourcemap } from "./build";
13
13
  import { join, dirname, resolve } from "path";
14
14
  import { resolveIslandImport } from "./island-resolve";
15
15
  import { normalizeBasePath, toSsrPath } from "./adapter/utils";
@@ -51,6 +51,8 @@ export type SsrOptions<T extends object = object> = {
51
51
  basePath?: string;
52
52
  /** Modules to exclude from the island bundle (passed to Bun.build) */
53
53
  external?: string[];
54
+ /** Development island sourcemaps (default: "linked") */
55
+ devSourcemap?: DevSourcemap;
54
56
  /** HTML template function (optional, has default) */
55
57
  template?: (
56
58
  ctx: {
@@ -112,7 +114,15 @@ export type SsrResult<T extends object> = {
112
114
  * ```
113
115
  */
114
116
  export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
115
- const { dev = false, verbose, external, template, rootDir: rootDirOption, basePath: basePathOption } = options;
117
+ const {
118
+ dev = false,
119
+ verbose,
120
+ external,
121
+ devSourcemap = "linked",
122
+ template,
123
+ rootDir: rootDirOption,
124
+ basePath: basePathOption,
125
+ } = options;
116
126
  const rootDir = resolve(rootDirOption ?? process.cwd());
117
127
  const basePath = normalizeBasePath(basePathOption);
118
128
  const ssrPath = toSsrPath(basePath);
@@ -200,6 +210,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
200
210
  cwd: rootDir,
201
211
  verbose: verbose ?? !dev,
202
212
  dev,
213
+ devSourcemap,
203
214
  external,
204
215
  });
205
216
  };
package/src/nav.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * This is not a router. Links remain real anchors and apps decide whether an
5
5
  * enhanced click can update client state before committing browser history.
6
6
  */
7
- import type { JSX } from "solid-js";
7
+ import { mergeProps, splitProps, type JSX } from "solid-js";
8
8
  import { createDynamic } from "solid-js/web";
9
9
 
10
10
  type AnchorProps = JSX.AnchorHTMLAttributes<HTMLAnchorElement>;
@@ -24,9 +24,16 @@ export type EnhancedNavigateOptions = {
24
24
  replace?: boolean;
25
25
  scroll?: NavigationScrollMode;
26
26
  scrollSnapshot?: ScrollSnapshot;
27
+ state?: unknown;
27
28
  viewTransition?: boolean;
28
29
  };
29
30
 
31
+ export type PopStateNavigationEvent = {
32
+ event: PopStateEvent;
33
+ url: URL;
34
+ state: unknown;
35
+ };
36
+
30
37
  export type LinkNavigateEvent = {
31
38
  event: MouseEvent;
32
39
  href: string;
@@ -119,6 +126,24 @@ export const restoreScroll = (snapshot: ScrollSnapshot, options: { window?: bool
119
126
  window.scrollTo(snapshot.window.x, snapshot.window.y);
120
127
  };
121
128
 
129
+ /**
130
+ * Subscribes to browser Back/Forward navigation.
131
+ *
132
+ * The application remains responsible for reconciling its island state with
133
+ * the URL. This helper intentionally does not perform route matching.
134
+ */
135
+ export const listenPopState = (handler: (navigation: PopStateNavigationEvent) => void): (() => void) => {
136
+ const listener = (event: PopStateEvent) => {
137
+ handler({ event, url: new URL(window.location.href), state: event.state });
138
+ };
139
+
140
+ window.addEventListener("popstate", listener);
141
+ return () => window.removeEventListener("popstate", listener);
142
+ };
143
+
144
+ const resolveNavigationUrl = (href: string): URL =>
145
+ new URL(href, document.baseURI || window.location.href);
146
+
122
147
  /**
123
148
  * Updates browser history without a document reload.
124
149
  *
@@ -127,14 +152,22 @@ export const restoreScroll = (snapshot: ScrollSnapshot, options: { window?: bool
127
152
  * data, or re-render server pages.
128
153
  */
129
154
  export const navigate = (href: string, options: EnhancedNavigateOptions = {}): void => {
155
+ const url = resolveNavigationUrl(href);
156
+ if (url.origin !== window.location.origin) {
157
+ documentNavigate(url.href, { replace: options.replace });
158
+ return;
159
+ }
160
+
130
161
  const scroll = options.scroll ?? "top";
131
162
  const snapshot = scroll === "manual" ? null : (options.scrollSnapshot ?? captureScroll());
132
- const url = new URL(href, window.location.href);
133
163
  const target = `${url.pathname}${url.search}${url.hash}`;
134
164
 
135
165
  const commit = () => {
136
- if (options.replace) window.history.replaceState(null, "", target);
137
- else window.history.pushState(null, "", target);
166
+ const hasExplicitState = Object.prototype.hasOwnProperty.call(options, "state");
167
+ const state = hasExplicitState ? options.state : options.replace ? window.history.state : null;
168
+
169
+ if (options.replace) window.history.replaceState(state, "", target);
170
+ else window.history.pushState(state, "", target);
138
171
 
139
172
  if (!snapshot) return;
140
173
  restoreRegionScroll(snapshot);
@@ -163,67 +196,113 @@ const shouldEnhanceClick = (event: MouseEvent, anchor: HTMLAnchorElement): boole
163
196
  if (anchor.target && anchor.target !== "_self") return false;
164
197
  if (anchor.hasAttribute("download")) return false;
165
198
 
166
- const url = new URL(anchor.href, window.location.href);
199
+ const url = new URL(anchor.href);
167
200
  return url.origin === window.location.origin;
168
201
  };
169
202
 
203
+ const isSameDocumentHash = (url: URL): boolean => {
204
+ const current = new URL(window.location.href);
205
+ return url.hash.length > 0 && url.pathname === current.pathname && url.search === current.search;
206
+ };
207
+
170
208
  const callUserClick = (handler: LinkProps["onClick"], event: MouseEvent, anchor: HTMLAnchorElement): void => {
171
209
  if (!handler) return;
172
- if (typeof handler === "function") {
173
- handler(event as MouseEvent & { currentTarget: HTMLAnchorElement; target: Element });
210
+ const typedEvent = event as MouseEvent & { currentTarget: HTMLAnchorElement; target: Element };
211
+
212
+ if (Array.isArray(handler)) {
213
+ handler[0].call(anchor, handler[1], typedEvent);
174
214
  return;
175
215
  }
176
- (handler as unknown as EventListenerObject).handleEvent(event);
216
+
217
+ if (typeof handler === "function") {
218
+ handler.call(anchor, typedEvent);
219
+ }
177
220
  };
178
221
 
179
222
  /**
180
223
  * SSR-safe anchor with opt-in progressive navigation.
181
224
  */
182
225
  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
- };
226
+ const [local, anchorProps] = splitProps(props, ["href", "replace", "scroll", "onNavigate", "onClick"]);
187
227
 
188
228
  const handleClick: JSX.EventHandler<HTMLAnchorElement, MouseEvent> = (event) => {
189
- callUserClick(props.onClick, event, event.currentTarget);
229
+ callUserClick(local.onClick, event, event.currentTarget);
190
230
  if (!shouldEnhanceClick(event, event.currentTarget)) return;
191
231
 
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);
232
+ const href = local.href;
233
+ const url = new URL(event.currentTarget.href);
234
+
235
+ // Preserve native target scrolling unless the application explicitly owns
236
+ // this hash navigation through onNavigate or a scroll option.
237
+ if (!local.onNavigate && local.scroll === undefined && isSameDocumentHash(url)) return;
238
+
239
+ const scroll = local.scroll ?? "top";
240
+ const replace = Boolean(local.replace);
196
241
  const scrollSnapshot = captureScroll();
197
242
 
198
243
  event.preventDefault();
199
244
 
200
- if (!props.onNavigate) {
201
- navigate(href, { replace, scroll, scrollSnapshot });
245
+ if (!local.onNavigate) {
246
+ navigate(url.href, { replace, scroll, scrollSnapshot });
202
247
  return;
203
248
  }
204
249
 
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
- );
250
+ let navigationOutcome: "none" | "history" | "document" = "none";
251
+ const runNavigation = async () => {
252
+ try {
253
+ await local.onNavigate!({
254
+ event,
255
+ href,
256
+ url,
257
+ replace,
258
+ scroll,
259
+ push: (nextHref = url.href, options = {}) => {
260
+ navigate(nextHref, {
261
+ ...options,
262
+ replace: false,
263
+ scroll: options.scroll ?? scroll,
264
+ scrollSnapshot: options.scrollSnapshot ?? scrollSnapshot,
265
+ viewTransition: false,
266
+ });
267
+ navigationOutcome = "history";
268
+ },
269
+ replaceWith: (nextHref = url.href, options = {}) => {
270
+ navigate(nextHref, {
271
+ ...options,
272
+ replace: true,
273
+ scroll: options.scroll ?? scroll,
274
+ scrollSnapshot: options.scrollSnapshot ?? scrollSnapshot,
275
+ viewTransition: false,
276
+ });
277
+ navigationOutcome = "history";
278
+ },
279
+ fallback: (nextHref = url.href) => {
280
+ documentNavigate(resolveNavigationUrl(nextHref).href, { replace });
281
+ navigationOutcome = "document";
282
+ },
283
+ scrollSnapshot,
284
+ captureScroll,
285
+ restoreScroll,
286
+ });
287
+ } catch (error) {
288
+ console.error("[@valentinkolb/ssr/nav] onNavigate failed; falling back to document navigation.", error);
289
+ if (navigationOutcome === "document") return;
290
+ const historyCommitted = navigationOutcome === "history";
291
+ const fallbackHref = historyCommitted ? window.location.href : url.href;
292
+ documentNavigate(fallbackHref, { replace: historyCommitted || replace });
293
+ }
294
+ };
295
+
296
+ startViewTransition(runNavigation);
222
297
  };
223
298
 
224
- return createDynamic(() => "a", {
225
- ...anchorProps(),
226
- href: props.href,
227
- onClick: handleClick,
228
- });
299
+ return createDynamic(
300
+ () => "a",
301
+ mergeProps(anchorProps, {
302
+ get href() {
303
+ return local.href;
304
+ },
305
+ onClick: handleClick,
306
+ }),
307
+ );
229
308
  }
package/src/transform.ts CHANGED
@@ -122,6 +122,10 @@ const componentWrapperPlugin = (filename: string, rootDir: string, dev: boolean)
122
122
  path.skip();
123
123
  },
124
124
  });
125
+
126
+ // Client wrappers remove their original JSX usage. Refresh bindings so
127
+ // later presets can discard imports that became unused in this pass.
128
+ programPath.scope.crawl();
125
129
  },
126
130
  },
127
131
  };
@@ -138,19 +142,10 @@ export const transform = async (
138
142
  dev: boolean = false,
139
143
  rootDir: string = process.cwd(),
140
144
  ): Promise<string> => {
141
- let code = source;
142
-
143
- if (mode === "ssr") {
144
- const result = await transformAsync(code, {
145
- filename,
146
- parserOpts: { plugins: ["jsx", "typescript"] },
147
- plugins: [() => componentWrapperPlugin(filename, rootDir, dev)],
148
- });
149
- code = result?.code || code;
150
- }
151
-
152
- const result = await transformAsync(code, {
145
+ const result = await transformAsync(source, {
153
146
  filename,
147
+ parserOpts: mode === "ssr" ? { plugins: ["jsx", "typescript"] } : undefined,
148
+ plugins: mode === "ssr" ? [() => componentWrapperPlugin(filename, rootDir, dev)] : [],
154
149
  presets: [
155
150
  [tsPreset, {}],
156
151
  [solidPreset, { generate: mode, hydratable: false }],