@valentinkolb/ssr 0.10.1 → 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 +29 -5
- package/package.json +3 -2
- package/src/nav.ts +120 -41
- package/src/transform.ts +7 -12
package/README.md
CHANGED
|
@@ -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.
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/ssr",
|
|
3
|
-
"version": "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,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",
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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(
|
|
229
|
+
callUserClick(local.onClick, event, event.currentTarget);
|
|
190
230
|
if (!shouldEnhanceClick(event, event.currentTarget)) return;
|
|
191
231
|
|
|
192
|
-
const href =
|
|
193
|
-
const url = new URL(
|
|
194
|
-
|
|
195
|
-
|
|
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 (!
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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 }],
|