@k2b/ssr 0.14.0 → 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
@@ -59,6 +59,7 @@ SPA routing.
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,6 +400,7 @@ 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
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/ssr",
3
- "version": "0.14.0",
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",
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
@@ -15,6 +15,8 @@ import { getAssetPrefix, getReloadId, normalizeBasePath, toSsrPath } from "./ada
15
15
  // @ts-ignore - Bun text import
16
16
  import devClientCode from "./adapter/client.js" with { type: "text" };
17
17
 
18
+ export type { IslandErrorProps } from "./mount";
19
+
18
20
  // ============================================================================
19
21
  // Constants
20
22
  // ============================================================================
@@ -41,6 +43,8 @@ export type SsrOptions<T extends object = object> = {
41
43
  external?: string[];
42
44
  /** Development island sourcemaps (default: "linked") */
43
45
  devSourcemap?: DevSourcemap;
46
+ /** Path relative to rootDir (or absolute) to a default-exported Solid component receiving IslandErrorProps. */
47
+ errorFallback?: string;
44
48
  /** HTML template function (optional, has default) */
45
49
  template?: (
46
50
  ctx: {
@@ -107,6 +111,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
107
111
  verbose,
108
112
  external,
109
113
  devSourcemap = "linked",
114
+ errorFallback,
110
115
  template,
111
116
  rootDir: rootDirOption,
112
117
  componentRoots,
@@ -200,6 +205,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
200
205
  dev,
201
206
  devSourcemap,
202
207
  external,
208
+ errorFallback: errorFallback ? resolve(rootDir, errorFallback) : undefined,
203
209
  });
204
210
  };
205
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,