@real-router/svelte 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 +63 -11
- package/dist/RouterProvider.svelte +12 -3
- package/dist/components/Await.svelte +48 -0
- package/dist/components/Await.svelte.d.ts +50 -0
- package/dist/components/ClientOnly.svelte +22 -0
- package/dist/components/ClientOnly.svelte.d.ts +8 -0
- package/dist/components/HttpStatusCode.svelte +63 -0
- package/dist/components/HttpStatusCode.svelte.d.ts +45 -0
- package/dist/components/HttpStatusProvider.svelte +45 -0
- package/dist/components/HttpStatusProvider.svelte.d.ts +30 -0
- package/dist/components/RouteView.helpers.d.ts +1 -0
- package/dist/components/RouteView.helpers.js +16 -0
- package/dist/components/RouteView.svelte +1 -25
- package/dist/components/RouteView.svelte.d.ts +0 -1
- package/dist/components/ServerOnly.svelte +22 -0
- package/dist/components/ServerOnly.svelte.d.ts +8 -0
- package/dist/components/Streamed.svelte +37 -0
- package/dist/components/Streamed.svelte.d.ts +46 -0
- package/dist/composables/useDeferred.svelte.d.ts +24 -0
- package/dist/composables/useDeferred.svelte.js +34 -0
- package/dist/composables/useRoute.svelte.d.ts +8 -1
- package/dist/context.d.ts +1 -0
- package/dist/context.js +1 -0
- package/dist/dom-utils/__test-helpers/expected-fragment.d.ts +30 -0
- package/dist/dom-utils/__test-helpers/expected-fragment.js +43 -0
- package/dist/dom-utils/__test-helpers/index.d.ts +8 -0
- package/dist/dom-utils/__test-helpers/index.js +8 -0
- package/dist/dom-utils/link-utils.d.ts +23 -0
- package/dist/dom-utils/link-utils.js +106 -5
- package/dist/dom-utils/route-announcer.js +51 -2
- package/dist/dom-utils/scroll-restore.d.ts +38 -1
- package/dist/dom-utils/scroll-restore.js +144 -12
- package/dist/ssr.d.ts +9 -0
- package/dist/ssr.js +17 -0
- package/dist/types.d.ts +23 -0
- package/dist/utils/createHttpStatusSink.d.ts +28 -0
- package/dist/utils/createHttpStatusSink.js +3 -0
- package/package.json +10 -5
- package/src/RouterProvider.svelte +12 -3
- package/src/components/Await.svelte +48 -0
- package/src/components/ClientOnly.svelte +22 -0
- package/src/components/HttpStatusCode.svelte +63 -0
- package/src/components/HttpStatusProvider.svelte +45 -0
- package/src/components/RouteView.helpers.ts +24 -0
- package/src/components/RouteView.svelte +1 -25
- package/src/components/ServerOnly.svelte +22 -0
- package/src/components/Streamed.svelte +37 -0
- package/src/composables/useDeferred.svelte.ts +41 -0
- package/src/composables/useIsActiveRoute.svelte.ts +1 -1
- package/src/composables/useRoute.svelte.ts +11 -7
- package/src/context.ts +2 -0
- package/src/ssr.ts +28 -0
- package/src/types.ts +23 -0
- package/src/utils/createHttpStatusSink.ts +31 -0
|
@@ -22,20 +22,36 @@ export function createScrollRestoration(router, options) {
|
|
|
22
22
|
const getContainer = options?.scrollContainer;
|
|
23
23
|
const behavior = options?.behavior ?? "auto";
|
|
24
24
|
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
25
|
+
// Write-through in-memory cache: parse sessionStorage once per provider
|
|
26
|
+
// mount, then mutate in-memory. Avoids a JSON.parse + JSON.stringify pair
|
|
27
|
+
// on every subscribeLeave / pagehide event.
|
|
28
|
+
let store;
|
|
25
29
|
const loadStore = () => {
|
|
30
|
+
if (store !== undefined) {
|
|
31
|
+
return store;
|
|
32
|
+
}
|
|
26
33
|
try {
|
|
27
34
|
const raw = sessionStorage.getItem(storageKey);
|
|
28
|
-
|
|
35
|
+
store = raw ? JSON.parse(raw) : {};
|
|
29
36
|
}
|
|
30
37
|
catch {
|
|
31
|
-
|
|
38
|
+
store = {};
|
|
32
39
|
}
|
|
40
|
+
return store;
|
|
33
41
|
};
|
|
34
42
|
const putPos = (key, pos) => {
|
|
35
43
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
const cached = loadStore();
|
|
45
|
+
// Skip-same-value: when a route is left at the same scroll position it
|
|
46
|
+
// already holds in the cache (e.g. tab-switching without scrolling),
|
|
47
|
+
// both the in-memory write and the JSON.stringify + setItem pair are
|
|
48
|
+
// no-ops. Eliminates redundant serialization on the navigation hot
|
|
49
|
+
// path for the common "click tabs without scrolling" case.
|
|
50
|
+
if (cached[key] === pos) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
cached[key] = pos;
|
|
54
|
+
sessionStorage.setItem(storageKey, JSON.stringify(cached));
|
|
39
55
|
}
|
|
40
56
|
catch {
|
|
41
57
|
// Ignore quota / security errors.
|
|
@@ -106,6 +122,27 @@ export function createScrollRestoration(router, options) {
|
|
|
106
122
|
writePos(0);
|
|
107
123
|
};
|
|
108
124
|
let destroyed = false;
|
|
125
|
+
let unserializableWarned = false;
|
|
126
|
+
// `keyOf` defers to `canonicalJson` which calls `JSON.stringify`. Two
|
|
127
|
+
// realistic inputs blow up the serializer and would otherwise crash the
|
|
128
|
+
// subscribe callback (taking scroll-restore offline for the whole session):
|
|
129
|
+
// - `BigInt` params → `TypeError: Do not know how to serialize a BigInt`
|
|
130
|
+
// - cyclic params (reactive proxies, DOM-ref back-pointers) → stack
|
|
131
|
+
// overflow.
|
|
132
|
+
// The defensive wrapper drops capture/restore for that specific navigation
|
|
133
|
+
// and warns once per provider — the rest of the cache stays usable.
|
|
134
|
+
const safeKeyOf = (state) => {
|
|
135
|
+
try {
|
|
136
|
+
return keyOf(state);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
if (!unserializableWarned) {
|
|
140
|
+
unserializableWarned = true;
|
|
141
|
+
console.error(`[real-router] scroll-restore: route "${state.name}" has params that cannot be canonicalized (e.g. BigInt or cyclic structure). Scroll position will not be captured or restored for this route.`);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
109
146
|
const unsubscribe = router.subscribe(({ route, previousRoute }) => {
|
|
110
147
|
const nav = route.context
|
|
111
148
|
.navigation;
|
|
@@ -113,7 +150,10 @@ export function createScrollRestoration(router, options) {
|
|
|
113
150
|
// previousRoute is undefined and capture is naturally skipped. The
|
|
114
151
|
// pre-refresh position was already persisted via pagehide.
|
|
115
152
|
if (previousRoute) {
|
|
116
|
-
|
|
153
|
+
const prevKey = safeKeyOf(previousRoute);
|
|
154
|
+
if (prevKey !== null) {
|
|
155
|
+
putPos(prevKey, readPos());
|
|
156
|
+
}
|
|
117
157
|
}
|
|
118
158
|
// Single rAF so DOM is committed before we read anchors / write scroll.
|
|
119
159
|
// Guard against destroy() racing with the callback.
|
|
@@ -131,7 +171,8 @@ export function createScrollRestoration(router, options) {
|
|
|
131
171
|
if (nav.direction === "back" ||
|
|
132
172
|
nav.navigationType === "traverse" ||
|
|
133
173
|
nav.navigationType === "reload") {
|
|
134
|
-
|
|
174
|
+
const key = safeKeyOf(route);
|
|
175
|
+
writePos(key === null ? 0 : (loadStore()[key] ?? 0));
|
|
135
176
|
return;
|
|
136
177
|
}
|
|
137
178
|
scrollToHashOrTop(route);
|
|
@@ -140,7 +181,10 @@ export function createScrollRestoration(router, options) {
|
|
|
140
181
|
const onPageHide = () => {
|
|
141
182
|
const current = router.getState();
|
|
142
183
|
if (current) {
|
|
143
|
-
|
|
184
|
+
const key = safeKeyOf(current);
|
|
185
|
+
if (key !== null) {
|
|
186
|
+
putPos(key, readPos());
|
|
187
|
+
}
|
|
144
188
|
}
|
|
145
189
|
};
|
|
146
190
|
globalThis.addEventListener("pagehide", onPageHide);
|
|
@@ -161,15 +205,103 @@ export function createScrollRestoration(router, options) {
|
|
|
161
205
|
},
|
|
162
206
|
};
|
|
163
207
|
}
|
|
164
|
-
|
|
165
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Internal cache-key builder for scroll-position storage.
|
|
210
|
+
*
|
|
211
|
+
* **Exported for testing only — not part of the public API** (intentionally
|
|
212
|
+
* excluded from `index.ts` barrel). Adapter property tests import it via
|
|
213
|
+
* the direct path to lock the `(name, canonicalJson(params))` key shape
|
|
214
|
+
* as a regression guard (§8b H20 / audit-2026-05-16 #S3). A change to
|
|
215
|
+
* key format would silently lose scroll positions across an upgrade —
|
|
216
|
+
* the test set is the contract.
|
|
217
|
+
*
|
|
218
|
+
* ## Identity-based memoization (audit-2026-05-17 §8b #2)
|
|
219
|
+
*
|
|
220
|
+
* `State` objects emitted by core are frozen per-navigation: their
|
|
221
|
+
* `name` / `params` are immutable for the lifetime of the snapshot, and
|
|
222
|
+
* any change produces a new `State` reference. A `WeakMap<State, string>`
|
|
223
|
+
* therefore safely caches the canonicalised key by identity — repeat
|
|
224
|
+
* `keyOf(state)` calls on the same snapshot (typical on
|
|
225
|
+
* back/forward/traverse where the same prior `State` is re-emitted)
|
|
226
|
+
* skip the recursive `canonicalJson` pass entirely.
|
|
227
|
+
*
|
|
228
|
+
* The cache key is the `State` reference, so entries auto-release when
|
|
229
|
+
* the snapshot is GC'd — no eviction needed.
|
|
230
|
+
*/
|
|
231
|
+
const KEY_CACHE = new WeakMap();
|
|
232
|
+
export function keyOf(state) {
|
|
233
|
+
const cached = KEY_CACHE.get(state);
|
|
234
|
+
if (cached !== undefined) {
|
|
235
|
+
return cached;
|
|
236
|
+
}
|
|
237
|
+
const key = `${state.name}:${canonicalJson(state.params)}`;
|
|
238
|
+
KEY_CACHE.set(state, key);
|
|
239
|
+
return key;
|
|
166
240
|
}
|
|
167
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Stable JSON serializer with sorted object keys.
|
|
243
|
+
*
|
|
244
|
+
* **Exported for testing only — not part of the public API** (intentionally
|
|
245
|
+
* excluded from `index.ts` barrel). Adapter property tests import it via
|
|
246
|
+
* the direct path to lock the key-order-insensitive property
|
|
247
|
+
* (`canonicalJson({a:1,b:2}) === canonicalJson({b:2,a:1})`).
|
|
248
|
+
*
|
|
249
|
+
* ## Divergence from `@real-router/sources/canonicalJson` — by design
|
|
250
|
+
*
|
|
251
|
+
* Two independent implementations live in the monorepo:
|
|
252
|
+
*
|
|
253
|
+
* - **`shared/dom-utils/scroll-restore.canonicalJson`** (this file) — scroll
|
|
254
|
+
* cache key builder. Uses `localeCompare` and a plain-object accumulator;
|
|
255
|
+
* tolerates `__proto__`-keyed inputs only insofar as `JSON.stringify`'s
|
|
256
|
+
* replacer happens to sort them; relies on `JSON.stringify`'s native cycle
|
|
257
|
+
* detector. Designed to be cheap on the navigation hot path. The
|
|
258
|
+
* surrounding [[safeKeyOf]] wrapper catches the two crash inputs (`BigInt`,
|
|
259
|
+
* cyclic) and skips the offending capture/restore.
|
|
260
|
+
*
|
|
261
|
+
* - **`@real-router/sources/canonicalJson`** — sources cache key builder.
|
|
262
|
+
* Uses byte-order compare (`< / >`) for locale-independence, a
|
|
263
|
+
* `Object.create(null)` accumulator to prevent prototype pollution, and a
|
|
264
|
+
* bespoke path-based cycle detector (the native one cannot see the cloned
|
|
265
|
+
* graph). Throws eagerly on `Map`/`Set`/`RegExp`/cycles — the caller falls
|
|
266
|
+
* back to a non-cached source.
|
|
267
|
+
*
|
|
268
|
+
* **They are intentionally NOT interchangeable.** Aligning them would either
|
|
269
|
+
* regress scroll-restore performance (byte-order + recursive clone is heavier
|
|
270
|
+
* per call) or weaken the sources cache (locale dependence breaks
|
|
271
|
+
* deterministic cache keys across machines). No cross-package equivalence
|
|
272
|
+
* test exists or should be added; the relationship is "different invariants,
|
|
273
|
+
* different costs, different consumers." Audit-2 / audit-2026-05-17 §2
|
|
274
|
+
* documents the choice.
|
|
275
|
+
*/
|
|
276
|
+
export function canonicalJson(value) {
|
|
168
277
|
return JSON.stringify(value, canonicalReplacer);
|
|
169
278
|
}
|
|
170
279
|
function canonicalReplacer(_key, val) {
|
|
280
|
+
// audit-2026-05-17 §5 MEDIUM (Sprint A.3) — function/Symbol marker.
|
|
281
|
+
// `JSON.stringify` silently drops function and symbol values from
|
|
282
|
+
// object output. Two routes that differ ONLY in a function/Symbol
|
|
283
|
+
// value would canonicalize to the same string → silent scroll-cache
|
|
284
|
+
// key collision (positions clobber each other). Replacing the value
|
|
285
|
+
// with a sentinel string breaks the collision while keeping the
|
|
286
|
+
// canonical form deterministic. The sentinels are intentionally
|
|
287
|
+
// ASCII-only and lexically distinct from valid JSON-stringified
|
|
288
|
+
// values; consumers will see `"<fn>"` / `"<sym>"` if they ever
|
|
289
|
+
// round-trip the cache key, signalling the substitution clearly.
|
|
290
|
+
if (typeof val === "function") {
|
|
291
|
+
return "<fn>";
|
|
292
|
+
}
|
|
293
|
+
if (typeof val === "symbol") {
|
|
294
|
+
return "<sym>";
|
|
295
|
+
}
|
|
171
296
|
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
172
|
-
|
|
297
|
+
// Null-prototype accumulator: a plain `{}` would interpret
|
|
298
|
+
// `sorted["__proto__"] = x` as a prototype assignment (silently dropped
|
|
299
|
+
// from JSON.stringify output AND a prototype-pollution vector). Mirrors
|
|
300
|
+
// the same guard in `@real-router/sources/canonicalJson`. The two
|
|
301
|
+
// implementations are still intentionally divergent (see the doc-block
|
|
302
|
+
// on [[canonicalJson]] above), but prototype-safety is non-negotiable
|
|
303
|
+
// on both. Lock-test: scrollRestoreKey.properties.ts Invariant 11.
|
|
304
|
+
const sorted = Object.create(null);
|
|
173
305
|
// eslint-disable-next-line unicorn/no-array-sort -- ng-packagr uses pre-ES2023 lib; toSorted unavailable
|
|
174
306
|
const keys = Object.keys(val).sort((left, right) => left.localeCompare(right));
|
|
175
307
|
for (const key of keys) {
|
package/dist/ssr.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { default as ClientOnly } from "./components/ClientOnly.svelte";
|
|
2
|
+
export { default as ServerOnly } from "./components/ServerOnly.svelte";
|
|
3
|
+
export { default as Await } from "./components/Await.svelte";
|
|
4
|
+
export { default as Streamed } from "./components/Streamed.svelte";
|
|
5
|
+
export { default as HttpStatusCode } from "./components/HttpStatusCode.svelte";
|
|
6
|
+
export { default as HttpStatusProvider } from "./components/HttpStatusProvider.svelte";
|
|
7
|
+
export { useDeferred } from "./composables/useDeferred.svelte";
|
|
8
|
+
export { createHttpStatusSink } from "./utils/createHttpStatusSink";
|
|
9
|
+
export type { HttpStatusSink } from "./utils/createHttpStatusSink";
|
package/dist/ssr.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SSR-feature entry — Svelte 5+
|
|
2
|
+
//
|
|
3
|
+
// Server-side and SSR-aware components/composables. Mirror of `@real-router/react/ssr`
|
|
4
|
+
// — same exports, Svelte-native idioms (`{#await}` block under the hood,
|
|
5
|
+
// `$state` rune for ClientOnly/ServerOnly, useDeferred returns Promise<T>
|
|
6
|
+
// for direct use with native `{#await}`).
|
|
7
|
+
// Components
|
|
8
|
+
export { default as ClientOnly } from "./components/ClientOnly.svelte";
|
|
9
|
+
export { default as ServerOnly } from "./components/ServerOnly.svelte";
|
|
10
|
+
export { default as Await } from "./components/Await.svelte";
|
|
11
|
+
export { default as Streamed } from "./components/Streamed.svelte";
|
|
12
|
+
export { default as HttpStatusCode } from "./components/HttpStatusCode.svelte";
|
|
13
|
+
export { default as HttpStatusProvider } from "./components/HttpStatusProvider.svelte";
|
|
14
|
+
// Composables
|
|
15
|
+
export { useDeferred } from "./composables/useDeferred.svelte";
|
|
16
|
+
// Utilities
|
|
17
|
+
export { createHttpStatusSink } from "./utils/createHttpStatusSink";
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NavigationOptions, Navigator, Params, State } from "@real-router/core";
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
2
3
|
export interface RouteContext<P extends Params = Params> {
|
|
3
4
|
readonly navigator: Navigator;
|
|
4
5
|
readonly route: {
|
|
@@ -8,7 +9,18 @@ export interface RouteContext<P extends Params = Params> {
|
|
|
8
9
|
readonly current: State | undefined;
|
|
9
10
|
};
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Props accepted by `<Link>`. Mirrors the inline prop shape in
|
|
14
|
+
* `src/components/Link.svelte` — any prop landed by `Link.svelte` is also
|
|
15
|
+
* declared here, including the rest-props index signature for arbitrary
|
|
16
|
+
* HTML attributes spread onto the rendered `<a>`.
|
|
17
|
+
*/
|
|
11
18
|
export interface LinkProps<P extends Params = Params> {
|
|
19
|
+
/**
|
|
20
|
+
* All other props are spread onto the rendered `<a>` element. Use this for
|
|
21
|
+
* `aria-*`, `data-*`, `id`, `title`, and any other native attributes.
|
|
22
|
+
*/
|
|
23
|
+
readonly [key: string]: unknown;
|
|
12
24
|
readonly routeName: string;
|
|
13
25
|
readonly routeParams?: P;
|
|
14
26
|
readonly routeOptions?: NavigationOptions;
|
|
@@ -16,5 +28,16 @@ export interface LinkProps<P extends Params = Params> {
|
|
|
16
28
|
readonly activeClassName?: string;
|
|
17
29
|
readonly activeStrict?: boolean;
|
|
18
30
|
readonly ignoreQueryParams?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* URL fragment (decoded, no leading "#") — #532.
|
|
33
|
+
* - `undefined` → preserve current `state.context.url.hash` on click.
|
|
34
|
+
* - `""` → clear the hash.
|
|
35
|
+
* - `"value"` → set the hash; same-route different-hash clicks route through
|
|
36
|
+
* `navigateWithHash`, which adds `force: true, hashChange: true` to
|
|
37
|
+
* bypass core's SAME_STATES check.
|
|
38
|
+
*/
|
|
39
|
+
readonly hash?: string;
|
|
19
40
|
readonly target?: string;
|
|
41
|
+
readonly children?: Snippet;
|
|
42
|
+
readonly onclick?: (evt: MouseEvent) => void;
|
|
20
43
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-scoped HTTP status sink. Created per request on the server, passed to
|
|
3
|
+
* `<HttpStatusProvider sink={...}>`, and read after `await render()` from
|
|
4
|
+
* `svelte/server` to apply the value to the HTTP response.
|
|
5
|
+
*
|
|
6
|
+
* Last write wins: if the rendered tree mounts more than one
|
|
7
|
+
* `<HttpStatusCode />`, the value reflects the last component that ran during
|
|
8
|
+
* the render pass.
|
|
9
|
+
*
|
|
10
|
+
* No-op on the client — `<HttpStatusCode />` reads the optional injected sink
|
|
11
|
+
* and skips the write when no provider is mounted, so the same component tree
|
|
12
|
+
* can be hydrated without changing behaviour.
|
|
13
|
+
*
|
|
14
|
+
* Constraints:
|
|
15
|
+
* - **Per-request only.** Don't share a sink across requests; the rendered
|
|
16
|
+
* tree mutates `code` in place. Module-level singletons leak status
|
|
17
|
+
* between concurrent requests.
|
|
18
|
+
* - **Don't `Object.freeze` the sink.** The component writes to `.code`;
|
|
19
|
+
* freezing makes the assignment throw under ESM strict mode.
|
|
20
|
+
* - **Hydration is tolerant.** Svelte 5's hydration walker accepts
|
|
21
|
+
* `{#if}`-branch asymmetry between server and client (verified by `ssr/`
|
|
22
|
+
* e2e), so the example app uses a server-only provider wrapper. This
|
|
23
|
+
* contrasts with Vue/Solid, which require symmetric provider mounting.
|
|
24
|
+
*/
|
|
25
|
+
export interface HttpStatusSink {
|
|
26
|
+
code: number | undefined;
|
|
27
|
+
}
|
|
28
|
+
export declare function createHttpStatusSink(): HttpStatusSink;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@real-router/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Svelte 5 integration for Real-Router",
|
|
6
6
|
"svelte": "./dist/index.js",
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"svelte": "./dist/index.js",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./ssr": {
|
|
15
|
+
"types": "./dist/ssr.d.ts",
|
|
16
|
+
"svelte": "./dist/ssr.js",
|
|
17
|
+
"default": "./dist/ssr.js"
|
|
13
18
|
}
|
|
14
19
|
},
|
|
15
20
|
"files": [
|
|
@@ -44,9 +49,9 @@
|
|
|
44
49
|
"license": "MIT",
|
|
45
50
|
"sideEffects": false,
|
|
46
51
|
"dependencies": {
|
|
47
|
-
"@real-router/core": "^0.
|
|
52
|
+
"@real-router/core": "^0.53.0",
|
|
48
53
|
"@real-router/route-utils": "^0.2.2",
|
|
49
|
-
"@real-router/sources": "^0.8.
|
|
54
|
+
"@real-router/sources": "^0.8.2"
|
|
50
55
|
},
|
|
51
56
|
"devDependencies": {
|
|
52
57
|
"@sveltejs/package": "2.5.7",
|
|
@@ -55,10 +60,10 @@
|
|
|
55
60
|
"@testing-library/svelte": "5.3.1",
|
|
56
61
|
"@testing-library/user-event": "14.6.1",
|
|
57
62
|
"eslint-plugin-svelte": "3.17.1",
|
|
58
|
-
"svelte": "5.
|
|
63
|
+
"svelte": "5.55.7",
|
|
59
64
|
"svelte-check": "4.4.5",
|
|
60
65
|
"svelte-eslint-parser": "1.6.0",
|
|
61
|
-
"@real-router/browser-plugin": "^0.17.
|
|
66
|
+
"@real-router/browser-plugin": "^0.17.2"
|
|
62
67
|
},
|
|
63
68
|
"peerDependencies": {
|
|
64
69
|
"svelte": ">=5.7.0"
|
|
@@ -47,9 +47,18 @@
|
|
|
47
47
|
|
|
48
48
|
$effect(() => {
|
|
49
49
|
if (!srEnabled) return;
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
50
|
+
// Pin primitive $derived deps as explicit dependencies of this effect
|
|
51
|
+
// BEFORE constructing the utility. The four `void srX` reads make
|
|
52
|
+
// intent unambiguous: even if `createScrollRestoration` throws after
|
|
53
|
+
// partial argument evaluation (e.g. invalid `mode` rejected), every
|
|
54
|
+
// srMode/srAnchor/srBehavior/srStorageKey is already in this effect's
|
|
55
|
+
// dependency set — the next change to any of them re-runs the effect
|
|
56
|
+
// and the utility gets rebuilt. Without these reads, the dependency
|
|
57
|
+
// tracking would depend on Svelte's argument-evaluation order inside
|
|
58
|
+
// the factory call, which is brittle. Non-primitive refs (like
|
|
59
|
+
// `scrollContainer` — a DOM element that changes ref every render but
|
|
60
|
+
// is identity-equal in practice) are deliberately read via `untrack`
|
|
61
|
+
// to keep this effect from re-running on every parent re-render.
|
|
53
62
|
void srMode;
|
|
54
63
|
void srAnchor;
|
|
55
64
|
void srBehavior;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Reads `useDeferred(name)` and renders the `children` snippet with the
|
|
4
|
+
resolved value via Svelte's native `{#await}` block. Optional `fallback`
|
|
5
|
+
snippet shown while the promise is pending; rejection bubbles to the
|
|
6
|
+
nearest `{:catch}` handler in the surrounding `{#await}` chain (or
|
|
7
|
+
`<Streamed>`).
|
|
8
|
+
|
|
9
|
+
```svelte
|
|
10
|
+
<Await name="reviews">
|
|
11
|
+
{#snippet children(reviews)}
|
|
12
|
+
<ReviewList items={reviews} />
|
|
13
|
+
{/snippet}
|
|
14
|
+
{#snippet fallback()}
|
|
15
|
+
<Spinner />
|
|
16
|
+
{/snippet}
|
|
17
|
+
</Await>
|
|
18
|
+
```
|
|
19
|
+
-->
|
|
20
|
+
<script lang="ts" generics="T">
|
|
21
|
+
import { useDeferred } from "../composables/useDeferred.svelte";
|
|
22
|
+
|
|
23
|
+
import type { Snippet } from "svelte";
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Render snippet for the resolved value. */
|
|
29
|
+
children: Snippet<[T]>;
|
|
30
|
+
/** Snippet shown while the promise is pending. */
|
|
31
|
+
fallback?: Snippet;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let { name, children, fallback }: Props = $props();
|
|
35
|
+
|
|
36
|
+
// `useDeferred(name)` reads `state.context.ssrDataDeferred[name]` —
|
|
37
|
+
// wrap in `$derived` so a dynamic `name` prop re-resolves the promise
|
|
38
|
+
// (vs. capturing the initial value at component init).
|
|
39
|
+
const promise = $derived(useDeferred<T>(name));
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
{#await promise}
|
|
43
|
+
{#if fallback}
|
|
44
|
+
{@render fallback()}
|
|
45
|
+
{/if}
|
|
46
|
+
{:then value}
|
|
47
|
+
{@render children(value)}
|
|
48
|
+
{/await}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: Snippet;
|
|
6
|
+
fallback?: Snippet;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let { children, fallback }: Props = $props();
|
|
10
|
+
|
|
11
|
+
let mounted = $state(false);
|
|
12
|
+
|
|
13
|
+
$effect(() => {
|
|
14
|
+
mounted = true;
|
|
15
|
+
});
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
{#if mounted}
|
|
19
|
+
{@render children()}
|
|
20
|
+
{:else if fallback}
|
|
21
|
+
{@render fallback()}
|
|
22
|
+
{/if}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Render-time HTTP status declaration. Mount inside a route component (typical
|
|
4
|
+
use case: a glob `*` route's NotFound page) when the status is decided by
|
|
5
|
+
the rendered tree rather than a loader.
|
|
6
|
+
|
|
7
|
+
Writes `code` to the nearest `<HttpStatusProvider>`'s sink during component
|
|
8
|
+
init and renders nothing. With no provider mounted (the standard
|
|
9
|
+
client-side case) the component is a silent no-op — same component tree
|
|
10
|
+
hydrates without touching the DOM or warning about mismatches.
|
|
11
|
+
|
|
12
|
+
Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep
|
|
13
|
+
working as before; this component covers render-time decisions only.
|
|
14
|
+
|
|
15
|
+
Last write wins when several `<HttpStatusCode />` instances mount in the
|
|
16
|
+
same render pass — sink reflects the last component that ran.
|
|
17
|
+
|
|
18
|
+
```svelte
|
|
19
|
+
<HttpStatusCode code={404} />
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Streaming SSR ({#await}):** Svelte 5 stable does NOT chunk-stream HTTP
|
|
23
|
+
for `{#await}` — the server emits the pending branch and returns the full
|
|
24
|
+
response immediately, async resolution happens client-side. So the sink
|
|
25
|
+
is always written by the time `await render(App, ...)` resolves, regardless
|
|
26
|
+
of where `<HttpStatusCode />` is mounted. (This is RSC-like, not React 19
|
|
27
|
+
/ Solid streaming.) No ordering concern.
|
|
28
|
+
|
|
29
|
+
**Hydration symmetry:** Svelte 5's hydration walker tolerates `{#if}`-branch
|
|
30
|
+
asymmetry between server and client (verified by `ssr/` e2e — no warnings
|
|
31
|
+
fire when SSR has the wrapper but CSR doesn't). The example's `App.svelte`
|
|
32
|
+
uses `{#if httpStatusSink}` so the wrapper is server-only; this is safe in
|
|
33
|
+
Svelte but would be a hydration mismatch in Vue/Solid.
|
|
34
|
+
|
|
35
|
+
**Valid `code` range:** Node's `res.end()` throws `Invalid status code` on
|
|
36
|
+
`NaN`, `0`, negative values, or values `> 999` — this surfaces as a 5xx /
|
|
37
|
+
dropped connection, not silent corruption. Pass a real HTTP status integer
|
|
38
|
+
(commonly 4xx/5xx; 100-999 is what Node accepts).
|
|
39
|
+
-->
|
|
40
|
+
<script lang="ts">
|
|
41
|
+
import { getContext } from "svelte";
|
|
42
|
+
|
|
43
|
+
import { HTTP_STATUS_KEY } from "../context";
|
|
44
|
+
|
|
45
|
+
import type { HttpStatusSink } from "../utils/createHttpStatusSink";
|
|
46
|
+
|
|
47
|
+
interface Props {
|
|
48
|
+
/** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */
|
|
49
|
+
code: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let { code }: Props = $props();
|
|
53
|
+
|
|
54
|
+
const sink = getContext<HttpStatusSink | undefined>(HTTP_STATUS_KEY);
|
|
55
|
+
|
|
56
|
+
if (sink) {
|
|
57
|
+
// svelte-ignore state_referenced_locally
|
|
58
|
+
// Intentional one-time write at component init: the sink is read by the
|
|
59
|
+
// server after `await render()` and a single value is the contract.
|
|
60
|
+
// Consumers that need to update the code mid-render should remount.
|
|
61
|
+
sink.code = code;
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Wraps an SSR tree with a render-scoped `HttpStatusSink`. `<HttpStatusCode />`
|
|
4
|
+
reads the sink via `getContext` and writes its `code` to it during component
|
|
5
|
+
init. Read `sink.code` after `await render()` to set the HTTP response
|
|
6
|
+
status.
|
|
7
|
+
|
|
8
|
+
```svelte
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import {
|
|
11
|
+
HttpStatusProvider,
|
|
12
|
+
createHttpStatusSink,
|
|
13
|
+
} from "@real-router/svelte/ssr";
|
|
14
|
+
|
|
15
|
+
const sink = createHttpStatusSink();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<HttpStatusProvider {sink}>
|
|
19
|
+
<App />
|
|
20
|
+
</HttpStatusProvider>
|
|
21
|
+
```
|
|
22
|
+
-->
|
|
23
|
+
<script lang="ts">
|
|
24
|
+
import { setContext } from "svelte";
|
|
25
|
+
|
|
26
|
+
import { HTTP_STATUS_KEY } from "../context";
|
|
27
|
+
|
|
28
|
+
import type { HttpStatusSink } from "../utils/createHttpStatusSink";
|
|
29
|
+
import type { Snippet } from "svelte";
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
sink: HttpStatusSink;
|
|
33
|
+
children: Snippet;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let { sink, children }: Props = $props();
|
|
37
|
+
|
|
38
|
+
// svelte-ignore state_referenced_locally
|
|
39
|
+
// The sink reference is captured once at provider init — replacing the sink
|
|
40
|
+
// mid-render isn't a supported usage pattern (the server reads it once
|
|
41
|
+
// after `await render()`).
|
|
42
|
+
setContext(HTTP_STATUS_KEY, sink);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
{@render children()}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { startsWithSegment } from "@real-router/route-utils";
|
|
2
|
+
|
|
3
|
+
// Snippet names reserved by RouteView for non-segment slots. Iteration in
|
|
4
|
+
// `getActiveSegment` skips these so they don't accidentally match a route.
|
|
5
|
+
const RESERVED_SLOT_NAMES = new Set(["self", "notFound"]);
|
|
6
|
+
|
|
7
|
+
export function getActiveSegment(
|
|
8
|
+
routeName: string,
|
|
9
|
+
node: string,
|
|
10
|
+
snippets: Record<string, unknown>,
|
|
11
|
+
): string {
|
|
12
|
+
const prefix = node ? `${node}.` : "";
|
|
13
|
+
|
|
14
|
+
for (const segment in snippets) {
|
|
15
|
+
if (RESERVED_SLOT_NAMES.has(segment)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (startsWithSegment(routeName, prefix + segment)) {
|
|
19
|
+
return segment;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
@@ -1,32 +1,8 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
import { startsWithSegment } from "@real-router/route-utils";
|
|
3
|
-
|
|
4
|
-
// Snippet names reserved by RouteView for non-segment slots. Iteration in
|
|
5
|
-
// `getActiveSegment` skips these so they don't accidentally match a route.
|
|
6
|
-
const RESERVED_SLOT_NAMES = new Set(["self", "notFound"]);
|
|
7
|
-
|
|
8
|
-
export function getActiveSegment(
|
|
9
|
-
routeName: string,
|
|
10
|
-
node: string,
|
|
11
|
-
snippets: Record<string, unknown>,
|
|
12
|
-
): string {
|
|
13
|
-
const prefix = node ? `${node}.` : "";
|
|
14
|
-
|
|
15
|
-
for (const segment in snippets) {
|
|
16
|
-
if (RESERVED_SLOT_NAMES.has(segment)) continue;
|
|
17
|
-
if (startsWithSegment(routeName, prefix + segment)) {
|
|
18
|
-
return segment;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
return "";
|
|
23
|
-
}
|
|
24
|
-
</script>
|
|
25
|
-
|
|
26
1
|
<script lang="ts">
|
|
27
2
|
import { UNKNOWN_ROUTE } from "@real-router/core";
|
|
28
3
|
|
|
29
4
|
import { useRouteNode } from "../composables/useRouteNode.svelte";
|
|
5
|
+
import { getActiveSegment } from "./RouteView.helpers";
|
|
30
6
|
|
|
31
7
|
import type { Snippet } from "svelte";
|
|
32
8
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: Snippet;
|
|
6
|
+
fallback?: Snippet;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let { children, fallback }: Props = $props();
|
|
10
|
+
|
|
11
|
+
let mounted = $state(false);
|
|
12
|
+
|
|
13
|
+
$effect(() => {
|
|
14
|
+
mounted = true;
|
|
15
|
+
});
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
{#if !mounted}
|
|
19
|
+
{@render children()}
|
|
20
|
+
{:else if fallback}
|
|
21
|
+
{@render fallback()}
|
|
22
|
+
{/if}
|