@k2b/ssr 0.13.1 → 0.15.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
@@ -55,10 +55,11 @@ SPA routing.
55
55
  - Monorepo support via `rootDir`
56
56
  - Public path mounting via `basePath` for microfrontends
57
57
  - Stable file-path-based island IDs (collision-safe across workspace packages)
58
- - Production chunk cache busting (`/_ssr/*.js?v=<buildTimestamp>`)
58
+ - Production module cache busting (`/_ssr/<buildTimestamp>/*.js`)
59
59
  - Linked development source maps and validator-aware asset delivery
60
60
  - Stale generated island assets removed after successful builds
61
61
  - Visibility-aware development reload with cross-tab SSE coordination
62
+ - Default error boundary per island/client instance with a retry fallback
62
63
 
63
64
  ## Install
64
65
 
@@ -233,9 +234,9 @@ export default function Tabs() {
233
234
 
234
235
  `Link` renders a real `<a href>` during SSR. Enhanced clicks only run in the
235
236
  browser for same-origin, left-click navigation without modifier keys. Without
236
- `onNavigate`, `Link` calls `navigate()` directly and only updates browser
237
- history. With `onNavigate`, the island owns data loading and state updates, then
238
- calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
237
+ `onNavigate`, the anchor keeps native document navigation and ignores `replace`
238
+ and `scroll`. With `onNavigate`, the island owns data loading and state updates,
239
+ then calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
239
240
 
240
241
  Use `listenPopState()` whenever `nav.push()` represents client state. Browser
241
242
  Back/Forward changes history but cannot infer how an island maps the URL back to
@@ -246,7 +247,7 @@ Navigation behavior:
246
247
 
247
248
  - reactive anchor props remain reactive after `Link` renders
248
249
  - same-document hash links retain native target scrolling unless `onNavigate`
249
- or `scroll` explicitly takes ownership
250
+ explicitly takes ownership
250
251
  - relative URLs follow `document.baseURI`
251
252
  - cross-origin `navigate()` calls use full document navigation
252
253
  - replace navigation preserves existing `history.state` unless `state` is set
@@ -263,6 +264,72 @@ Available exports:
263
264
  Use `data-scroll-preserve="stable-key"` on scroll containers that should keep
264
265
  their scroll position across enhanced navigation.
265
266
 
267
+ ## Error handling in islands
268
+
269
+ Every island and client component instance is mounted inside its own error
270
+ boundary. No setup is needed. If a component throws while it mounts, while its
271
+ props are deserialized, or later in a reactive update, only that instance is
272
+ replaced with a fallback. Other instances and other islands keep working, also
273
+ when they are updated by the same signal write.
274
+
275
+ The default fallback is plain, unstyled markup:
276
+
277
+ ```html
278
+ <div role="alert" data-ssr-error>
279
+ This part of the page could not be displayed. <button type="button">Try again</button>
280
+ </div>
281
+ ```
282
+
283
+ **Try again** remounts the component with fresh state from the same
284
+ `data-props`. Style the fallback through `[data-ssr-error]`.
285
+
286
+ Each caught error dispatches a bubbling, cancelable `ssr:island-error` event on
287
+ the `<solid-island>` or `<solid-client>` element. Its `detail` contains
288
+ `error`, the component `id`, and `reset()`. Unless a listener calls
289
+ `preventDefault()`, the error is then passed to `reportError()`, so it appears in
290
+ the console and in the window `error` event like an uncaught error. Call
291
+ `preventDefault()` only when you report the error yourself; the fallback is
292
+ still shown.
293
+
294
+ ```ts
295
+ addEventListener("ssr:island-error", (event) => {
296
+ if (!(event instanceof CustomEvent)) return;
297
+ const { error, id } = event.detail;
298
+ myReporter.capture(error, { island: id });
299
+ event.preventDefault();
300
+ });
301
+ ```
302
+
303
+ To replace the default fallback, for example to localize it, set
304
+ `errorFallback` to a module whose default export receives `{ error, reset }`:
305
+
306
+ ```tsx
307
+ // src/IslandError.tsx
308
+ import type { IslandErrorProps } from "@k2b/ssr";
309
+
310
+ export default function IslandError(props: IslandErrorProps) {
311
+ return (
312
+ <p role="alert" class="island-error">
313
+ Dieser Bereich konnte nicht angezeigt werden.
314
+ <button type="button" onClick={props.reset}>Erneut versuchen</button>
315
+ </p>
316
+ );
317
+ }
318
+ ```
319
+
320
+ ```ts
321
+ createConfig({ errorFallback: "./src/IslandError.tsx" });
322
+ ```
323
+
324
+ The option is optional and only changes presentation. If the custom fallback
325
+ throws, the default fallback is shown and its error is reported too.
326
+
327
+ Error boundaries inside your components are closer to the error and still take
328
+ precedence. Errors thrown directly in event handlers or in async code outside a
329
+ Solid computation stay ordinary uncaught browser errors. Errors in reactive
330
+ updates no longer propagate to the code that wrote the signal; that code now
331
+ sees the island fallback instead.
332
+
266
333
  ## Rendering API
267
334
 
268
335
  `html()` and Hono `ssr()` handlers expect a synchronous render function:
@@ -313,6 +380,7 @@ createConfig({
313
380
  basePath?: string; // default: "", example: "/docs"
314
381
  external?: string[]; // passed to Bun.build for island bundle
315
382
  devSourcemap?: "none" | "linked" | "inline"; // default: "linked"
383
+ errorFallback?: string; // optional island error fallback module, relative to rootDir
316
384
  template?: ({ body, scripts, ...custom }) => string | Promise<string>;
317
385
  })
318
386
  ```
@@ -332,10 +400,12 @@ createConfig({
332
400
  ```
333
401
 
334
402
  Use the same configuration in development and production. Files outside `rootDir` retain the existing canonical absolute-path ID fallback; moving an external package can change its IDs. SSR wrappers and browser assets must come from the same build.
403
+ - `errorFallback` replaces the presentation of the default island error boundary. Islands are protected without it; see [Error handling in islands](#error-handling-in-islands).
335
404
  - `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
336
405
  - Development builds emit linked source maps by default. Use `"inline"` only when a tool requires embedded maps, or `"none"` to disable them.
337
- - In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
338
- - 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.
406
+ - In production, all modules share a build timestamp directory (`/_ssr/<version>/<id>.js`). Relative lazy imports inherit that directory, so each module has one URL. Files stay flat on disk; adapters serve only the current build version.
407
+ - All adapters stream island assets from `Bun.file`. Production assets under the versioned path and content-hashed development chunks are immutable; stable development entries and source maps use validators for inexpensive freshness checks.
408
+ - Production adapters serve adjacent `.br` or `.gz` files when accepted by the request, preserving the original MIME type and varying caches by `Accept-Encoding`. Generate these siblings in the application build; the adapter does not compress responses at runtime. Development always serves the original file to avoid stale compressed copies.
339
409
 
340
410
  ## Microfrontend mount example
341
411
 
@@ -360,6 +430,12 @@ With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/
360
430
 
361
431
  ## Build for production
362
432
 
433
+ Set the environment before starting Bun so both the build configuration and bundled code use production mode:
434
+
435
+ ```bash
436
+ NODE_ENV=production bun scripts/build.ts
437
+ ```
438
+
363
439
  ```ts
364
440
  // scripts/build.ts
365
441
  import { plugin } from "./config";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/ssr",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Minimal SSR framework for SolidJS and Bun",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@happy-dom/global-registrator": "^20.10.6",
40
- "@types/bun": "^1.3.14",
40
+ "@types/bun": "1.4.2",
41
41
  "elysia": "^1.4.29",
42
42
  "file-type": "^21.3.1",
43
43
  "hono": "^4.12.30",
@@ -5,6 +5,7 @@
5
5
  import type { SsrConfig } from "../index";
6
6
  import {
7
7
  createAssetResponse,
8
+ getAssetPrefix,
8
9
  createPingResponse,
9
10
  getSsrDir,
10
11
  createReloadResponse,
@@ -30,6 +31,7 @@ type Routes = Record<string, RouteHandler>;
30
31
  export const routes = (config: SsrConfig): Routes => {
31
32
  const { dev, ssrPath } = config;
32
33
  const ssrDir = getSsrDir(config);
34
+ const assetPath = ssrPath + getAssetPrefix(dev);
33
35
 
34
36
  const devRoutes: Routes = dev
35
37
  ? {
@@ -39,13 +41,13 @@ export const routes = (config: SsrConfig): Routes => {
39
41
  : {};
40
42
 
41
43
  const serveAsset: RouteHandler = (req) => {
42
- const filename = new URL(req.url).pathname.split("/").pop()!;
44
+ const filename = new URL(req.url).pathname.slice(assetPath.length + 1);
43
45
  return createAssetResponse(req, ssrDir, filename, dev);
44
46
  };
45
47
 
46
48
  return {
47
49
  ...devRoutes,
48
- [`${ssrPath}/*.js`]: serveAsset,
49
- [`${ssrPath}/*.js.map`]: serveAsset,
50
+ [`${assetPath}/*.js`]: serveAsset,
51
+ [`${assetPath}/*.js.map`]: serveAsset,
50
52
  };
51
53
  };
@@ -6,6 +6,7 @@ import { Elysia } from "elysia";
6
6
  import type { SsrConfig } from "../index";
7
7
  import {
8
8
  createAssetResponse,
9
+ getAssetPrefix,
9
10
  createPingResponse,
10
11
  getSsrDir,
11
12
  createReloadResponse,
@@ -27,13 +28,14 @@ import {
27
28
  export const routes = (config: SsrConfig) => {
28
29
  const { dev, ssrPath } = config;
29
30
  const ssrDir = getSsrDir(config);
31
+ const assetPath = ssrPath + getAssetPrefix(dev);
30
32
 
31
33
  return new Elysia({ name: "ssr" })
32
34
  .get(`${ssrPath}/_reload`, ({ request }) =>
33
35
  dev ? createReloadResponse(request.signal) : notFound(),
34
36
  )
35
37
  .get(`${ssrPath}/_ping`, () => (dev ? createPingResponse() : notFound()))
36
- .get(`${ssrPath}/*`, ({ request, params }) =>
38
+ .get(`${assetPath}/*`, ({ request, params }) =>
37
39
  createAssetResponse(request, ssrDir, params["*"], dev),
38
40
  );
39
41
  };
@@ -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 { createAssetResponse, createPingResponse, getSsrDir, createReloadResponse } from "./utils";
9
+ import { createAssetResponse, getAssetPrefix, createPingResponse, getSsrDir, createReloadResponse } from "./utils";
10
10
 
11
11
  // ============================================================================
12
12
  // Types
@@ -183,8 +183,9 @@ export const routes = (config: SsrConfig) => {
183
183
  return createAssetResponse(c.req.raw, ssrDir, filename, dev);
184
184
  };
185
185
 
186
- app.get("/:filename{.+\\.js$}", serveAsset);
187
- app.get("/:filename{.+\\.js\\.map$}", serveAsset);
186
+ const prefix = getAssetPrefix(dev);
187
+ app.get(`${prefix}/:filename{.+\\.js$}`, serveAsset);
188
+ app.get(`${prefix}/:filename{.+\\.js\\.map$}`, serveAsset);
188
189
 
189
190
  return app;
190
191
  };
@@ -3,6 +3,7 @@
3
3
  * SSE live reload, and security utilities.
4
4
  */
5
5
  import { dirname, join, resolve } from "path";
6
+ import { statSync } from "fs";
6
7
  import type { SsrConfig } from "../index";
7
8
 
8
9
  /**
@@ -34,9 +35,36 @@ export const toSsrPath = (basePath: string): string =>
34
35
  export const getSsrDir = (config: SsrConfig): string =>
35
36
  join(config.dev ? config.rootDir ?? process.cwd() : dirname(Bun.main), "_ssr");
36
37
 
38
+ // Keep one version for this server process, including when Bun.main has no mtime.
39
+ const buildVersion = (() => {
40
+ try {
41
+ return String(Math.floor(statSync(Bun.main).mtimeMs));
42
+ } catch {
43
+ return String(Date.now());
44
+ }
45
+ })();
46
+
47
+ /** Relative imports inherit a versioned directory, unlike a query string. */
48
+ export const getAssetPrefix = (dev: boolean): string => dev ? "" : `/${buildVersion}`;
49
+
37
50
  const HASHED_CHUNK = /^chunk-[a-z0-9]+\.js$/i;
38
51
  const ASSET_FILE = /^[a-z0-9._-]+\.js(?:\.map)?$/i;
39
52
 
53
+ const acceptedEncodings = (header: string | null): Array<"br" | "gzip" | "identity"> => {
54
+ const qualities = new Map<string, number>();
55
+ for (const part of header?.split(",") ?? []) {
56
+ const [name, ...parameters] = part.trim().toLowerCase().split(";");
57
+ if (!name) continue;
58
+ const q = parameters.map((parameter) => parameter.trim()).find((parameter) => parameter.startsWith("q="));
59
+ const quality = q === undefined ? 1 : Number(q.slice(2));
60
+ qualities.set(name, Number.isFinite(quality) && quality >= 0 && quality <= 1 ? quality : 0);
61
+ }
62
+ const quality = (encoding: string) => qualities.get(encoding) ??
63
+ (encoding === "identity" ? (qualities.get("*") === 0 ? 0 : 1) : qualities.get("*") ?? 0);
64
+ return (["br", "gzip", "identity"] as const).filter((encoding) => quality(encoding) > 0)
65
+ .sort((left, right) => quality(right) - quality(left));
66
+ };
67
+
40
68
  /**
41
69
  * Stable entry names can change during development. Content-hashed chunks
42
70
  * cannot, so the browser may retain them across page navigations.
@@ -85,9 +113,19 @@ export const createAssetResponse = async (
85
113
 
86
114
  const cacheControl = getCacheHeaders(dev, filename);
87
115
  if (!dev) {
88
- return new Response(file, {
89
- headers: { "Content-Type": contentType, "Cache-Control": cacheControl },
90
- });
116
+ for (const encoding of acceptedEncodings(request.headers.get("Accept-Encoding"))) {
117
+ const selected = encoding === "identity" ? file : Bun.file(`${path}${encoding === "br" ? ".br" : ".gz"}`);
118
+ if (!(await selected.exists())) continue;
119
+ const headers = new Headers({
120
+ "Content-Type": contentType,
121
+ "Content-Length": String(selected.size),
122
+ "Cache-Control": cacheControl,
123
+ Vary: "Accept-Encoding",
124
+ });
125
+ if (encoding !== "identity") headers.set("Content-Encoding", encoding);
126
+ return new Response(request.method === "HEAD" ? null : selected, { headers });
127
+ }
128
+ return new Response(null, { status: 406, headers: { Vary: "Accept-Encoding" } });
91
129
  }
92
130
 
93
131
  const lastModified = file.lastModified;
@@ -102,7 +140,7 @@ export const createAssetResponse = async (
102
140
  return new Response(null, { status: 304, headers: validatorHeaders });
103
141
  }
104
142
 
105
- return new Response(file, {
143
+ return new Response(request.method === "HEAD" ? null : file, {
106
144
  headers: { "Content-Type": contentType, ...validatorHeaders },
107
145
  });
108
146
  };
package/src/build.ts CHANGED
@@ -95,8 +95,9 @@ export const buildIslands = async (options: {
95
95
  dev?: boolean;
96
96
  devSourcemap?: DevSourcemap;
97
97
  external?: string[];
98
+ errorFallback?: string;
98
99
  }): Promise<void> => {
99
- const { pattern, outdir, cwd, componentRoots, verbose, dev = false, devSourcemap = "linked", external } = options;
100
+ const { pattern, outdir, cwd, componentRoots, verbose, dev = false, devSourcemap = "linked", external, errorFallback } = options;
100
101
  const resolvedCwd = resolve(cwd);
101
102
 
102
103
  const totalStart = performance.now();
@@ -200,7 +201,7 @@ export const buildIslands = async (options: {
200
201
  }
201
202
 
202
203
  return {
203
- contents: `import{render,createComponent}from"solid-js/web";import{deserialize}from"seroval";import C from"${component.path}";document.querySelectorAll('${component.selector}').forEach(e=>{e.innerHTML="";render(()=>createComponent(C,deserialize(e.dataset.props||"{}")),e)})`,
204
+ contents: `import{mount}from${JSON.stringify(join(import.meta.dir, "mount.ts"))};import C from${JSON.stringify(component.path)};${errorFallback ? `import F from${JSON.stringify(errorFallback)};` : ""}mount(C,${JSON.stringify(component.selector)}${errorFallback ? ",F" : ""})`,
204
205
  loader: "js",
205
206
  };
206
207
  });
package/src/index.ts CHANGED
@@ -7,15 +7,16 @@
7
7
  import { renderToString } from "solid-js/web";
8
8
  import type { JSX } from "solid-js";
9
9
  import type { BunPlugin } from "bun";
10
- import { statSync } from "fs";
11
10
  import { transform } from "./transform";
12
11
  import { buildIslands, type DevSourcemap } from "./build";
13
12
  import { join, dirname, resolve } from "path";
14
13
  import { resolveIslandImport } from "./island-resolve";
15
- import { getReloadId, normalizeBasePath, toSsrPath } from "./adapter/utils";
14
+ import { getAssetPrefix, getReloadId, normalizeBasePath, toSsrPath } from "./adapter/utils";
16
15
  // @ts-ignore - Bun text import
17
16
  import devClientCode from "./adapter/client.js" with { type: "text" };
18
17
 
18
+ export type { IslandErrorProps } from "./mount";
19
+
19
20
  // ============================================================================
20
21
  // Constants
21
22
  // ============================================================================
@@ -23,19 +24,6 @@ import devClientCode from "./adapter/client.js" with { type: "text" };
23
24
  /** Glob pattern for island/client component files */
24
25
  const COMPONENT_PATTERN = "**/*.{island,client}.tsx";
25
26
 
26
- /**
27
- * Build version used for cache busting island script imports in production.
28
- * Uses server entrypoint mtime as a stable per-build version value.
29
- */
30
- const getBuildVersion = (dev: boolean): string => {
31
- if (dev) return "";
32
- try {
33
- return String(Math.floor(statSync(Bun.main).mtimeMs));
34
- } catch {
35
- return String(Date.now());
36
- }
37
- };
38
-
39
27
  // ============================================================================
40
28
  // Types
41
29
  // ============================================================================
@@ -55,6 +43,8 @@ export type SsrOptions<T extends object = object> = {
55
43
  external?: string[];
56
44
  /** Development island sourcemaps (default: "linked") */
57
45
  devSourcemap?: DevSourcemap;
46
+ /** Path relative to rootDir (or absolute) to a default-exported Solid component receiving IslandErrorProps. */
47
+ errorFallback?: string;
58
48
  /** HTML template function (optional, has default) */
59
49
  template?: (
60
50
  ctx: {
@@ -121,6 +111,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
121
111
  verbose,
122
112
  external,
123
113
  devSourcemap = "linked",
114
+ errorFallback,
124
115
  template,
125
116
  rootDir: rootDirOption,
126
117
  componentRoots,
@@ -156,13 +147,11 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
156
147
  ssrPath,
157
148
  };
158
149
 
159
- const buildVersion = getBuildVersion(dev);
160
-
161
150
  const islandDisplayStyle =
162
151
  "<style>solid-client,solid-island{display:contents}</style>";
163
152
 
164
153
  // Hydration script - dynamically loads island/client bundles based on DOM
165
- const hydrationScript = `<script type="module">const p=${JSON.stringify(ssrPath)};const v=${JSON.stringify(buildVersion)};document.querySelectorAll('solid-island,solid-client').forEach(e=>import(p+'/'+e.dataset.id+'.js'+(v?'?v='+v:'')));</script>`;
154
+ const hydrationScript = `<script type="module">const p=${JSON.stringify(ssrPath + getAssetPrefix(dev))};document.querySelectorAll('solid-island,solid-client').forEach(e=>import(p+'/'+e.dataset.id+'.js'));</script>`;
166
155
  const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath, reloadId: getReloadId() })}</script>`;
167
156
 
168
157
  // HTML renderer
@@ -216,6 +205,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
216
205
  dev,
217
206
  devSourcemap,
218
207
  external,
208
+ errorFallback: errorFallback ? resolve(rootDir, errorFallback) : undefined,
219
209
  });
220
210
  };
221
211
 
package/src/mount.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { Component } from "solid-js";
2
+ import { createComponent, ErrorBoundary, render } from "solid-js/web";
3
+ import { deserialize } from "seroval";
4
+
5
+ /** Props received by a configured island error fallback component. */
6
+ export type IslandErrorProps = {
7
+ error: unknown;
8
+ reset: () => void;
9
+ };
10
+
11
+ const defaultFallback = (reset: () => void): HTMLDivElement => {
12
+ const alert = document.createElement("div");
13
+ alert.setAttribute("role", "alert");
14
+ alert.setAttribute("data-ssr-error", "");
15
+ alert.append("This part of the page could not be displayed. ");
16
+ const button = document.createElement("button");
17
+ button.type = "button";
18
+ button.textContent = "Try again";
19
+ button.addEventListener("click", reset);
20
+ alert.append(button);
21
+ return alert;
22
+ };
23
+
24
+ const report = (element: HTMLElement, error: unknown, reset: () => void): void => {
25
+ const event = new CustomEvent("ssr:island-error", {
26
+ bubbles: true,
27
+ cancelable: true,
28
+ detail: { error, id: element.dataset.id, reset },
29
+ });
30
+ if (element.dispatchEvent(event)) globalThis.reportError(error);
31
+ };
32
+
33
+ /** Mount each island/client instance in its own error boundary. */
34
+ export const mount = <Props extends object>(
35
+ Component: Component<Props>,
36
+ selector: string,
37
+ Fallback?: Component<IslandErrorProps>,
38
+ ): void => {
39
+ document.querySelectorAll<HTMLElement>(selector).forEach((element) => {
40
+ const mountElement = (): void => {
41
+ try {
42
+ element.innerHTML = "";
43
+ render(() => createComponent(ErrorBoundary, {
44
+ fallback: (error: unknown, reset: () => void) => {
45
+ report(element, error, reset);
46
+ if (!Fallback) return defaultFallback(reset);
47
+ return createComponent(ErrorBoundary, {
48
+ fallback: (fallbackError: unknown) => {
49
+ globalThis.reportError(fallbackError);
50
+ return defaultFallback(reset);
51
+ },
52
+ get children() {
53
+ return createComponent(Fallback, { error, reset });
54
+ },
55
+ });
56
+ },
57
+ get children() {
58
+ return createComponent(Component, deserialize<Props>(element.dataset.props || "{}"));
59
+ },
60
+ }), element);
61
+ } catch (error) {
62
+ report(element, error, mountElement);
63
+ element.replaceChildren(defaultFallback(mountElement));
64
+ }
65
+ };
66
+ mountElement();
67
+ });
68
+ };
package/src/nav.ts CHANGED
@@ -50,9 +50,12 @@ export type LinkNavigateEvent = {
50
50
 
51
51
  export type LinkProps = Omit<AnchorProps, "href" | "onClick"> & {
52
52
  href: string;
53
+ /** Default history mode for enhanced clicks. Ignored without `onNavigate`. */
53
54
  replace?: boolean;
55
+ /** Default scroll mode for enhanced clicks. Ignored without `onNavigate`. */
54
56
  scroll?: NavigationScrollMode;
55
57
  onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
58
+ /** Enables enhanced same-origin clicks. Without it, `Link` is a native anchor. */
56
59
  onNavigate?: (event: LinkNavigateEvent) => void | Promise<void>;
57
60
  };
58
61
 
@@ -200,11 +203,6 @@ const shouldEnhanceClick = (event: MouseEvent, anchor: HTMLAnchorElement): boole
200
203
  return url.origin === window.location.origin;
201
204
  };
202
205
 
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
-
208
206
  const callUserClick = (handler: LinkProps["onClick"], event: MouseEvent, anchor: HTMLAnchorElement): void => {
209
207
  if (!handler) return;
210
208
  const typedEvent = event as MouseEvent & { currentTarget: HTMLAnchorElement; target: Element };
@@ -227,30 +225,23 @@ export function Link(props: LinkProps) {
227
225
 
228
226
  const handleClick: JSX.EventHandler<HTMLAnchorElement, MouseEvent> = (event) => {
229
227
  callUserClick(local.onClick, event, event.currentTarget);
228
+ const onNavigate = local.onNavigate;
229
+ if (!onNavigate) return;
230
230
  if (!shouldEnhanceClick(event, event.currentTarget)) return;
231
231
 
232
232
  const href = local.href;
233
233
  const url = new URL(event.currentTarget.href);
234
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
235
  const scroll = local.scroll ?? "top";
240
236
  const replace = Boolean(local.replace);
241
237
  const scrollSnapshot = captureScroll();
242
238
 
243
239
  event.preventDefault();
244
240
 
245
- if (!local.onNavigate) {
246
- navigate(url.href, { replace, scroll, scrollSnapshot });
247
- return;
248
- }
249
-
250
241
  let navigationOutcome: "none" | "history" | "document" = "none";
251
242
  const runNavigation = async () => {
252
243
  try {
253
- await local.onNavigate!({
244
+ await onNavigate({
254
245
  event,
255
246
  href,
256
247
  url,