@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.
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|