@valentinkolb/ssr 0.10.1 → 0.11.1

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
@@ -21,8 +21,8 @@ Current source size in this repo (`packages/ssr-core/src`):
21
21
  | Component | Lines | Raw | Gzipped |
22
22
  | --- | ---: | ---: | ---: |
23
23
  | Core (`index`, `transform`, `build`, island ID + resolver) | ~689 | 23.8 KB | 7.2 KB |
24
- | Dev client (overlay + reload, dev only) | ~211 | 6.1 KB | 2.0 KB |
25
- | Adapters (`bun`, `hono`, `elysia`, shared utils) | ~355 | 10.4 KB | 3.3 KB |
24
+ | Dev client (overlay + reload, dev only) | ~421 | 11.9 KB | 3.5 KB |
25
+ | Adapters (`bun`, `hono`, `elysia`, shared utils) | ~465 | 14.4 KB | 4.8 KB |
26
26
 
27
27
  Important: these sizes describe framework source code that runs at build time and on the server.
28
28
  The browser receives only:
@@ -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.
@@ -334,6 +358,12 @@ It can:
334
358
  - highlight island/client boundaries
335
359
  - show source filenames for wrapped components
336
360
 
361
+ In browsers with Web Locks support, auto-reload elects one visible tab per
362
+ origin and SSR path to hold the SSE connection. Hidden tabs suspend reload work,
363
+ leadership transfers automatically, and cached pages resume safely after a
364
+ back-forward cache restore. Browsers without Web Locks retain visibility-scoped
365
+ per-tab connections as a compatibility fallback.
366
+
337
367
  ## Limitations
338
368
 
339
369
  - islands must use default export
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/ssr",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Minimal SSR framework for SolidJS and Bun",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -13,7 +13,7 @@
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",
@@ -36,6 +36,7 @@
36
36
  "seroval": "^1.5.5"
37
37
  },
38
38
  "devDependencies": {
39
+ "@happy-dom/global-registrator": "^20.10.6",
39
40
  "@types/bun": "^1.3.14",
40
41
  "elysia": "^1.4.29",
41
42
  "file-type": "^21.3.1",
@@ -5,6 +5,7 @@
5
5
  import type { SsrConfig } from "../index";
6
6
  import {
7
7
  createAssetResponse,
8
+ createPingResponse,
8
9
  getSsrDir,
9
10
  createReloadResponse,
10
11
  } from "./utils";
@@ -32,8 +33,8 @@ export const routes = (config: SsrConfig): Routes => {
32
33
 
33
34
  const devRoutes: Routes = dev
34
35
  ? {
35
- [`${ssrPath}/_reload`]: () => createReloadResponse(),
36
- [`${ssrPath}/_ping`]: () => new Response("ok"),
36
+ [`${ssrPath}/_reload`]: (req) => createReloadResponse(req.signal),
37
+ [`${ssrPath}/_ping`]: () => createPingResponse(),
37
38
  }
38
39
  : {};
39
40
 
@@ -14,11 +14,31 @@ if (!window.__ssr_reload) {
14
14
  position: "bl",
15
15
  };
16
16
 
17
- const load = () => ({
18
- ...defaults,
19
- ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"),
20
- });
21
- const save = (s) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
17
+ const readStorage = (key) => {
18
+ try {
19
+ return localStorage.getItem(key);
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+ const writeStorage = (key, value) => {
25
+ try {
26
+ localStorage.setItem(key, value);
27
+ } catch {
28
+ // Dev tools still work when storage is unavailable.
29
+ }
30
+ };
31
+ const load = () => {
32
+ try {
33
+ return {
34
+ ...defaults,
35
+ ...JSON.parse(readStorage(STORAGE_KEY) || "{}"),
36
+ };
37
+ } catch {
38
+ return { ...defaults };
39
+ }
40
+ };
41
+ const save = (s) => writeStorage(STORAGE_KEY, JSON.stringify(s));
22
42
  let settings = load();
23
43
 
24
44
  // Highlight Styles
@@ -152,10 +172,23 @@ if (!window.__ssr_reload) {
152
172
  bind("_ssr_pos", "position", applyPosition);
153
173
 
154
174
  // Live Reload (SSE)
155
- let es, reconnectInterval, animationInterval;
175
+ const reloadId = globalThis.__SSR_CONFIG?.reloadId;
176
+ const reloadIdKey = `${STORAGE_KEY}:reload-id`;
177
+ const reloadLockName = `${STORAGE_KEY}:reload-lock`;
178
+ let es, retryTimer, retryController, animationInterval;
179
+ let lockController, releaseLock;
180
+ let retryAttempt = 0;
181
+ let retryGeneration = 0;
182
+ let ownsLock = false;
183
+ let pageActive = true;
184
+ let reloadRequested = false;
156
185
  const spinFrames = ["[ / ]", "[ – ]", "[ \\ ]", "[ | ]"];
157
186
  let spinIndex = 0;
158
187
 
188
+ // A fresh document already represents this server generation. Publishing
189
+ // it first wakes stale tabs without making the fresh document reload.
190
+ if (reloadId) writeStorage(reloadIdKey, reloadId);
191
+
159
192
  const stopAnimation = () => {
160
193
  clearInterval(animationInterval);
161
194
  animationInterval = null;
@@ -169,45 +202,220 @@ if (!window.__ssr_reload) {
169
202
  }, 150);
170
203
  };
171
204
 
172
- const stop = () => {
173
- es?.close();
205
+ const shouldParticipate = () =>
206
+ pageActive &&
207
+ !reloadRequested &&
208
+ settings.autoReload &&
209
+ document.visibilityState === "visible";
210
+ const canConnect = () => shouldParticipate() && ownsLock;
211
+
212
+ const closeSource = () => {
213
+ const source = es;
174
214
  es = null;
175
- clearInterval(reconnectInterval);
176
- reconnectInterval = null;
215
+ source?.close();
216
+ };
217
+
218
+ const cancelRetry = () => {
219
+ retryGeneration += 1;
220
+ clearTimeout(retryTimer);
221
+ retryTimer = null;
222
+ const controller = retryController;
223
+ retryController = null;
224
+ controller?.abort();
225
+ };
226
+
227
+ const stopConnection = () => {
228
+ closeSource();
229
+ cancelRetry();
230
+ retryAttempt = 0;
177
231
  stopAnimation();
232
+ badge.innerText = "[ssr]";
233
+ };
234
+
235
+ const releaseLeadership = () => {
236
+ const pendingLock = lockController;
237
+ lockController = null;
238
+ pendingLock?.abort();
239
+
240
+ const release = releaseLock;
241
+ releaseLock = null;
242
+ ownsLock = false;
243
+ release?.();
244
+ stopConnection();
245
+ };
246
+
247
+ const requestReload = () => {
248
+ if (reloadRequested) return;
249
+ reloadRequested = true;
250
+ releaseLeadership();
251
+ location.reload();
252
+ };
253
+
254
+ const acceptReloadId = (nextReloadId) => {
255
+ if (!nextReloadId) return;
256
+ writeStorage(reloadIdKey, nextReloadId);
257
+ if (reloadId && nextReloadId !== reloadId) requestReload();
258
+ };
259
+
260
+ const retryDelay = () =>
261
+ Math.min(2_000, 300 * 2 ** Math.min(retryAttempt, 3));
262
+
263
+ const scheduleRetry = () => {
264
+ if (!canConnect() || retryTimer || retryController) return;
265
+
266
+ const generation = retryGeneration;
267
+ retryTimer = setTimeout(async () => {
268
+ retryTimer = null;
269
+ if (generation !== retryGeneration || !canConnect()) return;
270
+
271
+ const controller = new AbortController();
272
+ retryController = controller;
273
+ try {
274
+ const response = await fetch(`${ssrPath}/_ping`, {
275
+ cache: "no-store",
276
+ signal: controller.signal,
277
+ });
278
+ if (response.ok && canConnect()) {
279
+ acceptReloadId(response.headers.get("X-SSR-Reload-ID"));
280
+ if (!reloadRequested) requestReload();
281
+ return;
282
+ }
283
+ } catch {
284
+ // A stopped or unavailable dev server is expected during reload.
285
+ } finally {
286
+ if (retryController === controller) retryController = null;
287
+ }
288
+
289
+ if (generation !== retryGeneration || !canConnect()) return;
290
+ retryAttempt += 1;
291
+ scheduleRetry();
292
+ }, retryDelay());
293
+ };
294
+
295
+ const startRetry = () => {
296
+ if (!canConnect()) return;
297
+ startAnimation();
298
+ scheduleRetry();
178
299
  };
179
300
 
180
301
  const start = () => {
181
- if (es) return;
302
+ if (!canConnect() || es || retryTimer || retryController) return;
303
+
304
+ let source;
182
305
  try {
183
- es = new EventSource(`${ssrPath}/_reload`);
306
+ source = new EventSource(`${ssrPath}/_reload`);
307
+ es = source;
184
308
  stopAnimation();
185
309
  badge.innerText = "[ssr]";
186
310
  } catch {
311
+ startRetry();
187
312
  return;
188
313
  }
189
314
 
190
- es.onerror = (e) => {
191
- e.preventDefault();
192
- stop();
193
- startAnimation();
194
- if (!settings.autoReload) return;
195
- reconnectInterval = setInterval(() => {
196
- fetch(`${ssrPath}/_ping`)
197
- .then((r) => r.ok && location.reload())
198
- .catch(() => {});
199
- }, 300);
315
+ source.onopen = () => {
316
+ if (es !== source) return;
317
+ retryAttempt = 0;
318
+ stopAnimation();
319
+ badge.innerText = "[ssr]";
200
320
  };
321
+
322
+ source.onmessage = (event) => {
323
+ if (es !== source) return;
324
+ acceptReloadId(event.data);
325
+ };
326
+
327
+ source.onerror = (event) => {
328
+ if (es !== source) return;
329
+ event.preventDefault();
330
+ closeSource();
331
+ retryAttempt = 0;
332
+ startRetry();
333
+ };
334
+ };
335
+
336
+ const requestLeadership = () => {
337
+ if (!shouldParticipate()) return;
338
+ if (ownsLock) {
339
+ start();
340
+ return;
341
+ }
342
+ if (lockController) return;
343
+
344
+ if (!navigator.locks?.request) {
345
+ ownsLock = true;
346
+ start();
347
+ return;
348
+ }
349
+
350
+ const controller = new AbortController();
351
+ lockController = controller;
352
+ void navigator.locks
353
+ .request(
354
+ reloadLockName,
355
+ { mode: "exclusive", signal: controller.signal },
356
+ async () => {
357
+ if (lockController === controller) lockController = null;
358
+ if (controller.signal.aborted || !shouldParticipate()) return;
359
+
360
+ ownsLock = true;
361
+ start();
362
+ await new Promise((resolve) => {
363
+ releaseLock = resolve;
364
+ });
365
+ releaseLock = null;
366
+ ownsLock = false;
367
+ stopConnection();
368
+ },
369
+ )
370
+ .catch((error) => {
371
+ if (lockController === controller) lockController = null;
372
+ if (error?.name === "AbortError" || !shouldParticipate()) return;
373
+
374
+ // If Web Locks is unavailable at runtime, retain per-tab behavior.
375
+ ownsLock = true;
376
+ start();
377
+ });
201
378
  };
202
379
 
203
- if (settings.autoReload) start();
380
+ const syncConnection = () => {
381
+ const knownReloadId = readStorage(reloadIdKey);
382
+ if (
383
+ shouldParticipate() &&
384
+ reloadId &&
385
+ knownReloadId &&
386
+ knownReloadId !== reloadId
387
+ ) {
388
+ requestReload();
389
+ return;
390
+ }
391
+
392
+ if (shouldParticipate()) requestLeadership();
393
+ else releaseLeadership();
394
+ };
395
+
396
+ syncConnection();
204
397
 
205
398
  panel.querySelector("#_ssr_reload").onchange = (e) => {
206
399
  settings.autoReload = e.target.checked;
207
400
  save(settings);
208
- settings.autoReload ? start() : stop();
401
+ syncConnection();
209
402
  };
210
403
 
211
- window.addEventListener("pagehide", stop);
404
+ document.addEventListener("visibilitychange", syncConnection);
405
+ window.addEventListener("storage", (event) => {
406
+ if (event.key !== reloadIdKey || !event.newValue) return;
407
+ if (reloadId && event.newValue !== reloadId && shouldParticipate()) {
408
+ requestReload();
409
+ }
410
+ });
411
+ window.addEventListener("pagehide", () => {
412
+ pageActive = false;
413
+ releaseLeadership();
414
+ });
415
+ window.addEventListener("pageshow", () => {
416
+ pageActive = true;
417
+ reloadRequested = false;
418
+ syncConnection();
419
+ });
212
420
  })();
213
421
  }
@@ -6,6 +6,7 @@ import { Elysia } from "elysia";
6
6
  import type { SsrConfig } from "../index";
7
7
  import {
8
8
  createAssetResponse,
9
+ createPingResponse,
9
10
  getSsrDir,
10
11
  createReloadResponse,
11
12
  notFound,
@@ -28,8 +29,10 @@ export const routes = (config: SsrConfig) => {
28
29
  const ssrDir = getSsrDir(config);
29
30
 
30
31
  return new Elysia({ name: "ssr" })
31
- .get(`${ssrPath}/_reload`, () => (dev ? createReloadResponse() : notFound()))
32
- .get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()))
32
+ .get(`${ssrPath}/_reload`, ({ request }) =>
33
+ dev ? createReloadResponse(request.signal) : notFound(),
34
+ )
35
+ .get(`${ssrPath}/_ping`, () => (dev ? createPingResponse() : notFound()))
33
36
  .get(`${ssrPath}/*`, ({ request, params }) =>
34
37
  createAssetResponse(request, ssrDir, params["*"], dev),
35
38
  );
@@ -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, getSsrDir, createReloadResponse } from "./utils";
9
+ import { createAssetResponse, createPingResponse, getSsrDir, createReloadResponse } from "./utils";
10
10
 
11
11
  // ============================================================================
12
12
  // Types
@@ -172,8 +172,8 @@ export const routes = (config: SsrConfig) => {
172
172
 
173
173
  // Dev mode endpoints
174
174
  if (dev) {
175
- app.get("/_reload", () => createReloadResponse());
176
- app.get("/_ping", (c) => c.text("ok"));
175
+ app.get("/_reload", (c) => createReloadResponse(c.req.raw.signal));
176
+ app.get("/_ping", () => createPingResponse());
177
177
  }
178
178
 
179
179
  const serveAsset = (c: Context) => createAssetResponse(c.req.raw, ssrDir, c.req.param("filename"), dev);
@@ -116,28 +116,63 @@ export const SSE_HEADERS = {
116
116
  Connection: "keep-alive",
117
117
  } as const;
118
118
 
119
+ const reloadId = crypto.randomUUID();
120
+ const reloadConnected = new TextEncoder().encode(`: connected\ndata: ${reloadId}\n\n`);
121
+ const reloadPing = new TextEncoder().encode(": ping\n\n");
122
+
123
+ export const getReloadId = (): string => reloadId;
124
+
125
+ export const createPingResponse = (): Response =>
126
+ new Response("ok", { headers: { "X-SSR-Reload-ID": reloadId } });
127
+
119
128
  /**
120
129
  * Creates a Server-Sent Events stream for live reload
121
130
  */
122
- export const createReloadStream = (): ReadableStream =>
123
- new ReadableStream({
131
+ export const createReloadStream = (signal?: AbortSignal): ReadableStream<Uint8Array> => {
132
+ let interval: ReturnType<typeof setInterval> | undefined;
133
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
134
+ const handleAbort = () => {
135
+ stopHeartbeat();
136
+ try {
137
+ streamController?.close();
138
+ } catch {
139
+ // The consumer may already have canceled the stream.
140
+ }
141
+ };
142
+ const stopHeartbeat = () => {
143
+ if (interval === undefined) return;
144
+ clearInterval(interval);
145
+ interval = undefined;
146
+ signal?.removeEventListener("abort", handleAbort);
147
+ };
148
+
149
+ return new ReadableStream<Uint8Array>({
124
150
  start(controller) {
125
- controller.enqueue(new TextEncoder().encode(": connected\n\n"));
126
- const interval = setInterval(() => {
151
+ streamController = controller;
152
+ if (signal?.aborted) {
153
+ controller.close();
154
+ return;
155
+ }
156
+
157
+ signal?.addEventListener("abort", handleAbort, { once: true });
158
+ controller.enqueue(reloadConnected);
159
+ interval = setInterval(() => {
127
160
  try {
128
- controller.enqueue(new TextEncoder().encode(": ping\n\n"));
161
+ controller.enqueue(reloadPing);
129
162
  } catch {
130
- clearInterval(interval);
163
+ stopHeartbeat();
131
164
  }
132
165
  }, 5000);
133
166
  },
167
+ cancel: stopHeartbeat,
134
168
  });
169
+ };
135
170
 
136
171
  /**
137
172
  * Creates a Response for the SSE reload endpoint
138
173
  */
139
- export const createReloadResponse = (): Response =>
140
- new Response(createReloadStream(), { headers: SSE_HEADERS });
174
+ export const createReloadResponse = (signal?: AbortSignal): Response =>
175
+ new Response(createReloadStream(signal), { headers: SSE_HEADERS });
141
176
 
142
177
  /**
143
178
  * 404 Response
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import { transform } from "./transform";
12
12
  import { buildIslands, type DevSourcemap } from "./build";
13
13
  import { join, dirname, resolve } from "path";
14
14
  import { resolveIslandImport } from "./island-resolve";
15
- import { normalizeBasePath, toSsrPath } from "./adapter/utils";
15
+ import { getReloadId, normalizeBasePath, toSsrPath } from "./adapter/utils";
16
16
  // @ts-ignore - Bun text import
17
17
  import devClientCode from "./adapter/client.js" with { type: "text" };
18
18
 
@@ -160,7 +160,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
160
160
 
161
161
  // Hydration script - dynamically loads island/client bundles based on DOM
162
162
  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>`;
163
- const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath })}</script>`;
163
+ const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath, reloadId: getReloadId() })}</script>`;
164
164
 
165
165
  // HTML renderer
166
166
  const html: HtmlFn<T> = async (render, opts = {} as T) => {
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 }],