@real-router/svelte 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.
Files changed (54) hide show
  1. package/README.md +63 -11
  2. package/dist/RouterProvider.svelte +12 -3
  3. package/dist/components/Await.svelte +48 -0
  4. package/dist/components/Await.svelte.d.ts +50 -0
  5. package/dist/components/ClientOnly.svelte +22 -0
  6. package/dist/components/ClientOnly.svelte.d.ts +8 -0
  7. package/dist/components/HttpStatusCode.svelte +63 -0
  8. package/dist/components/HttpStatusCode.svelte.d.ts +45 -0
  9. package/dist/components/HttpStatusProvider.svelte +45 -0
  10. package/dist/components/HttpStatusProvider.svelte.d.ts +30 -0
  11. package/dist/components/RouteView.helpers.d.ts +1 -0
  12. package/dist/components/RouteView.helpers.js +16 -0
  13. package/dist/components/RouteView.svelte +1 -25
  14. package/dist/components/RouteView.svelte.d.ts +0 -1
  15. package/dist/components/ServerOnly.svelte +22 -0
  16. package/dist/components/ServerOnly.svelte.d.ts +8 -0
  17. package/dist/components/Streamed.svelte +37 -0
  18. package/dist/components/Streamed.svelte.d.ts +46 -0
  19. package/dist/composables/useDeferred.svelte.d.ts +24 -0
  20. package/dist/composables/useDeferred.svelte.js +34 -0
  21. package/dist/composables/useRoute.svelte.d.ts +8 -1
  22. package/dist/context.d.ts +1 -0
  23. package/dist/context.js +1 -0
  24. package/dist/dom-utils/__test-helpers/expected-fragment.d.ts +30 -0
  25. package/dist/dom-utils/__test-helpers/expected-fragment.js +43 -0
  26. package/dist/dom-utils/__test-helpers/index.d.ts +8 -0
  27. package/dist/dom-utils/__test-helpers/index.js +8 -0
  28. package/dist/dom-utils/link-utils.d.ts +23 -0
  29. package/dist/dom-utils/link-utils.js +106 -5
  30. package/dist/dom-utils/route-announcer.js +51 -2
  31. package/dist/dom-utils/scroll-restore.d.ts +38 -1
  32. package/dist/dom-utils/scroll-restore.js +144 -12
  33. package/dist/ssr.d.ts +9 -0
  34. package/dist/ssr.js +17 -0
  35. package/dist/types.d.ts +23 -0
  36. package/dist/utils/createHttpStatusSink.d.ts +28 -0
  37. package/dist/utils/createHttpStatusSink.js +3 -0
  38. package/package.json +10 -5
  39. package/src/RouterProvider.svelte +12 -3
  40. package/src/components/Await.svelte +48 -0
  41. package/src/components/ClientOnly.svelte +22 -0
  42. package/src/components/HttpStatusCode.svelte +63 -0
  43. package/src/components/HttpStatusProvider.svelte +45 -0
  44. package/src/components/RouteView.helpers.ts +24 -0
  45. package/src/components/RouteView.svelte +1 -25
  46. package/src/components/ServerOnly.svelte +22 -0
  47. package/src/components/Streamed.svelte +37 -0
  48. package/src/composables/useDeferred.svelte.ts +41 -0
  49. package/src/composables/useIsActiveRoute.svelte.ts +1 -1
  50. package/src/composables/useRoute.svelte.ts +11 -7
  51. package/src/context.ts +2 -0
  52. package/src/ssr.ts +28 -0
  53. package/src/types.ts +23 -0
  54. package/src/utils/createHttpStatusSink.ts +31 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`
3
+ * inside an SSR data loader. Returns the Promise — feed straight into Svelte's
4
+ * native `{#await}` block, or use `<Await name="key">` (this package) for the
5
+ * cross-adapter shape.
6
+ *
7
+ * ```svelte
8
+ * <script>
9
+ * import { useDeferred } from "@real-router/svelte/ssr";
10
+ * const reviewsPromise = useDeferred("reviews");
11
+ * </script>
12
+ *
13
+ * {#await reviewsPromise}
14
+ * <Spinner />
15
+ * {:then reviews}
16
+ * <ReviewList items={reviews} />
17
+ * {/await}
18
+ * ```
19
+ *
20
+ * Returns a forever-pending promise when the key is missing — surfaces
21
+ * loader/consumer key drift as a visible {#await} loading state rather than
22
+ * a silent runtime error.
23
+ */
24
+ export declare function useDeferred<T = unknown>(key: string): Promise<T>;
@@ -0,0 +1,34 @@
1
+ import { useRoute } from "./useRoute.svelte";
2
+ const NEVER_PROMISE = new Promise(() => {
3
+ // Intentionally never resolves — surfaces a forever-pending {#await} block
4
+ // when a key is requested that the loader never declared.
5
+ });
6
+ /**
7
+ * Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`
8
+ * inside an SSR data loader. Returns the Promise — feed straight into Svelte's
9
+ * native `{#await}` block, or use `<Await name="key">` (this package) for the
10
+ * cross-adapter shape.
11
+ *
12
+ * ```svelte
13
+ * <script>
14
+ * import { useDeferred } from "@real-router/svelte/ssr";
15
+ * const reviewsPromise = useDeferred("reviews");
16
+ * </script>
17
+ *
18
+ * {#await reviewsPromise}
19
+ * <Spinner />
20
+ * {:then reviews}
21
+ * <ReviewList items={reviews} />
22
+ * {/await}
23
+ * ```
24
+ *
25
+ * Returns a forever-pending promise when the key is missing — surfaces
26
+ * loader/consumer key drift as a visible {#await} loading state rather than
27
+ * a silent runtime error.
28
+ */
29
+ export function useDeferred(key) {
30
+ const { route } = useRoute();
31
+ const context = route.current.context;
32
+ const deferred = context.ssrDataDeferred;
33
+ return (deferred?.[key] ?? NEVER_PROMISE);
34
+ }
@@ -1,7 +1,14 @@
1
1
  import type { RouteContext } from "../types";
2
2
  import type { Params, State } from "@real-router/core";
3
- export declare const useRoute: <P extends Params = Params>() => Omit<RouteContext<P>, "route"> & {
3
+ /**
4
+ * `useRoute()`'s return type: same shape as `RouteContext<P>` but with
5
+ * `route.current` narrowed to non-nullable `State<P>` (the composable's
6
+ * `if (!ctx.route.current) throw` guard makes this safe).
7
+ */
8
+ type ActiveRouteContext<P extends Params> = Omit<RouteContext<P>, "route"> & {
4
9
  route: {
5
10
  readonly current: State<P>;
6
11
  };
7
12
  };
13
+ export declare const useRoute: <P extends Params = Params>() => ActiveRouteContext<P>;
14
+ export {};
package/dist/context.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export declare const ROUTER_KEY = "real-router:router";
2
2
  export declare const NAVIGATOR_KEY = "real-router:navigator";
3
3
  export declare const ROUTE_KEY = "real-router:route";
4
+ export declare const HTTP_STATUS_KEY = "real-router:http-status-sink";
4
5
  export declare function getContextOrThrow<T>(key: string, consumerName: string): T;
package/dist/context.js CHANGED
@@ -2,6 +2,7 @@ import { getContext } from "svelte";
2
2
  export const ROUTER_KEY = "real-router:router";
3
3
  export const NAVIGATOR_KEY = "real-router:navigator";
4
4
  export const ROUTE_KEY = "real-router:route";
5
+ export const HTTP_STATUS_KEY = "real-router:http-status-sink";
5
6
  // The type parameter is used by the caller to narrow the return type.
6
7
  // ESLint's no-unnecessary-type-parameters sees only a single textual use of T
7
8
  // (the return type) — but each call site supplies a different T, so it is not
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Test helper: mirror of `encodeFragmentInline` from `link-utils.ts` used by
3
+ * property tests in every adapter (`packages/{vue,preact,react,solid}/tests/
4
+ * property/linkUtils.properties.ts` and `packages/svelte/tests/property/
5
+ * buildHref.properties.ts`).
6
+ *
7
+ * Why a mirror, not an import of the real function: a property test that uses
8
+ * the production implementation to compute its own expected value is a
9
+ * tautology — any regression in `encodeFragmentInline` would be invisible.
10
+ * We hand-roll the same algorithm so the test asserts an independent
11
+ * derivation. Drift between this mirror and `encodeFragmentInline` is exactly
12
+ * the signal property tests are supposed to surface.
13
+ *
14
+ * This file lives under `__test-helpers/` so the sync-dom-utils script can
15
+ * exclude it from the Angular copy (it ships no production code), and so
16
+ * bundlers (tsdown, svelte-package) tree-shake it out — nothing in
17
+ * `src/index.ts` of any adapter imports from this directory.
18
+ */
19
+ /**
20
+ * Compute the expected fragment portion of an href for a given raw hash input.
21
+ *
22
+ * Mirrors the contract of `encodeFragmentInline`:
23
+ * 1. If input contains `%XX`, try `decodeURIComponent` → `encodeURI` (idempotent
24
+ * re-encoding so consumers can copy-paste `location.hash` back in without
25
+ * `%20` becoming `%2520`).
26
+ * 2. If `decodeURIComponent` throws (malformed `%XX`), fall through to plain
27
+ * `encodeURI` on the original input.
28
+ * 3. Defensive `#` → `%23` (encodeURI does not encode `#`).
29
+ */
30
+ export declare function computeExpectedFragment(rawHash: string): string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Test helper: mirror of `encodeFragmentInline` from `link-utils.ts` used by
3
+ * property tests in every adapter (`packages/{vue,preact,react,solid}/tests/
4
+ * property/linkUtils.properties.ts` and `packages/svelte/tests/property/
5
+ * buildHref.properties.ts`).
6
+ *
7
+ * Why a mirror, not an import of the real function: a property test that uses
8
+ * the production implementation to compute its own expected value is a
9
+ * tautology — any regression in `encodeFragmentInline` would be invisible.
10
+ * We hand-roll the same algorithm so the test asserts an independent
11
+ * derivation. Drift between this mirror and `encodeFragmentInline` is exactly
12
+ * the signal property tests are supposed to surface.
13
+ *
14
+ * This file lives under `__test-helpers/` so the sync-dom-utils script can
15
+ * exclude it from the Angular copy (it ships no production code), and so
16
+ * bundlers (tsdown, svelte-package) tree-shake it out — nothing in
17
+ * `src/index.ts` of any adapter imports from this directory.
18
+ */
19
+ const PERCENT_ESCAPE_PROBE = /%[\dA-Fa-f]{2}/;
20
+ /**
21
+ * Compute the expected fragment portion of an href for a given raw hash input.
22
+ *
23
+ * Mirrors the contract of `encodeFragmentInline`:
24
+ * 1. If input contains `%XX`, try `decodeURIComponent` → `encodeURI` (idempotent
25
+ * re-encoding so consumers can copy-paste `location.hash` back in without
26
+ * `%20` becoming `%2520`).
27
+ * 2. If `decodeURIComponent` throws (malformed `%XX`), fall through to plain
28
+ * `encodeURI` on the original input.
29
+ * 3. Defensive `#` → `%23` (encodeURI does not encode `#`).
30
+ */
31
+ export function computeExpectedFragment(rawHash) {
32
+ let roundtrip = rawHash;
33
+ if (PERCENT_ESCAPE_PROBE.test(rawHash)) {
34
+ try {
35
+ roundtrip = decodeURIComponent(rawHash);
36
+ }
37
+ catch {
38
+ // Malformed %XX — encodeFragmentInline falls through to plain
39
+ // encodeURI on the original input, so do the same here.
40
+ }
41
+ }
42
+ return encodeURI(roundtrip).replaceAll("#", "%23");
43
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Test-only barrel. Intentionally NOT re-exported from
3
+ * `shared/dom-utils/index.ts` so the helpers don't leak into adapters'
4
+ * public API surface. Property tests import from this path directly:
5
+ *
6
+ * import { computeExpectedFragment } from "../../src/dom-utils/__test-helpers";
7
+ */
8
+ export { computeExpectedFragment } from "./expected-fragment.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Test-only barrel. Intentionally NOT re-exported from
3
+ * `shared/dom-utils/index.ts` so the helpers don't leak into adapters'
4
+ * public API surface. Property tests import from this path directly:
5
+ *
6
+ * import { computeExpectedFragment } from "../../src/dom-utils/__test-helpers";
7
+ */
8
+ export { computeExpectedFragment } from "./expected-fragment.js";
@@ -18,5 +18,28 @@ export declare function buildHref(router: Router, routeName: string, routeParams
18
18
  }): string | undefined;
19
19
  export declare function navigateWithHash(router: Router, routeName: string, routeParams: Params, hash: string | undefined, extraOptions?: NavigationOptions): Promise<State>;
20
20
  export declare function buildActiveClassName(isActive: boolean, activeClassName: string | undefined, baseClassName: string | undefined): string | undefined;
21
+ /**
22
+ * One-level structural equality using `Object.is` per key.
23
+ *
24
+ * **String-keyed properties only (Mini-sprint E.3 — audit-5 §4.2 #3).**
25
+ * Implementation walks `Object.keys()` which by spec returns only
26
+ * enumerable own STRING keys. Symbol-keyed properties — created via
27
+ * `obj[Symbol("brand")] = value` or `{ [Symbol(...)]: value }` — are
28
+ * NOT compared. Two records that differ only in a Symbol-keyed value
29
+ * will compare as equal.
30
+ *
31
+ * This is intentional: route params and Link options are documented as
32
+ * string-keyed primitives (string | number | boolean) — Symbol-keyed
33
+ * metadata (e.g. brand markers, private state) doesn't belong in a
34
+ * cache-key comparison. Switching to `Reflect.ownKeys()` would extend
35
+ * the contract to symbols at the cost of one extra allocation per call
36
+ * (Reflect.ownKeys composes string-keys + symbol-keys arrays). If a
37
+ * consumer relies on symbol-keyed metadata for navigation
38
+ * disambiguation, they should encode it into a string key instead.
39
+ *
40
+ * Mirrors React's `shallowEqual` (packages/shared/shallowEqual.js) in
41
+ * both the string-keys-only semantics and the `hasOwnProperty` guard
42
+ * below.
43
+ */
21
44
  export declare function shallowEqual(prev: object | undefined, next: object | undefined): boolean;
22
45
  export declare function applyLinkA11y(element: HTMLElement | null | undefined): void;
@@ -5,14 +5,39 @@ export function shouldNavigate(evt) {
5
5
  !evt.ctrlKey &&
6
6
  !evt.shiftKey);
7
7
  }
8
+ // Matches a single percent-escape triple (`%` + two hex digits). Used as
9
+ // the "already-encoded" probe in `encodeFragmentInline` below — see the
10
+ // idempotency rationale there.
11
+ const PERCENT_ESCAPE_PROBE = /%[\dA-Fa-f]{2}/;
8
12
  /**
9
13
  * RFC 3986 fragment encoding: preserve sub-delims (`&`, `=`, `?`, `:`),
10
14
  * encode space, `%`, control chars, non-ASCII via encodeURI; defensively
11
15
  * escape `#` (encodeURI does not). Mirrors `encodeHashFragment` in
12
16
  * `shared/browser-env/url-context.ts` — duplicated here because the
13
17
  * shared/dom-utils symlink graph does not reach shared/browser-env.
18
+ *
19
+ * **Idempotency for pre-encoded input (audit-2026-05-17 §5 MEDIUM E.1).**
20
+ * The doc-comment on `<Link hash>` says the value is a "decoded fragment
21
+ * without leading #". But realistic consumers copy hashes out of
22
+ * `location.hash` (which is percent-encoded) and pass them back, so the
23
+ * naive `encodeURI("%20")` would double-encode into `"%2520"` and break
24
+ * anchor lookup. We detect a percent-escape triple in the input and, if
25
+ * present, decode + re-encode for idempotency. Malformed `%XX` (e.g.
26
+ * `"%2"` or `"%ZZ"`) makes `decodeURIComponent` throw — in that case we
27
+ * fall through to plain `encodeURI`, which never throws.
14
28
  */
15
29
  function encodeFragmentInline(decoded) {
30
+ if (PERCENT_ESCAPE_PROBE.test(decoded)) {
31
+ try {
32
+ const roundtrip = decodeURIComponent(decoded);
33
+ return encodeURI(roundtrip).replaceAll("#", "%23");
34
+ }
35
+ catch {
36
+ // Malformed `%XX` — fall through to the plain encoding path.
37
+ // encodeURI does not throw on malformed escapes; it treats the
38
+ // `%` as a literal and percent-encodes it (`%2` → `%252`).
39
+ }
40
+ }
16
41
  return encodeURI(decoded).replaceAll("#", "%23");
17
42
  }
18
43
  /**
@@ -38,11 +63,28 @@ export function buildHref(router, routeName, routeParams, options) {
38
63
  const buildUrl = router.buildUrl;
39
64
  if (buildUrl) {
40
65
  const url = buildUrl(routeName, routeParams, normHash === undefined ? undefined : { hash: normHash });
41
- if (url !== undefined) {
66
+ // Accept only non-empty strings. The BuildUrlFn type contract is
67
+ // `string | undefined`, but defensive against:
68
+ // - `""` (empty string) → would render `<a href="">`, which resolves
69
+ // to the current page URL → silent self-navigation on click.
70
+ // - `null` (type-contract violation) → would render `<a href={null}>`,
71
+ // stringified to `"null"` in some renderers.
72
+ // Either case falls through to the `router.buildPath` fallback below.
73
+ if (typeof url === "string" && url.length > 0) {
42
74
  return url;
43
75
  }
44
76
  }
45
77
  const path = router.buildPath(routeName, routeParams);
78
+ // Symmetric to the buildUrl guard above (#S1 audit, Invariant 12).
79
+ // `router.buildPath` is typed `string`, but defends against:
80
+ // - `""` (empty string) — would render `<a href="">`, which resolves
81
+ // to the current page URL → silent self-navigation on click.
82
+ // - non-string type-contract violations from custom path-matchers.
83
+ // Both yield `undefined` (renderer drops the attribute) with a warning.
84
+ if (typeof path !== "string" || path.length === 0) {
85
+ console.error(`[real-router] Route "${routeName}" yielded an empty path. The element will render without an href attribute.`);
86
+ return undefined;
87
+ }
46
88
  return normHash ? `${path}#${encodeFragmentInline(normHash)}` : path;
47
89
  }
48
90
  catch {
@@ -68,8 +110,25 @@ export function navigateWithHash(router, routeName, routeParams, hash, extraOpti
68
110
  }
69
111
  return router.navigate(routeName, routeParams, opts);
70
112
  }
113
+ // Match-any-whitespace regex shared across calls. RegExp literals at
114
+ // call-site recompile in some engines; lifting it avoids that microcost
115
+ // for the slow-path branch.
116
+ const WHITESPACE_PROBE = /\s/;
117
+ const WHITESPACE_SPLIT = /\S+/g;
71
118
  function parseTokens(value) {
72
- return value ? (value.match(/\S+/g) ?? []) : [];
119
+ if (!value) {
120
+ return [];
121
+ }
122
+ // Hot-path fast-path (audit-2026-05-17 §8b #1): >99% of active-class
123
+ // inputs at `<Link>` emit are single-token strings like `"active"` or
124
+ // `"is-current"` — no whitespace, no leading/trailing pad. Skip the
125
+ // regex match and Array result allocation: a literal `[value]` works
126
+ // because the slow-path `match(/\S+/g)` would return exactly `[value]`
127
+ // for the same input. PBT lock: linkUtils.properties.ts Invariant 13.
128
+ if (!WHITESPACE_PROBE.test(value)) {
129
+ return [value];
130
+ }
131
+ return value.match(WHITESPACE_SPLIT) ?? [];
73
132
  }
74
133
  export function buildActiveClassName(isActive, activeClassName, baseClassName) {
75
134
  if (isActive && activeClassName) {
@@ -92,6 +151,29 @@ export function buildActiveClassName(isActive, activeClassName, baseClassName) {
92
151
  }
93
152
  return baseClassName ?? undefined;
94
153
  }
154
+ /**
155
+ * One-level structural equality using `Object.is` per key.
156
+ *
157
+ * **String-keyed properties only (Mini-sprint E.3 — audit-5 §4.2 #3).**
158
+ * Implementation walks `Object.keys()` which by spec returns only
159
+ * enumerable own STRING keys. Symbol-keyed properties — created via
160
+ * `obj[Symbol("brand")] = value` or `{ [Symbol(...)]: value }` — are
161
+ * NOT compared. Two records that differ only in a Symbol-keyed value
162
+ * will compare as equal.
163
+ *
164
+ * This is intentional: route params and Link options are documented as
165
+ * string-keyed primitives (string | number | boolean) — Symbol-keyed
166
+ * metadata (e.g. brand markers, private state) doesn't belong in a
167
+ * cache-key comparison. Switching to `Reflect.ownKeys()` would extend
168
+ * the contract to symbols at the cost of one extra allocation per call
169
+ * (Reflect.ownKeys composes string-keys + symbol-keys arrays). If a
170
+ * consumer relies on symbol-keyed metadata for navigation
171
+ * disambiguation, they should encode it into a string key instead.
172
+ *
173
+ * Mirrors React's `shallowEqual` (packages/shared/shallowEqual.js) in
174
+ * both the string-keys-only semantics and the `hasOwnProperty` guard
175
+ * below.
176
+ */
95
177
  export function shallowEqual(prev, next) {
96
178
  if (Object.is(prev, next)) {
97
179
  return true;
@@ -106,7 +188,11 @@ export function shallowEqual(prev, next) {
106
188
  const prevRecord = prev;
107
189
  const nextRecord = next;
108
190
  for (const key of prevKeys) {
109
- if (!Object.is(prevRecord[key], nextRecord[key])) {
191
+ // hasOwnProperty guard: without it, a key missing in `next` reads as
192
+ // `undefined` and falsely matches `prev[key] === undefined`. Same shape
193
+ // as React's shallowEqual (packages/shared/shallowEqual.js).
194
+ if (!Object.prototype.hasOwnProperty.call(next, key) ||
195
+ !Object.is(prevRecord[key], nextRecord[key])) {
110
196
  return false;
111
197
  }
112
198
  }
@@ -116,8 +202,23 @@ export function applyLinkA11y(element) {
116
202
  if (!element) {
117
203
  return;
118
204
  }
119
- if (element instanceof HTMLAnchorElement ||
120
- element instanceof HTMLButtonElement) {
205
+ // Cross-realm safety (audit-2026-05-17 §5 HIGH #4):
206
+ // `instanceof HTMLAnchorElement` compares against the constructor from
207
+ // the CURRENT realm. An element created in a different window (iframe
208
+ // contentDocument, micro-frontend, embedded widget) fails the check
209
+ // even when it IS a real anchor — the helper would then inject
210
+ // role="link" + tabindex="0" on top of native anchor semantics,
211
+ // breaking screen reader output ("link link") and focus order.
212
+ //
213
+ // tagName is realm-agnostic and is uppercase for HTML-namespaced
214
+ // elements in any document. SVG `<a>` has lowercase tagName plus a
215
+ // different prototype (SVGAElement) — skipping it here is wrong by
216
+ // accident: SVG anchors don't have keyboard activation semantics the
217
+ // helper would add. But they also don't reach this helper in
218
+ // practice (router Link components emit HTML anchors). Lock the
219
+ // uppercase compare to keep the contract narrow.
220
+ const tag = element.tagName;
221
+ if (tag === "A" || tag === "BUTTON") {
121
222
  return;
122
223
  }
123
224
  if (!element.hasAttribute("role")) {
@@ -3,7 +3,24 @@ const SAFARI_READY_DELAY = 100;
3
3
  const ANNOUNCER_ATTR = "data-real-router-announcer";
4
4
  const INTERNAL_ROUTE_PREFIX = "@@";
5
5
  const VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);clip-path:inset(50%);white-space:nowrap;border:0";
6
+ const NOOP_INSTANCE = Object.freeze({
7
+ destroy: () => {
8
+ /* no-op */
9
+ },
10
+ });
6
11
  export function createRouteAnnouncer(router, options) {
12
+ // Defensive SSR / non-browser guard: in SSR (Node.js) or non-DOM
13
+ // environments, `document` is undefined and the announcer cannot
14
+ // attach its aria-live region. Return a frozen NOOP_INSTANCE — same
15
+ // pattern as `createDirectionTracker`, `createScrollRestoration`, and
16
+ // `createViewTransitions`. Without this guard, `NavigationAnnouncer`
17
+ // component construction would throw `ReferenceError: document is not
18
+ // defined` under `@angular/ssr` rendering, tearing down the whole SSR
19
+ // bootstrap. Closes review-2026-05-10 §5.10 ⛔ "NavigationAnnouncer
20
+ // SSR mode" MED.
21
+ if (typeof document === "undefined") {
22
+ return NOOP_INSTANCE;
23
+ }
7
24
  const prefix = options?.prefix ?? "Navigated to ";
8
25
  const getCustomText = options?.getAnnouncementText;
9
26
  let isInitialNavigation = true;
@@ -84,7 +101,19 @@ function getOrCreateAnnouncer() {
84
101
  element.setAttribute("aria-live", "assertive");
85
102
  element.setAttribute("aria-atomic", "true");
86
103
  element.setAttribute(ANNOUNCER_ATTR, "");
87
- document.body.prepend(element);
104
+ // Defensive SSR / pre-`<body>` guard: in some environments (early
105
+ // injection, deferred-body documents, certain SSR rehydration paths)
106
+ // `document.body` can be null when the announcer is constructed.
107
+ // `document.body.prepend(...)` would throw `TypeError: Cannot read
108
+ // properties of null`, tearing down the consumer's RouterProvider /
109
+ // NavigationAnnouncer mount. Fallback to `documentElement` keeps the
110
+ // announcer working for SR users; visual-hidden styling means there is
111
+ // no visible artifact regardless of mount point.
112
+ //
113
+ // TS dom lib types `document.body` as `HTMLElement` (non-null), but
114
+ // runtime can return null per spec. The `as` cast narrows the type to
115
+ // include null so the `??` short-circuit is type-safe.
116
+ (document.body ?? document.documentElement).prepend(element);
88
117
  return element;
89
118
  }
90
119
  function removeAnnouncer() {
@@ -92,7 +121,27 @@ function removeAnnouncer() {
92
121
  }
93
122
  function resolveText(route, prefix, getCustomText, h1) {
94
123
  if (getCustomText) {
95
- return getCustomText(route);
124
+ try {
125
+ const customText = getCustomText(route);
126
+ // Mini-sprint E.4 (audit-5 §4.2 #4) — empty-string fallback.
127
+ // A consumer pattern like
128
+ // getAnnouncementText: (route) => myMap[route.name] ?? ""
129
+ // returns `""` for routes outside the map. The subscribe loop
130
+ // then sees an empty text and silently no-announces — screen
131
+ // readers stay quiet without any signal to the developer. Treat
132
+ // a falsy custom result (`""` / `null` / `undefined`) as
133
+ // "consumer doesn't have a name for this route" and fall through
134
+ // to the default resolution chain (h1 → title → route name).
135
+ if (customText) {
136
+ return customText;
137
+ }
138
+ }
139
+ catch (error) {
140
+ // A throwing consumer callback inside the router's subscribe loop
141
+ // would tear down sibling listeners — log and fall through to the
142
+ // built-in resolution chain so the announcer keeps working.
143
+ console.error("[real-router] getAnnouncementText threw; falling back to default resolution.", error);
144
+ }
96
145
  }
97
146
  const h1Text = (h1?.textContent ?? "").trim();
98
147
  const routeName = route.name.startsWith(INTERNAL_ROUTE_PREFIX)
@@ -1,4 +1,4 @@
1
- import type { Router } from "@real-router/core";
1
+ import type { Router, State } from "@real-router/core";
2
2
  export type ScrollRestorationMode = "restore" | "top" | "native";
3
3
  export interface ScrollRestorationOptions {
4
4
  mode?: ScrollRestorationMode | undefined;
@@ -29,3 +29,40 @@ export interface ScrollRestorationOptions {
29
29
  export declare function createScrollRestoration(router: Router, options?: ScrollRestorationOptions): {
30
30
  destroy: () => void;
31
31
  };
32
+ export declare function keyOf(state: State): string;
33
+ /**
34
+ * Stable JSON serializer with sorted object keys.
35
+ *
36
+ * **Exported for testing only — not part of the public API** (intentionally
37
+ * excluded from `index.ts` barrel). Adapter property tests import it via
38
+ * the direct path to lock the key-order-insensitive property
39
+ * (`canonicalJson({a:1,b:2}) === canonicalJson({b:2,a:1})`).
40
+ *
41
+ * ## Divergence from `@real-router/sources/canonicalJson` — by design
42
+ *
43
+ * Two independent implementations live in the monorepo:
44
+ *
45
+ * - **`shared/dom-utils/scroll-restore.canonicalJson`** (this file) — scroll
46
+ * cache key builder. Uses `localeCompare` and a plain-object accumulator;
47
+ * tolerates `__proto__`-keyed inputs only insofar as `JSON.stringify`'s
48
+ * replacer happens to sort them; relies on `JSON.stringify`'s native cycle
49
+ * detector. Designed to be cheap on the navigation hot path. The
50
+ * surrounding [[safeKeyOf]] wrapper catches the two crash inputs (`BigInt`,
51
+ * cyclic) and skips the offending capture/restore.
52
+ *
53
+ * - **`@real-router/sources/canonicalJson`** — sources cache key builder.
54
+ * Uses byte-order compare (`< / >`) for locale-independence, a
55
+ * `Object.create(null)` accumulator to prevent prototype pollution, and a
56
+ * bespoke path-based cycle detector (the native one cannot see the cloned
57
+ * graph). Throws eagerly on `Map`/`Set`/`RegExp`/cycles — the caller falls
58
+ * back to a non-cached source.
59
+ *
60
+ * **They are intentionally NOT interchangeable.** Aligning them would either
61
+ * regress scroll-restore performance (byte-order + recursive clone is heavier
62
+ * per call) or weaken the sources cache (locale dependence breaks
63
+ * deterministic cache keys across machines). No cross-package equivalence
64
+ * test exists or should be added; the relationship is "different invariants,
65
+ * different costs, different consumers." Audit-2 / audit-2026-05-17 §2
66
+ * documents the choice.
67
+ */
68
+ export declare function canonicalJson(value: unknown): string;