@real-router/vue 0.12.1 → 0.14.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 +98 -14
- package/dist/cjs/createHttpStatusSink-XDu5aGhc.d.ts +32 -0
- package/dist/cjs/createHttpStatusSink-XDu5aGhc.d.ts.map +1 -0
- package/dist/cjs/index.d.ts +48 -32
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/ssr.d.ts +180 -0
- package/dist/cjs/ssr.d.ts.map +1 -0
- package/dist/cjs/ssr.js +2 -0
- package/dist/cjs/ssr.js.map +1 -0
- package/dist/cjs/useRoute-BT3SkdOc.js +2 -0
- package/dist/cjs/useRoute-BT3SkdOc.js.map +1 -0
- package/dist/esm/createHttpStatusSink-DduXvbGr.d.mts +32 -0
- package/dist/esm/createHttpStatusSink-DduXvbGr.d.mts.map +1 -0
- package/dist/esm/index.d.mts +48 -32
- package/dist/esm/index.d.mts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/ssr.d.mts +180 -0
- package/dist/esm/ssr.d.mts.map +1 -0
- package/dist/esm/ssr.mjs +2 -0
- package/dist/esm/ssr.mjs.map +1 -0
- package/dist/esm/useRoute-2ocUdDHc.mjs +2 -0
- package/dist/esm/useRoute-2ocUdDHc.mjs.map +1 -0
- package/package.json +20 -4
- package/src/RouterProvider.ts +45 -39
- package/src/components/Await.ts +47 -0
- package/src/components/ClientOnly.ts +16 -0
- package/src/components/HttpStatusCode.ts +74 -0
- package/src/components/HttpStatusProvider.ts +22 -0
- package/src/components/Link.ts +33 -13
- package/src/components/RouteView/RouteView.ts +30 -51
- package/src/components/RouteView/helpers.ts +33 -2
- package/src/components/ServerOnly.ts +16 -0
- package/src/components/Streamed.ts +31 -0
- package/src/composables/useDeferred.ts +37 -0
- package/src/composables/useIsActiveRoute.ts +33 -3
- package/src/composables/useRoute.ts +11 -5
- package/src/context.ts +4 -0
- package/src/directives/vLink.ts +18 -1
- package/src/index.ts +2 -1
- package/src/ssr.ts +39 -0
- package/src/types.ts +10 -0
- package/src/utils/createHttpStatusSink.ts +31 -0
package/src/components/Link.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createActiveRouteSource } from "@real-router/sources";
|
|
1
|
+
import { canonicalJson, createActiveRouteSource } from "@real-router/sources";
|
|
2
2
|
import { defineComponent, h, computed, shallowRef, watch } from "vue";
|
|
3
3
|
|
|
4
4
|
import { useRouter } from "../composables/useRouter";
|
|
@@ -18,6 +18,13 @@ type OnClickHandler = (evt: MouseEvent) => void;
|
|
|
18
18
|
/**
|
|
19
19
|
* Vue's compiled template binds multiple `@click` handlers as an array.
|
|
20
20
|
* Single render-function `onClick` is a function. Both must be invoked.
|
|
21
|
+
*
|
|
22
|
+
* The function-branch deliberately omits a `defaultPrevented` check: the
|
|
23
|
+
* single call short-circuits naturally and control returns to the caller
|
|
24
|
+
* (`handleClick`), which then re-reads `evt.defaultPrevented` on the same
|
|
25
|
+
* MouseEvent. The array-branch needs the per-iteration check because the
|
|
26
|
+
* caller cannot observe intermediate handlers — without it, later handlers
|
|
27
|
+
* would still run after an earlier one called `preventDefault()`.
|
|
21
28
|
*/
|
|
22
29
|
function invokeAttributesOnClick(value: unknown, evt: MouseEvent): void {
|
|
23
30
|
if (typeof value === "function") {
|
|
@@ -96,24 +103,37 @@ export const Link = defineComponent({
|
|
|
96
103
|
|
|
97
104
|
const isActive = shallowRef(false);
|
|
98
105
|
|
|
99
|
-
// watch with an explicit dep getter recreates the source ONLY when
|
|
100
|
-
// routeName/routeParams/strict/ignoreQueryParams
|
|
101
|
-
//
|
|
102
|
-
//
|
|
106
|
+
// watch with an explicit dep getter recreates the source ONLY when the
|
|
107
|
+
// structural identity of routeName/routeParams/strict/ignoreQueryParams/
|
|
108
|
+
// hash changes — not on every parent rerender that hands a fresh
|
|
109
|
+
// `routeParams` literal with the same shape.
|
|
110
|
+
//
|
|
111
|
+
// Hot-path note: inline `:routeParams="{ id: 1 }"` in a parent template
|
|
112
|
+
// allocates a new object each render. Comparing by reference would
|
|
113
|
+
// tear down + recreate the ActiveRouteSource subscription on every
|
|
114
|
+
// unrelated parent state change. `canonicalJson(routeParams)` collapses
|
|
115
|
+
// structurally-equal objects to the same key-order-stable string, so the
|
|
116
|
+
// subscription persists across re-renders that don't change shape.
|
|
117
|
+
// (The source's own per-router cache uses the same canonical key under
|
|
118
|
+
// the hood — this watch dep just mirrors it at the consumer layer.)
|
|
103
119
|
watch(
|
|
104
120
|
() =>
|
|
105
121
|
[
|
|
106
122
|
props.routeName,
|
|
107
|
-
props.routeParams,
|
|
123
|
+
canonicalJson(props.routeParams),
|
|
108
124
|
props.activeStrict,
|
|
109
125
|
props.ignoreQueryParams,
|
|
110
126
|
props.hash,
|
|
111
127
|
] as const,
|
|
112
128
|
(
|
|
113
|
-
[routeName,
|
|
129
|
+
[routeName, _paramsKey, activeStrict, ignoreQueryParams, hash],
|
|
114
130
|
_prev,
|
|
115
131
|
onCleanup,
|
|
116
132
|
) => {
|
|
133
|
+
// Re-read the raw `routeParams` ref when constructing the source —
|
|
134
|
+
// canonicalJson was only used for change-detection above, the source
|
|
135
|
+
// factory still wants the live object.
|
|
136
|
+
const routeParams = props.routeParams;
|
|
117
137
|
// Hash-aware active (#532): pass hash through so tab links with the
|
|
118
138
|
// same routeName but different `hash` props don't all light up.
|
|
119
139
|
const source = createActiveRouteSource(
|
|
@@ -182,13 +202,13 @@ export const Link = defineComponent({
|
|
|
182
202
|
// double-invoke user handlers when combined with our explicit `onClick`.
|
|
183
203
|
// We invoke the original attrs.onClick manually inside handleClick so the
|
|
184
204
|
// preventDefault contract is preserved.
|
|
185
|
-
|
|
205
|
+
//
|
|
206
|
+
// Spread + delete avoids the per-key copy loop on every render — one
|
|
207
|
+
// allocation + one property deletion instead of N iterations across
|
|
208
|
+
// data-*, aria-*, role, etc. Hot-path optimisation for Link-heavy pages.
|
|
209
|
+
const restAttributes = { ...attrs };
|
|
186
210
|
|
|
187
|
-
|
|
188
|
-
if (key !== "onClick") {
|
|
189
|
-
restAttributes[key] = (attrs as Record<string, unknown>)[key];
|
|
190
|
-
}
|
|
191
|
-
}
|
|
211
|
+
delete restAttributes.onClick;
|
|
192
212
|
|
|
193
213
|
return h(
|
|
194
214
|
"a",
|
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
} from "vue";
|
|
9
9
|
|
|
10
10
|
import { Match, NotFound, Self } from "./components";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildRenderList,
|
|
13
|
+
collectElements,
|
|
14
|
+
isKeepAliveEnabled,
|
|
15
|
+
} from "./helpers";
|
|
12
16
|
import { useRouteNode } from "../../composables/useRouteNode";
|
|
13
17
|
|
|
14
18
|
import type { Component, VNode } from "vue";
|
|
@@ -66,14 +70,23 @@ function wrapWithSuspense(content: VNode, fallback: unknown): VNode {
|
|
|
66
70
|
);
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
// Lazy-initialised — only allocated when a per-Match keepAlive path needs to
|
|
74
|
+
// keep the cache slot "occupied" with a no-render placeholder. Apps without
|
|
75
|
+
// keepAlive never pay the markRaw + defineComponent allocation at import.
|
|
76
|
+
let emptyKeepAlivePlaceholderInstance: Component | null = null;
|
|
77
|
+
|
|
78
|
+
function getEmptyKeepAlivePlaceholder(): Component {
|
|
79
|
+
emptyKeepAlivePlaceholderInstance ??= markRaw(
|
|
80
|
+
defineComponent({
|
|
81
|
+
name: "KeepAlive-placeholder",
|
|
82
|
+
render() {
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return emptyKeepAlivePlaceholderInstance;
|
|
89
|
+
}
|
|
77
90
|
|
|
78
91
|
function renderWithRootKA(
|
|
79
92
|
activeChild: VNode,
|
|
@@ -92,17 +105,6 @@ function renderWithRootKA(
|
|
|
92
105
|
return wrapWithSuspense(keepAliveContent, fallback);
|
|
93
106
|
}
|
|
94
107
|
|
|
95
|
-
// Vue compiles boolean-shorthand template attributes (`<Match keepAlive>`) to
|
|
96
|
-
// an empty string instead of `true`, and converts them to `true` only when the
|
|
97
|
-
// receiving component's prop is declared with `type: Boolean`. `Match` is a
|
|
98
|
-
// marker component (`render: null`) — its props are inspected on the VNode
|
|
99
|
-
// without ever going through Vue's prop-casting pipeline, so the raw `""` (or
|
|
100
|
-
// the hyphenated attribute name) reaches us here. Accept the same trio Vue's
|
|
101
|
-
// runtime does.
|
|
102
|
-
function isKeepAliveEnabled(value: unknown): boolean {
|
|
103
|
-
return value === true || value === "" || value === "keep-alive";
|
|
104
|
-
}
|
|
105
|
-
|
|
106
108
|
function renderWithPerMatchKA(
|
|
107
109
|
activeChild: VNode,
|
|
108
110
|
wrapperCache: Map<string, Component>,
|
|
@@ -137,7 +139,9 @@ function renderWithPerMatchKA(
|
|
|
137
139
|
/* v8 ignore stop */
|
|
138
140
|
|
|
139
141
|
return h(Fragment, [
|
|
140
|
-
h(KeepAlive, null, {
|
|
142
|
+
h(KeepAlive, null, {
|
|
143
|
+
default: () => h(getEmptyKeepAlivePlaceholder()),
|
|
144
|
+
}),
|
|
141
145
|
wrapWithSuspense(h(Fragment, content), fallback),
|
|
142
146
|
]);
|
|
143
147
|
}
|
|
@@ -158,33 +162,6 @@ const RouteViewComponent = defineComponent({
|
|
|
158
162
|
const routeContext = useRouteNode(props.nodeName);
|
|
159
163
|
const wrapperCache = new Map<string, Component>();
|
|
160
164
|
|
|
161
|
-
// Cache per-Match `keepAlive` detection by slot output identity. Slot
|
|
162
|
-
// contents change reference only when the parent re-renders with new
|
|
163
|
-
// children, so steady-state navigations skip the O(n) `.some(...)` scan.
|
|
164
|
-
let lastSlotOutput: unknown = null;
|
|
165
|
-
let lastHasPerMatchKA = false;
|
|
166
|
-
|
|
167
|
-
function detectPerMatchKA(elements: VNode[], slotOutput: unknown): boolean {
|
|
168
|
-
/* v8 ignore next 3 -- @preserve: Vue's compiled slot wrapper allocates a
|
|
169
|
-
new array per render call in JSDOM tests; identity-cache hits in
|
|
170
|
-
production where parent compiled templates share slot output, but
|
|
171
|
-
is unobservable through TestBed-style assertions. */
|
|
172
|
-
if (slotOutput === lastSlotOutput) {
|
|
173
|
-
return lastHasPerMatchKA;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
lastSlotOutput = slotOutput;
|
|
177
|
-
lastHasPerMatchKA = elements.some(
|
|
178
|
-
(element) =>
|
|
179
|
-
element.type === Match &&
|
|
180
|
-
isKeepAliveEnabled(
|
|
181
|
-
(element.props as { keepAlive?: unknown } | null)?.keepAlive,
|
|
182
|
-
),
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
return lastHasPerMatchKA;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
165
|
return (): VNode | null => {
|
|
189
166
|
const route = routeContext.route.value;
|
|
190
167
|
|
|
@@ -197,7 +174,11 @@ const RouteViewComponent = defineComponent({
|
|
|
197
174
|
|
|
198
175
|
collectElements(slotOutput, elements);
|
|
199
176
|
|
|
200
|
-
|
|
177
|
+
// `hasPerMatchKA` is a side-channel produced by the same pipeline pass
|
|
178
|
+
// that builds `rendered` — closes the audit §8.1 "double iteration"
|
|
179
|
+
// finding. The previous identity-cache on `slotOutput` is no longer
|
|
180
|
+
// needed: per-render cost is one O(n) walk instead of two.
|
|
181
|
+
const { rendered, fallback, hasPerMatchKA } = buildRenderList(
|
|
201
182
|
elements,
|
|
202
183
|
route.name,
|
|
203
184
|
props.nodeName,
|
|
@@ -223,8 +204,6 @@ const RouteViewComponent = defineComponent({
|
|
|
223
204
|
}
|
|
224
205
|
/* v8 ignore stop */
|
|
225
206
|
|
|
226
|
-
const hasPerMatchKA = detectPerMatchKA(elements, slotOutput);
|
|
227
|
-
|
|
228
207
|
if (hasPerMatchKA) {
|
|
229
208
|
return renderWithPerMatchKA(activeChild, wrapperCache, fallback);
|
|
230
209
|
}
|
|
@@ -14,7 +14,7 @@ interface FallbackSlots {
|
|
|
14
14
|
notFoundChildren: unknown;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
function isSegmentMatch(
|
|
17
|
+
export function isSegmentMatch(
|
|
18
18
|
routeName: string,
|
|
19
19
|
fullSegmentName: string,
|
|
20
20
|
exact: boolean,
|
|
@@ -26,6 +26,17 @@ function isSegmentMatch(
|
|
|
26
26
|
return startsWithSegment(routeName, fullSegmentName);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Vue compiles boolean-shorthand template attributes (`<Match keepAlive>`) to
|
|
30
|
+
// an empty string instead of `true`, and converts them to `true` only when the
|
|
31
|
+
// receiving component's prop is declared with `type: Boolean`. `Match` is a
|
|
32
|
+
// marker component (`render: null`) — its props are inspected on the VNode
|
|
33
|
+
// without ever going through Vue's prop-casting pipeline, so the raw `""` (or
|
|
34
|
+
// the hyphenated attribute name) reaches us here. Accept the same trio Vue's
|
|
35
|
+
// runtime does.
|
|
36
|
+
export function isKeepAliveEnabled(value: unknown): boolean {
|
|
37
|
+
return value === true || value === "" || value === "keep-alive";
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
function normalizeChildren(children: unknown): VNode[] {
|
|
30
41
|
if (Array.isArray(children)) {
|
|
31
42
|
const result: VNode[] = [];
|
|
@@ -137,6 +148,14 @@ export function buildRenderList(
|
|
|
137
148
|
rendered: VNode[];
|
|
138
149
|
activeMatchFound: boolean;
|
|
139
150
|
fallback?: FallbackType;
|
|
151
|
+
/**
|
|
152
|
+
* True iff any `<Match>` child in the input has its `keepAlive` prop set
|
|
153
|
+
* to one of Vue's accepted boolean-shorthand forms. Surfaced as a
|
|
154
|
+
* side-channel from the single pipeline pass so the caller doesn't have
|
|
155
|
+
* to re-iterate `elements` after `buildRenderList` returns — closes a MED
|
|
156
|
+
* code-quality finding (audit §8.1).
|
|
157
|
+
*/
|
|
158
|
+
hasPerMatchKA: boolean;
|
|
140
159
|
} {
|
|
141
160
|
const slots: FallbackSlots = {
|
|
142
161
|
selfVNode: null,
|
|
@@ -145,9 +164,21 @@ export function buildRenderList(
|
|
|
145
164
|
};
|
|
146
165
|
let activeMatchFound = false;
|
|
147
166
|
let fallback: FallbackType = undefined;
|
|
167
|
+
let hasPerMatchKA = false;
|
|
148
168
|
const rendered: VNode[] = [];
|
|
149
169
|
|
|
150
170
|
for (const child of elements) {
|
|
171
|
+
// Match-only side-channel: scan for the keepAlive shorthand in the same
|
|
172
|
+
// pass that already inspects every child. Short-circuits once a positive
|
|
173
|
+
// is found to avoid redundant prop reads in big slot trees.
|
|
174
|
+
if (!hasPerMatchKA && child.type === Match) {
|
|
175
|
+
const matchProps = child.props as { keepAlive?: unknown } | null;
|
|
176
|
+
|
|
177
|
+
if (isKeepAliveEnabled(matchProps?.keepAlive)) {
|
|
178
|
+
hasPerMatchKA = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
if (recordFallback(child, slots)) {
|
|
152
183
|
continue;
|
|
153
184
|
}
|
|
@@ -169,5 +200,5 @@ export function buildRenderList(
|
|
|
169
200
|
fallback = appendFallback(rendered, routeName, nodeName, slots, elements);
|
|
170
201
|
}
|
|
171
202
|
|
|
172
|
-
return { rendered, activeMatchFound, fallback };
|
|
203
|
+
return { rendered, activeMatchFound, fallback, hasPerMatchKA };
|
|
173
204
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineComponent, onMounted, ref } from "vue";
|
|
2
|
+
|
|
3
|
+
export const ServerOnly = defineComponent({
|
|
4
|
+
name: "ServerOnly",
|
|
5
|
+
setup(_, { slots }) {
|
|
6
|
+
const mounted = ref(false);
|
|
7
|
+
|
|
8
|
+
onMounted(() => {
|
|
9
|
+
mounted.value = true;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return () => (mounted.value ? slots.fallback?.() : slots.default?.());
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type ServerOnlyProps = InstanceType<typeof ServerOnly>["$props"];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineComponent, h, Suspense } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-adapter alias for Vue's native `<Suspense>`. Symmetric naming with
|
|
5
|
+
* the React/Preact/Solid/Svelte/Angular `<Streamed>` components.
|
|
6
|
+
*
|
|
7
|
+
* Slots:
|
|
8
|
+
* - `default` — content (may contain `<Await>` or `async setup()` children).
|
|
9
|
+
* - `fallback` — shown while any descendant suspends.
|
|
10
|
+
*
|
|
11
|
+
* Vue's `<Suspense>` is **blocking** under SSR (no out-of-order placeholder
|
|
12
|
+
* resolution) — render of HTML after `<Streamed>` waits for every
|
|
13
|
+
* `async setup()` inside. This matches Vue 3's stable streaming behaviour
|
|
14
|
+
* (vs React 19 / Solid which support OOO resolution).
|
|
15
|
+
*/
|
|
16
|
+
export const Streamed = defineComponent({
|
|
17
|
+
name: "Streamed",
|
|
18
|
+
setup(_, { slots }) {
|
|
19
|
+
return () =>
|
|
20
|
+
h(
|
|
21
|
+
Suspense,
|
|
22
|
+
{},
|
|
23
|
+
{
|
|
24
|
+
default: () => slots.default?.(),
|
|
25
|
+
fallback: () => slots.fallback?.(),
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export type StreamedProps = InstanceType<typeof Streamed>["$props"];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useRoute } from "./useRoute";
|
|
2
|
+
|
|
3
|
+
interface DeferredContext {
|
|
4
|
+
ssrDataDeferred?: Record<string, Promise<unknown>>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const NEVER_PROMISE = new Promise<never>(() => {
|
|
8
|
+
// Intentionally never resolves — surfaces a forever-pending Suspense boundary
|
|
9
|
+
// when a key is requested that the loader never declared.
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`
|
|
14
|
+
* inside an SSR data loader. Returns the Promise for use inside `async setup()`
|
|
15
|
+
* (Vue's native Suspense pattern) or paired with `<Await name="key">`.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* // Vue async setup pattern
|
|
19
|
+
* export default defineComponent({
|
|
20
|
+
* async setup() {
|
|
21
|
+
* const reviews = await useDeferred<Review[]>("reviews");
|
|
22
|
+
* return () => h("div", reviews.map(...));
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Returns a forever-pending promise when the key is missing — surfaces
|
|
28
|
+
* loader/consumer key drift as a visible Suspense fallback rather than a
|
|
29
|
+
* silent runtime error.
|
|
30
|
+
*/
|
|
31
|
+
export function useDeferred<T = unknown>(key: string): Promise<T> {
|
|
32
|
+
const { route } = useRoute();
|
|
33
|
+
const context = route.value.context as DeferredContext;
|
|
34
|
+
const deferred = context.ssrDataDeferred;
|
|
35
|
+
|
|
36
|
+
return (deferred?.[key] ?? NEVER_PROMISE) as Promise<T>;
|
|
37
|
+
}
|
|
@@ -6,14 +6,44 @@ import { useRouter } from "./useRouter";
|
|
|
6
6
|
import type { Params } from "@real-router/core";
|
|
7
7
|
import type { ShallowRef } from "vue";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Options object for `useIsActiveRoute`. Replaces the previous trailing
|
|
11
|
+
* positional booleans (`strict`, `ignoreQueryParams`) — positional flags at
|
|
12
|
+
* call sites read as magic numbers and the order was easy to swap silently.
|
|
13
|
+
*
|
|
14
|
+
* The composable is `@internal` (consumed by `<Link>` and tests only), so
|
|
15
|
+
* the signature changes without a deprecation cycle.
|
|
16
|
+
*/
|
|
17
|
+
export interface UseIsActiveRouteOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Match the route name exactly (no descendant match). Default: `false`.
|
|
20
|
+
*/
|
|
21
|
+
strict?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Ignore query params when comparing the active route. Default: `true`.
|
|
24
|
+
*/
|
|
25
|
+
ignoreQueryParams?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Hash-aware active state (#532) — when provided, the route is active only
|
|
28
|
+
* if `state.context.url.hash` equals this value. Default: `undefined`
|
|
29
|
+
* (hash is ignored).
|
|
30
|
+
*/
|
|
31
|
+
hash?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @internal Consumed by `<Link>` via `createActiveRouteSource`. Not exported
|
|
36
|
+
* from `@real-router/vue`.
|
|
37
|
+
*/
|
|
9
38
|
export function useIsActiveRoute(
|
|
10
39
|
routeName: string,
|
|
11
40
|
params?: Params,
|
|
12
|
-
|
|
13
|
-
ignoreQueryParams = true,
|
|
14
|
-
hash?: string,
|
|
41
|
+
options?: UseIsActiveRouteOptions,
|
|
15
42
|
): ShallowRef<boolean> {
|
|
16
43
|
const router = useRouter();
|
|
44
|
+
const strict = options?.strict ?? false;
|
|
45
|
+
const ignoreQueryParams = options?.ignoreQueryParams ?? true;
|
|
46
|
+
const hash = options?.hash;
|
|
17
47
|
|
|
18
48
|
// The `hash` argument (#532) participates in the cache key when defined.
|
|
19
49
|
// exactOptionalPropertyTypes forbids `{ hash: undefined }` literally — we
|
|
@@ -6,10 +6,18 @@ import type { RouteContext } from "../types";
|
|
|
6
6
|
import type { Params, State } from "@real-router/core";
|
|
7
7
|
import type { Ref } from "vue";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Return shape for `useRoute()` — `RouteContext<P>` with `route` narrowed
|
|
11
|
+
* to the non-nullable variant. The composable throws when `route.value`
|
|
12
|
+
* would be `undefined`, so consumers can read `.value.params.x` without a
|
|
13
|
+
* nullable guard. Extracted from inline duplication at two call sites.
|
|
14
|
+
*/
|
|
15
|
+
export type UseRouteReturn<P extends Params = Params> = Omit<
|
|
10
16
|
RouteContext<P>,
|
|
11
17
|
"route"
|
|
12
|
-
> & { route: Readonly<Ref<State<P>>> }
|
|
18
|
+
> & { route: Readonly<Ref<State<P>>> };
|
|
19
|
+
|
|
20
|
+
export const useRoute = <P extends Params = Params>(): UseRouteReturn<P> => {
|
|
13
21
|
const routeContext = inject(RouteKey);
|
|
14
22
|
|
|
15
23
|
if (!routeContext) {
|
|
@@ -22,7 +30,5 @@ export const useRoute = <P extends Params = Params>(): Omit<
|
|
|
22
30
|
);
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
return routeContext as
|
|
26
|
-
route: Readonly<Ref<State<P>>>;
|
|
27
|
-
};
|
|
33
|
+
return routeContext as UseRouteReturn<P>;
|
|
28
34
|
};
|
package/src/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RouteContext as RouteContextType } from "./types";
|
|
2
|
+
import type { HttpStatusSink } from "./utils/createHttpStatusSink";
|
|
2
3
|
import type { Router, Navigator } from "@real-router/core";
|
|
3
4
|
import type { InjectionKey } from "vue";
|
|
4
5
|
|
|
@@ -7,3 +8,6 @@ export const RouterKey: InjectionKey<Router> = Symbol("RouterKey");
|
|
|
7
8
|
export const NavigatorKey: InjectionKey<Navigator> = Symbol("NavigatorKey");
|
|
8
9
|
|
|
9
10
|
export const RouteKey: InjectionKey<RouteContextType> = Symbol("RouteKey");
|
|
11
|
+
|
|
12
|
+
export const HTTP_STATUS_KEY: InjectionKey<HttpStatusSink> =
|
|
13
|
+
Symbol("HttpStatusSink");
|
package/src/directives/vLink.ts
CHANGED
|
@@ -42,7 +42,11 @@ export function pushDirectiveRouter(router: Router): () => void {
|
|
|
42
42
|
/**
|
|
43
43
|
* Backwards-compatible alias. Replaces the active router unconditionally and
|
|
44
44
|
* does NOT participate in the stack — use {@link pushDirectiveRouter} from
|
|
45
|
-
* provider code instead.
|
|
45
|
+
* provider code instead. Not exported from the package entry; retained for
|
|
46
|
+
* unit tests and rare standalone-directive setups (where v-link is mounted
|
|
47
|
+
* outside any RouterProvider).
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
46
50
|
*/
|
|
47
51
|
export function setDirectiveRouter(router: Router | null): void {
|
|
48
52
|
if (router === null) {
|
|
@@ -174,6 +178,19 @@ export const vLink: Directive<HTMLElement, LinkDirectiveValue> = {
|
|
|
174
178
|
},
|
|
175
179
|
|
|
176
180
|
updated(element, binding) {
|
|
181
|
+
// Hot-path guard: Vue invokes `updated` on every parent re-render even
|
|
182
|
+
// when the directive's binding value reference has not changed. Without
|
|
183
|
+
// this short-circuit, every parent rerender (which is the common case on
|
|
184
|
+
// Link-heavy pages — any unrelated state change triggers the parent's
|
|
185
|
+
// render fn) would detach + reattach the click/keydown listeners.
|
|
186
|
+
// Comparing references is enough: when consumers pass a stable
|
|
187
|
+
// `LinkDirectiveValue` object (the recommended pattern, since Vue's
|
|
188
|
+
// template compiler hoists `v-link="{ name: 'home' }"` to a stable
|
|
189
|
+
// literal), this guard collapses the work to zero.
|
|
190
|
+
if (binding.value === binding.oldValue) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
177
194
|
const router = getDirectiveRouter();
|
|
178
195
|
|
|
179
196
|
detachHandlers(element);
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ export { RouterProvider } from "./RouterProvider";
|
|
|
34
34
|
export { RouterKey, NavigatorKey, RouteKey } from "./context";
|
|
35
35
|
|
|
36
36
|
// Types
|
|
37
|
-
export type { LinkProps } from "./types";
|
|
37
|
+
export type { RouteContext, LinkProps } from "./types";
|
|
38
38
|
|
|
39
39
|
export type { RouterErrorBoundaryProps } from "./components/RouterErrorBoundary";
|
|
40
40
|
|
|
@@ -43,6 +43,7 @@ export type { LinkDirectiveValue } from "./directives/vLink";
|
|
|
43
43
|
export type {
|
|
44
44
|
RouteViewProps,
|
|
45
45
|
RouteViewMatchProps,
|
|
46
|
+
RouteViewSelfProps,
|
|
46
47
|
RouteViewNotFoundProps,
|
|
47
48
|
} from "./components/RouteView";
|
|
48
49
|
|
package/src/ssr.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SSR-feature entry — Vue 3.3+
|
|
2
|
+
//
|
|
3
|
+
// Server-side and SSR-aware components/composables. Mirror of `@real-router/react/ssr`
|
|
4
|
+
// — same exports, Vue-native idioms (defineComponent + h(), async setup()
|
|
5
|
+
// for Await, native Suspense for Streamed).
|
|
6
|
+
|
|
7
|
+
// Components
|
|
8
|
+
export { ClientOnly } from "./components/ClientOnly";
|
|
9
|
+
|
|
10
|
+
export { ServerOnly } from "./components/ServerOnly";
|
|
11
|
+
|
|
12
|
+
export { Await } from "./components/Await";
|
|
13
|
+
|
|
14
|
+
export { Streamed } from "./components/Streamed";
|
|
15
|
+
|
|
16
|
+
export { HttpStatusCode } from "./components/HttpStatusCode";
|
|
17
|
+
|
|
18
|
+
export { HttpStatusProvider } from "./components/HttpStatusProvider";
|
|
19
|
+
|
|
20
|
+
// Composables
|
|
21
|
+
export { useDeferred } from "./composables/useDeferred";
|
|
22
|
+
|
|
23
|
+
// Utilities
|
|
24
|
+
export { createHttpStatusSink } from "./utils/createHttpStatusSink";
|
|
25
|
+
|
|
26
|
+
// Types
|
|
27
|
+
export type { ClientOnlyProps } from "./components/ClientOnly";
|
|
28
|
+
|
|
29
|
+
export type { ServerOnlyProps } from "./components/ServerOnly";
|
|
30
|
+
|
|
31
|
+
export type { AwaitProps } from "./components/Await";
|
|
32
|
+
|
|
33
|
+
export type { StreamedProps } from "./components/Streamed";
|
|
34
|
+
|
|
35
|
+
export type { HttpStatusCodeProps } from "./components/HttpStatusCode";
|
|
36
|
+
|
|
37
|
+
export type { HttpStatusProviderProps } from "./components/HttpStatusProvider";
|
|
38
|
+
|
|
39
|
+
export type { HttpStatusSink } from "./utils/createHttpStatusSink";
|
package/src/types.ts
CHANGED
|
@@ -27,4 +27,14 @@ export interface LinkProps<P extends Params = Params> {
|
|
|
27
27
|
activeStrict?: boolean;
|
|
28
28
|
ignoreQueryParams?: boolean;
|
|
29
29
|
target?: string;
|
|
30
|
+
/**
|
|
31
|
+
* URL fragment (#532). Decoded, no leading `#`. Tri-state:
|
|
32
|
+
* - `undefined` (default) — preserves current `state.context.url.hash` on click.
|
|
33
|
+
* - `""` — clears the hash.
|
|
34
|
+
* - `"value"` — sets the hash; click routes through `navigateWithHash`,
|
|
35
|
+
* which auto-adds `force: true, hashChange: true` for same-route hash
|
|
36
|
+
* transitions (bypasses core's SAME_STATES check).
|
|
37
|
+
* Active state is hash-aware when `hash` is set.
|
|
38
|
+
*/
|
|
39
|
+
hash?: string;
|
|
30
40
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-scoped HTTP status sink. Created per request on the server, passed to
|
|
3
|
+
* `<HttpStatusProvider :sink="...">`, and read after `renderToString` /
|
|
4
|
+
* `renderToWebStream` 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 symmetry:** mount `<HttpStatusProvider>` on both server and
|
|
21
|
+
* client (with a throwaway client sink). Vue emits `<!--[-->` / `<!--]-->`
|
|
22
|
+
* fragment markers around the provider's slot; an extra provider on one
|
|
23
|
+
* side trips Vue with "Hydration completed but contains mismatches".
|
|
24
|
+
*/
|
|
25
|
+
export interface HttpStatusSink {
|
|
26
|
+
code: number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createHttpStatusSink(): HttpStatusSink {
|
|
30
|
+
return { code: undefined };
|
|
31
|
+
}
|