@real-router/vue 0.12.1 → 0.13.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 (45) hide show
  1. package/README.md +97 -13
  2. package/dist/cjs/createHttpStatusSink-XDu5aGhc.d.ts +32 -0
  3. package/dist/cjs/createHttpStatusSink-XDu5aGhc.d.ts.map +1 -0
  4. package/dist/cjs/index.d.ts +19 -2
  5. package/dist/cjs/index.d.ts.map +1 -1
  6. package/dist/cjs/index.js +1 -1
  7. package/dist/cjs/index.js.map +1 -1
  8. package/dist/cjs/ssr.d.ts +181 -0
  9. package/dist/cjs/ssr.d.ts.map +1 -0
  10. package/dist/cjs/ssr.js +2 -0
  11. package/dist/cjs/ssr.js.map +1 -0
  12. package/dist/cjs/useRoute-BT3SkdOc.js +2 -0
  13. package/dist/cjs/useRoute-BT3SkdOc.js.map +1 -0
  14. package/dist/esm/createHttpStatusSink-DduXvbGr.d.mts +32 -0
  15. package/dist/esm/createHttpStatusSink-DduXvbGr.d.mts.map +1 -0
  16. package/dist/esm/index.d.mts +19 -2
  17. package/dist/esm/index.d.mts.map +1 -1
  18. package/dist/esm/index.mjs +1 -1
  19. package/dist/esm/index.mjs.map +1 -1
  20. package/dist/esm/ssr.d.mts +181 -0
  21. package/dist/esm/ssr.d.mts.map +1 -0
  22. package/dist/esm/ssr.mjs +2 -0
  23. package/dist/esm/ssr.mjs.map +1 -0
  24. package/dist/esm/useRoute-2ocUdDHc.mjs +2 -0
  25. package/dist/esm/useRoute-2ocUdDHc.mjs.map +1 -0
  26. package/package.json +20 -4
  27. package/src/RouterProvider.ts +45 -39
  28. package/src/components/Await.ts +47 -0
  29. package/src/components/ClientOnly.ts +16 -0
  30. package/src/components/HttpStatusCode.ts +74 -0
  31. package/src/components/HttpStatusProvider.ts +22 -0
  32. package/src/components/Link.ts +33 -13
  33. package/src/components/RouteView/RouteView.ts +30 -51
  34. package/src/components/RouteView/helpers.ts +33 -2
  35. package/src/components/ServerOnly.ts +16 -0
  36. package/src/components/Streamed.ts +31 -0
  37. package/src/composables/useDeferred.ts +37 -0
  38. package/src/composables/useIsActiveRoute.ts +33 -3
  39. package/src/composables/useRoute.ts +11 -5
  40. package/src/context.ts +4 -0
  41. package/src/directives/vLink.ts +18 -1
  42. package/src/index.ts +2 -1
  43. package/src/ssr.ts +39 -0
  44. package/src/types.ts +10 -0
  45. package/src/utils/createHttpStatusSink.ts +31 -0
@@ -0,0 +1,181 @@
1
+ import { n as createHttpStatusSink, t as HttpStatusSink } from "./createHttpStatusSink-DduXvbGr.mjs";
2
+ import * as _$vue from "vue";
3
+ import { PropType } from "vue";
4
+
5
+ //#region src/components/ClientOnly.d.ts
6
+ declare const ClientOnly: _$vue.DefineComponent<{}, () => _$vue.VNode<_$vue.RendererNode, _$vue.RendererElement, {
7
+ [key: string]: any;
8
+ }>[] | undefined, {}, {}, {}, _$vue.ComponentOptionsMixin, _$vue.ComponentOptionsMixin, {}, string, _$vue.PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, _$vue.ComponentProvideOptions, true, {}, any>;
9
+ type ClientOnlyProps = InstanceType<typeof ClientOnly>["$props"];
10
+ //#endregion
11
+ //#region src/components/ServerOnly.d.ts
12
+ declare const ServerOnly: _$vue.DefineComponent<{}, () => _$vue.VNode<_$vue.RendererNode, _$vue.RendererElement, {
13
+ [key: string]: any;
14
+ }>[] | undefined, {}, {}, {}, _$vue.ComponentOptionsMixin, _$vue.ComponentOptionsMixin, {}, string, _$vue.PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, _$vue.ComponentProvideOptions, true, {}, any>;
15
+ type ServerOnlyProps = InstanceType<typeof ServerOnly>["$props"];
16
+ //#endregion
17
+ //#region src/components/Await.d.ts
18
+ /**
19
+ * Reads `useDeferred(name)` and hands the resolved value to the `default`
20
+ * scoped slot via Vue's native `async setup()` Suspense pattern. Wrap in
21
+ * `<Streamed>` (or Vue's `<Suspense>`).
22
+ *
23
+ * ```vue-html
24
+ * <Streamed>
25
+ * <Await name="reviews" v-slot="{ value }">
26
+ * <ReviewList :items="value" />
27
+ * </Await>
28
+ * <template #fallback>
29
+ * <Spinner />
30
+ * </template>
31
+ * </Streamed>
32
+ * ```
33
+ *
34
+ * Or with the render function:
35
+ *
36
+ * ```ts
37
+ * h(Await, { name: "reviews" }, {
38
+ * default: ({ value }: { value: Review[] }) => h(ReviewList, { items: value }),
39
+ * });
40
+ * ```
41
+ *
42
+ * Implementation: `async setup()` awaits the deferred promise. Vue's
43
+ * `<Suspense>` boundary catches the pending promise and shows the fallback
44
+ * until resolution. Rejection bubbles to the nearest `onErrorCaptured`
45
+ * handler.
46
+ */
47
+ declare const Await: _$vue.DefineComponent<_$vue.ExtractPropTypes<{
48
+ /** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */name: {
49
+ type: StringConstructor;
50
+ required: true;
51
+ };
52
+ }>, () => _$vue.VNode<_$vue.RendererNode, _$vue.RendererElement, {
53
+ [key: string]: any;
54
+ }>[] | undefined, {}, {}, {}, _$vue.ComponentOptionsMixin, _$vue.ComponentOptionsMixin, {}, string, _$vue.PublicProps, Readonly<_$vue.ExtractPropTypes<{
55
+ /** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */name: {
56
+ type: StringConstructor;
57
+ required: true;
58
+ };
59
+ }>> & Readonly<{}>, {}, {}, {}, {}, string, _$vue.ComponentProvideOptions, true, {}, any>;
60
+ type AwaitProps = InstanceType<typeof Await>["$props"];
61
+ //#endregion
62
+ //#region src/components/Streamed.d.ts
63
+ /**
64
+ * Cross-adapter alias for Vue's native `<Suspense>`. Symmetric naming with
65
+ * the React/Preact/Solid/Svelte/Angular `<Streamed>` components.
66
+ *
67
+ * Slots:
68
+ * - `default` — content (may contain `<Await>` or `async setup()` children).
69
+ * - `fallback` — shown while any descendant suspends.
70
+ *
71
+ * Vue's `<Suspense>` is **blocking** under SSR (no out-of-order placeholder
72
+ * resolution) — render of HTML after `<Streamed>` waits for every
73
+ * `async setup()` inside. This matches Vue 3's stable streaming behaviour
74
+ * (vs React 19 / Solid which support OOO resolution).
75
+ */
76
+ declare const Streamed: _$vue.DefineComponent<{}, () => _$vue.VNode<_$vue.RendererNode, _$vue.RendererElement, {
77
+ [key: string]: any;
78
+ }>, {}, {}, {}, _$vue.ComponentOptionsMixin, _$vue.ComponentOptionsMixin, {}, string, _$vue.PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, _$vue.ComponentProvideOptions, true, {}, any>;
79
+ type StreamedProps = InstanceType<typeof Streamed>["$props"];
80
+ //#endregion
81
+ //#region src/components/HttpStatusCode.d.ts
82
+ /**
83
+ * Render-time HTTP status declaration. Mount inside a route component (typical
84
+ * use case: a glob `*` route's NotFound page) when the status is decided by
85
+ * the rendered tree rather than a loader.
86
+ *
87
+ * Writes `code` to the nearest `<HttpStatusProvider>`'s sink during `setup()`
88
+ * and renders nothing. With no provider mounted (the standard client-side
89
+ * case) the component is a silent no-op — same component tree hydrates
90
+ * without touching the DOM or warning about mismatches.
91
+ *
92
+ * Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep
93
+ * working as before; this component covers render-time decisions only.
94
+ *
95
+ * Last write wins when several `<HttpStatusCode />` instances mount in the
96
+ * same render pass — sink reflects the last component that ran.
97
+ *
98
+ * ```vue-html
99
+ * <HttpStatusProvider :sink="sink">
100
+ * <RouterProvider :router="router">
101
+ * <App />
102
+ * </RouterProvider>
103
+ * </HttpStatusProvider>
104
+ *
105
+ * <!-- inside NotFound.vue -->
106
+ * <HttpStatusCode :code="404" />
107
+ * ```
108
+ *
109
+ * **`renderToWebStream` (streaming SSR):** Vue 3 `<Suspense>` is
110
+ * chunked-blocking — Vue waits for every `async setup()` inside a boundary
111
+ * before emitting the chunks past it. So in practice `<HttpStatusCode />`
112
+ * inside a `<Suspense>` boundary still writes to the sink before the
113
+ * response headers flush. Still, prefer mounting in the shell to avoid
114
+ * coupling the contract to that particular Vue 3 streaming behaviour. With
115
+ * `renderToString` there is no ordering concern at all.
116
+ *
117
+ * **Hydration symmetry:** `<HttpStatusProvider>` wraps a render slot, so
118
+ * Vue emits a fragment marker pair (`<!--[-->` / `<!--]-->`) around its
119
+ * children server-side. Mount the same `<HttpStatusProvider>` on the
120
+ * client (with a throwaway sink) to keep the marker count balanced — see
121
+ * the `ssr/` example's `entry-client.ts`. Otherwise hydration logs
122
+ * "Hydration completed but contains mismatches".
123
+ *
124
+ * **Valid `code` range:** Node's `res.end()` throws `Invalid status code`
125
+ * on `NaN`, `0`, negative values, or values `> 999` — this surfaces as a
126
+ * 5xx / dropped connection, not silent corruption. Pass a real HTTP status
127
+ * integer (commonly 4xx/5xx; 100-999 is what Node accepts).
128
+ */
129
+ declare const HttpStatusCode: _$vue.DefineComponent<_$vue.ExtractPropTypes<{
130
+ /** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */code: {
131
+ type: NumberConstructor;
132
+ required: true;
133
+ };
134
+ }>, () => null, {}, {}, {}, _$vue.ComponentOptionsMixin, _$vue.ComponentOptionsMixin, {}, string, _$vue.PublicProps, Readonly<_$vue.ExtractPropTypes<{
135
+ /** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */code: {
136
+ type: NumberConstructor;
137
+ required: true;
138
+ };
139
+ }>> & Readonly<{}>, {}, {}, {}, {}, string, _$vue.ComponentProvideOptions, true, {}, any>;
140
+ type HttpStatusCodeProps = InstanceType<typeof HttpStatusCode>["$props"];
141
+ //#endregion
142
+ //#region src/components/HttpStatusProvider.d.ts
143
+ declare const HttpStatusProvider: _$vue.DefineComponent<_$vue.ExtractPropTypes<{
144
+ sink: {
145
+ type: PropType<HttpStatusSink>;
146
+ required: true;
147
+ };
148
+ }>, () => _$vue.VNode<_$vue.RendererNode, _$vue.RendererElement, {
149
+ [key: string]: any;
150
+ }>[] | undefined, {}, {}, {}, _$vue.ComponentOptionsMixin, _$vue.ComponentOptionsMixin, {}, string, _$vue.PublicProps, Readonly<_$vue.ExtractPropTypes<{
151
+ sink: {
152
+ type: PropType<HttpStatusSink>;
153
+ required: true;
154
+ };
155
+ }>> & Readonly<{}>, {}, {}, {}, {}, string, _$vue.ComponentProvideOptions, true, {}, any>;
156
+ type HttpStatusProviderProps = InstanceType<typeof HttpStatusProvider>["$props"];
157
+ //#endregion
158
+ //#region src/composables/useDeferred.d.ts
159
+ /**
160
+ * Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`
161
+ * inside an SSR data loader. Returns the Promise for use inside `async setup()`
162
+ * (Vue's native Suspense pattern) or paired with `<Await name="key">`.
163
+ *
164
+ * ```ts
165
+ * // Vue async setup pattern
166
+ * export default defineComponent({
167
+ * async setup() {
168
+ * const reviews = await useDeferred<Review[]>("reviews");
169
+ * return () => h("div", reviews.map(...));
170
+ * },
171
+ * });
172
+ * ```
173
+ *
174
+ * Returns a forever-pending promise when the key is missing — surfaces
175
+ * loader/consumer key drift as a visible Suspense fallback rather than a
176
+ * silent runtime error.
177
+ */
178
+ declare function useDeferred<T = unknown>(key: string): Promise<T>;
179
+ //#endregion
180
+ export { Await, type AwaitProps, ClientOnly, type ClientOnlyProps, HttpStatusCode, type HttpStatusCodeProps, HttpStatusProvider, type HttpStatusProviderProps, type HttpStatusSink, ServerOnly, type ServerOnlyProps, Streamed, type StreamedProps, createHttpStatusSink, useDeferred };
181
+ //# sourceMappingURL=ssr.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr.d.mts","names":[],"sources":["../../src/components/ClientOnly.ts","../../src/components/ServerOnly.ts","../../src/components/Await.ts","../../src/components/Streamed.ts","../../src/components/HttpStatusCode.ts","../../src/components/HttpStatusProvider.ts","../../src/composables/useDeferred.ts"],"mappings":";;;;;cAEa,UAAA,QAAU,eAAA,WAAA,KAAA,CAAA,KAAA,CAWrB,KAAA,CAXqB,YAAA,EAAA,KAAA,CAAA,eAAA;EAAA;8BAAA,KAAA,CAAA,qBAAA;KAaX,eAAA,GAAkB,YAAA,QAAoB,UAAA;;;cCbrC,UAAA,QAAU,eAAA,WAAA,KAAA,CAAA,KAAA,CAWrB,KAAA,CAXqB,YAAA,EAAA,KAAA,CAAA,eAAA;EAAA;8BAAA,KAAA,CAAA,qBAAA;KAaX,eAAA,GAAkB,YAAA,QAAoB,UAAA;;;;;;;;ADblD;;;;;;;;;;;;;;;;;;;;;;;;cE+Ba,KAAA,QAAK,eAAA,OAAA,gBAAA;;;;;sBAAA,KAAA,CAAA,YAAA;;;;;;;;KAaN,UAAA,GAAa,YAAA,QAAoB,KAAA;;;;;;;;AF5C7C;;;;;;;;cGaa,QAAA,QAAQ,eAAA,WAAA,KAAA,CAAA,KAAA,CAanB,KAAA,CAbmB,YAAA,EAAA,KAAA,CAAA,eAAA;EAAA;gBAAA,KAAA,CAAA,qBAAA;KAeT,aAAA,GAAgB,YAAA,QAAoB,QAAA;;;;;;;;AH5BhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAaA;;;;;;;;ACbA;;;;;;cGsDa,cAAA,QAAc,eAAA,OAAA,gBAAA;;;;;4BAAA,KAAA,CAjDA,qBAAA,EAAA,KAAA,CAAA,qBAAA,cAAA,KAAA,CAAA,WAAA,EAAA,QAAA,OAAA,gBAAA;EHLJ;;;;;KGuEX,mBAAA,GAAsB,YAAA,QAAoB,cAAA;;;cClEzC,kBAAA,QAAkB,eAAA,CAGK,KAAA,CAHL,gBAAA;;UAGH,QAAA,CAAS,cAAA;;;sBAHN,KAAA,CAAA,YAAA;;gIAGK,KAAA,CAAA,gBAAA;;UAAR,QAAA,CAAS,cAAA;;;;KASzB,uBAAA,GAA0B,YAAA,QAC7B,kBAAA;;;;;;;;ALlBT;;;;;;;;;;;;;;iBM4BgB,WAAA,aAAA,CAAyB,GAAA,WAAc,OAAA,CAAQ,CAAA"}
@@ -0,0 +1,2 @@
1
+ import{n as e,t}from"./useRoute-2ocUdDHc.mjs";import{Suspense as n,defineComponent as r,h as i,inject as a,onMounted as o,provide as s,ref as c}from"vue";const l=r({name:`ClientOnly`,setup(e,{slots:t}){let n=c(!1);return o(()=>{n.value=!0}),()=>n.value?t.default?.():t.fallback?.()}}),u=r({name:`ServerOnly`,setup(e,{slots:t}){let n=c(!1);return o(()=>{n.value=!0}),()=>n.value?t.fallback?.():t.default?.()}}),d=new Promise(()=>{});function f(e){let{route:n}=t();return n.value.context.ssrDataDeferred?.[e]??d}const p=r({name:`Await`,props:{name:{type:String,required:!0}},async setup(e,{slots:t}){let n=await f(e.name);return()=>t.default?.({value:n})}}),m=r({name:`Streamed`,setup(e,{slots:t}){return()=>i(n,{},{default:()=>t.default?.(),fallback:()=>t.fallback?.()})}}),h=()=>null,g=r({name:`HttpStatusCode`,props:{code:{type:Number,required:!0}},setup(t){let n=a(e,null);return n&&(n.code=t.code),h}}),_=r({name:`HttpStatusProvider`,props:{sink:{type:Object,required:!0}},setup(t,{slots:n}){return s(e,t.sink),()=>n.default?.()}});function v(){return{code:void 0}}export{p as Await,l as ClientOnly,g as HttpStatusCode,_ as HttpStatusProvider,u as ServerOnly,m as Streamed,v as createHttpStatusSink,f as useDeferred};
2
+ //# sourceMappingURL=ssr.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr.mjs","names":[],"sources":["../../src/components/ClientOnly.ts","../../src/components/ServerOnly.ts","../../src/composables/useDeferred.ts","../../src/components/Await.ts","../../src/components/Streamed.ts","../../src/components/HttpStatusCode.ts","../../src/components/HttpStatusProvider.ts","../../src/utils/createHttpStatusSink.ts"],"sourcesContent":["import { defineComponent, onMounted, ref } from \"vue\";\n\nexport const ClientOnly = defineComponent({\n name: \"ClientOnly\",\n setup(_, { slots }) {\n const mounted = ref(false);\n\n onMounted(() => {\n mounted.value = true;\n });\n\n return () => (mounted.value ? slots.default?.() : slots.fallback?.());\n },\n});\n\nexport type ClientOnlyProps = InstanceType<typeof ClientOnly>[\"$props\"];\n","import { defineComponent, onMounted, ref } from \"vue\";\n\nexport const ServerOnly = defineComponent({\n name: \"ServerOnly\",\n setup(_, { slots }) {\n const mounted = ref(false);\n\n onMounted(() => {\n mounted.value = true;\n });\n\n return () => (mounted.value ? slots.fallback?.() : slots.default?.());\n },\n});\n\nexport type ServerOnlyProps = InstanceType<typeof ServerOnly>[\"$props\"];\n","import { useRoute } from \"./useRoute\";\n\ninterface DeferredContext {\n ssrDataDeferred?: Record<string, Promise<unknown>>;\n}\n\nconst NEVER_PROMISE = new Promise<never>(() => {\n // Intentionally never resolves — surfaces a forever-pending Suspense boundary\n // when a key is requested that the loader never declared.\n});\n\n/**\n * Read a deferred promise published by `defer({ deferred: { <key>: Promise } })`\n * inside an SSR data loader. Returns the Promise for use inside `async setup()`\n * (Vue's native Suspense pattern) or paired with `<Await name=\"key\">`.\n *\n * ```ts\n * // Vue async setup pattern\n * export default defineComponent({\n * async setup() {\n * const reviews = await useDeferred<Review[]>(\"reviews\");\n * return () => h(\"div\", reviews.map(...));\n * },\n * });\n * ```\n *\n * Returns a forever-pending promise when the key is missing — surfaces\n * loader/consumer key drift as a visible Suspense fallback rather than a\n * silent runtime error.\n */\nexport function useDeferred<T = unknown>(key: string): Promise<T> {\n const { route } = useRoute();\n const context = route.value.context as DeferredContext;\n const deferred = context.ssrDataDeferred;\n\n return (deferred?.[key] ?? NEVER_PROMISE) as Promise<T>;\n}\n","import { defineComponent } from \"vue\";\n\nimport { useDeferred } from \"../composables/useDeferred\";\n\n/**\n * Reads `useDeferred(name)` and hands the resolved value to the `default`\n * scoped slot via Vue's native `async setup()` Suspense pattern. Wrap in\n * `<Streamed>` (or Vue's `<Suspense>`).\n *\n * ```vue-html\n * <Streamed>\n * <Await name=\"reviews\" v-slot=\"{ value }\">\n * <ReviewList :items=\"value\" />\n * </Await>\n * <template #fallback>\n * <Spinner />\n * </template>\n * </Streamed>\n * ```\n *\n * Or with the render function:\n *\n * ```ts\n * h(Await, { name: \"reviews\" }, {\n * default: ({ value }: { value: Review[] }) => h(ReviewList, { items: value }),\n * });\n * ```\n *\n * Implementation: `async setup()` awaits the deferred promise. Vue's\n * `<Suspense>` boundary catches the pending promise and shows the fallback\n * until resolution. Rejection bubbles to the nearest `onErrorCaptured`\n * handler.\n */\nexport const Await = defineComponent({\n name: \"Await\",\n props: {\n /** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */\n name: { type: String, required: true },\n },\n async setup(props, { slots }) {\n const value = await useDeferred(props.name);\n\n return () => slots.default?.({ value });\n },\n});\n\nexport type AwaitProps = InstanceType<typeof Await>[\"$props\"];\n","import { defineComponent, h, Suspense } from \"vue\";\n\n/**\n * Cross-adapter alias for Vue's native `<Suspense>`. Symmetric naming with\n * the React/Preact/Solid/Svelte/Angular `<Streamed>` components.\n *\n * Slots:\n * - `default` — content (may contain `<Await>` or `async setup()` children).\n * - `fallback` — shown while any descendant suspends.\n *\n * Vue's `<Suspense>` is **blocking** under SSR (no out-of-order placeholder\n * resolution) — render of HTML after `<Streamed>` waits for every\n * `async setup()` inside. This matches Vue 3's stable streaming behaviour\n * (vs React 19 / Solid which support OOO resolution).\n */\nexport const Streamed = defineComponent({\n name: \"Streamed\",\n setup(_, { slots }) {\n return () =>\n h(\n Suspense,\n {},\n {\n default: () => slots.default?.(),\n fallback: () => slots.fallback?.(),\n },\n );\n },\n});\n\nexport type StreamedProps = InstanceType<typeof Streamed>[\"$props\"];\n","import { defineComponent, inject } from \"vue\";\n\nimport { HTTP_STATUS_KEY } from \"../context\";\n\n// Module-scope render function — returns null since the component emits no DOM.\n// Hoisted to satisfy `unicorn/consistent-function-scoping` and to avoid\n// re-creating the closure on every component instantiation.\nconst renderNull = (): null => null;\n\n/**\n * Render-time HTTP status declaration. Mount inside a route component (typical\n * use case: a glob `*` route's NotFound page) when the status is decided by\n * the rendered tree rather than a loader.\n *\n * Writes `code` to the nearest `<HttpStatusProvider>`'s sink during `setup()`\n * and renders nothing. With no provider mounted (the standard client-side\n * case) the component is a silent no-op — same component tree hydrates\n * without touching the DOM or warning about mismatches.\n *\n * Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep\n * working as before; this component covers render-time decisions only.\n *\n * Last write wins when several `<HttpStatusCode />` instances mount in the\n * same render pass — sink reflects the last component that ran.\n *\n * ```vue-html\n * <HttpStatusProvider :sink=\"sink\">\n * <RouterProvider :router=\"router\">\n * <App />\n * </RouterProvider>\n * </HttpStatusProvider>\n *\n * <!-- inside NotFound.vue -->\n * <HttpStatusCode :code=\"404\" />\n * ```\n *\n * **`renderToWebStream` (streaming SSR):** Vue 3 `<Suspense>` is\n * chunked-blocking — Vue waits for every `async setup()` inside a boundary\n * before emitting the chunks past it. So in practice `<HttpStatusCode />`\n * inside a `<Suspense>` boundary still writes to the sink before the\n * response headers flush. Still, prefer mounting in the shell to avoid\n * coupling the contract to that particular Vue 3 streaming behaviour. With\n * `renderToString` there is no ordering concern at all.\n *\n * **Hydration symmetry:** `<HttpStatusProvider>` wraps a render slot, so\n * Vue emits a fragment marker pair (`<!--[-->` / `<!--]-->`) around its\n * children server-side. Mount the same `<HttpStatusProvider>` on the\n * client (with a throwaway sink) to keep the marker count balanced — see\n * the `ssr/` example's `entry-client.ts`. Otherwise hydration logs\n * \"Hydration completed but contains mismatches\".\n *\n * **Valid `code` range:** Node's `res.end()` throws `Invalid status code`\n * on `NaN`, `0`, negative values, or values `> 999` — this surfaces as a\n * 5xx / dropped connection, not silent corruption. Pass a real HTTP status\n * integer (commonly 4xx/5xx; 100-999 is what Node accepts).\n */\nexport const HttpStatusCode = defineComponent({\n name: \"HttpStatusCode\",\n props: {\n /** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */\n code: { type: Number, required: true },\n },\n setup(props) {\n const sink = inject(HTTP_STATUS_KEY, null);\n\n if (sink) {\n sink.code = props.code;\n }\n\n return renderNull;\n },\n});\n\nexport type HttpStatusCodeProps = InstanceType<typeof HttpStatusCode>[\"$props\"];\n","import { defineComponent, provide } from \"vue\";\n\nimport { HTTP_STATUS_KEY } from \"../context\";\n\nimport type { HttpStatusSink } from \"../utils/createHttpStatusSink\";\nimport type { PropType } from \"vue\";\n\nexport const HttpStatusProvider = defineComponent({\n name: \"HttpStatusProvider\",\n props: {\n sink: { type: Object as PropType<HttpStatusSink>, required: true },\n },\n setup(props, { slots }) {\n provide(HTTP_STATUS_KEY, props.sink);\n\n return () => slots.default?.();\n },\n});\n\nexport type HttpStatusProviderProps = InstanceType<\n typeof HttpStatusProvider\n>[\"$props\"];\n","/**\n * Render-scoped HTTP status sink. Created per request on the server, passed to\n * `<HttpStatusProvider :sink=\"...\">`, and read after `renderToString` /\n * `renderToWebStream` to apply the value to the HTTP response.\n *\n * Last write wins: if the rendered tree mounts more than one\n * `<HttpStatusCode />`, the value reflects the last component that ran during\n * the render pass.\n *\n * No-op on the client — `<HttpStatusCode />` reads the optional injected sink\n * and skips the write when no provider is mounted, so the same component tree\n * can be hydrated without changing behaviour.\n *\n * Constraints:\n * - **Per-request only.** Don't share a sink across requests; the rendered\n * tree mutates `code` in place. Module-level singletons leak status\n * between concurrent requests.\n * - **Don't `Object.freeze` the sink.** The component writes to `.code`;\n * freezing makes the assignment throw under ESM strict mode.\n * - **Hydration symmetry:** mount `<HttpStatusProvider>` on both server and\n * client (with a throwaway client sink). Vue emits `<!--[-->` / `<!--]-->`\n * fragment markers around the provider's slot; an extra provider on one\n * side trips Vue with \"Hydration completed but contains mismatches\".\n */\nexport interface HttpStatusSink {\n code: number | undefined;\n}\n\nexport function createHttpStatusSink(): HttpStatusSink {\n return { code: undefined };\n}\n"],"mappings":"0JAEA,MAAa,EAAa,EAAgB,CACxC,KAAM,aACN,MAAM,EAAG,CAAE,SAAS,CAClB,IAAM,EAAU,EAAI,GAAM,CAM1B,OAJA,MAAgB,CACd,EAAQ,MAAQ,IAChB,KAEY,EAAQ,MAAQ,EAAM,WAAW,CAAG,EAAM,YAAY,EAEvE,CAAC,CCXW,EAAa,EAAgB,CACxC,KAAM,aACN,MAAM,EAAG,CAAE,SAAS,CAClB,IAAM,EAAU,EAAI,GAAM,CAM1B,OAJA,MAAgB,CACd,EAAQ,MAAQ,IAChB,KAEY,EAAQ,MAAQ,EAAM,YAAY,CAAG,EAAM,WAAW,EAEvE,CAAC,CCPI,EAAgB,IAAI,YAAqB,GAG7C,CAqBF,SAAgB,EAAyB,EAAyB,CAChE,GAAM,CAAE,SAAU,GAAU,CAI5B,OAHgB,EAAM,MAAM,QACH,kBAEN,IAAQ,ECF7B,MAAa,EAAQ,EAAgB,CACnC,KAAM,QACN,MAAO,CAEL,KAAM,CAAE,KAAM,OAAQ,SAAU,GAAM,CACvC,CACD,MAAM,MAAM,EAAO,CAAE,SAAS,CAC5B,IAAM,EAAQ,MAAM,EAAY,EAAM,KAAK,CAE3C,UAAa,EAAM,UAAU,CAAE,QAAO,CAAC,EAE1C,CAAC,CC7BW,EAAW,EAAgB,CACtC,KAAM,WACN,MAAM,EAAG,CAAE,SAAS,CAClB,UACE,EACE,EACA,EAAE,CACF,CACE,YAAe,EAAM,WAAW,CAChC,aAAgB,EAAM,YAAY,CACnC,CACF,EAEN,CAAC,CCrBI,MAAyB,KAiDlB,EAAiB,EAAgB,CAC5C,KAAM,iBACN,MAAO,CAEL,KAAM,CAAE,KAAM,OAAQ,SAAU,GAAM,CACvC,CACD,MAAM,EAAO,CACX,IAAM,EAAO,EAAO,EAAiB,KAAK,CAM1C,OAJI,IACF,EAAK,KAAO,EAAM,MAGb,GAEV,CAAC,CChEW,EAAqB,EAAgB,CAChD,KAAM,qBACN,MAAO,CACL,KAAM,CAAE,KAAM,OAAoC,SAAU,GAAM,CACnE,CACD,MAAM,EAAO,CAAE,SAAS,CAGtB,OAFA,EAAQ,EAAiB,EAAM,KAAK,KAEvB,EAAM,WAAW,EAEjC,CAAC,CCWF,SAAgB,GAAuC,CACrD,MAAO,CAAE,KAAM,IAAA,GAAW"}
@@ -0,0 +1,2 @@
1
+ import{inject as e}from"vue";const t=Symbol(`RouterKey`),n=Symbol(`NavigatorKey`),r=Symbol(`RouteKey`),i=Symbol(`HttpStatusSink`),a=()=>{let t=e(r);if(!t)throw Error(`useRoute must be used within a RouterProvider`);if(!t.route.value)throw Error(`useRoute called with no active route. Did you forget to await router.start() before rendering, or is the router stopped/disposed?`);return t};export{t as a,r as i,i as n,n as r,a as t};
2
+ //# sourceMappingURL=useRoute-2ocUdDHc.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useRoute-2ocUdDHc.mjs","names":[],"sources":["../../src/context.ts","../../src/composables/useRoute.ts"],"sourcesContent":["import type { RouteContext as RouteContextType } from \"./types\";\nimport type { HttpStatusSink } from \"./utils/createHttpStatusSink\";\nimport type { Router, Navigator } from \"@real-router/core\";\nimport type { InjectionKey } from \"vue\";\n\nexport const RouterKey: InjectionKey<Router> = Symbol(\"RouterKey\");\n\nexport const NavigatorKey: InjectionKey<Navigator> = Symbol(\"NavigatorKey\");\n\nexport const RouteKey: InjectionKey<RouteContextType> = Symbol(\"RouteKey\");\n\nexport const HTTP_STATUS_KEY: InjectionKey<HttpStatusSink> =\n Symbol(\"HttpStatusSink\");\n","import { inject } from \"vue\";\n\nimport { RouteKey } from \"../context\";\n\nimport type { RouteContext } from \"../types\";\nimport type { Params, State } from \"@real-router/core\";\nimport type { Ref } from \"vue\";\n\n/**\n * Return shape for `useRoute()` — `RouteContext<P>` with `route` narrowed\n * to the non-nullable variant. The composable throws when `route.value`\n * would be `undefined`, so consumers can read `.value.params.x` without a\n * nullable guard. Extracted from inline duplication at two call sites.\n */\nexport type UseRouteReturn<P extends Params = Params> = Omit<\n RouteContext<P>,\n \"route\"\n> & { route: Readonly<Ref<State<P>>> };\n\nexport const useRoute = <P extends Params = Params>(): UseRouteReturn<P> => {\n const routeContext = inject(RouteKey);\n\n if (!routeContext) {\n throw new Error(\"useRoute must be used within a RouterProvider\");\n }\n\n if (!routeContext.route.value) {\n throw new Error(\n \"useRoute called with no active route. Did you forget to await router.start() before rendering, or is the router stopped/disposed?\",\n );\n }\n\n return routeContext as UseRouteReturn<P>;\n};\n"],"mappings":"6BAKA,MAAa,EAAkC,OAAO,YAAY,CAErD,EAAwC,OAAO,eAAe,CAE9D,EAA2C,OAAO,WAAW,CAE7D,EACX,OAAO,iBAAiB,CCOb,MAA+D,CAC1E,IAAM,EAAe,EAAO,EAAS,CAErC,GAAI,CAAC,EACH,MAAU,MAAM,gDAAgD,CAGlE,GAAI,CAAC,EAAa,MAAM,MACtB,MAAU,MACR,oIACD,CAGH,OAAO"}
package/package.json CHANGED
@@ -1,11 +1,18 @@
1
1
  {
2
2
  "name": "@real-router/vue",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "type": "commonjs",
5
5
  "description": "Vue 3 integration for Real-Router",
6
6
  "main": "./dist/cjs/index.js",
7
7
  "module": "./dist/esm/index.mjs",
8
8
  "types": "./dist/esm/index.d.mts",
9
+ "typesVersions": {
10
+ "*": {
11
+ "ssr": [
12
+ "./dist/cjs/ssr.d.ts"
13
+ ]
14
+ }
15
+ },
9
16
  "exports": {
10
17
  ".": {
11
18
  "@real-router/internal-source": "./src/index.ts",
@@ -15,6 +22,15 @@
15
22
  },
16
23
  "import": "./dist/esm/index.mjs",
17
24
  "require": "./dist/cjs/index.js"
25
+ },
26
+ "./ssr": {
27
+ "@real-router/internal-source": "./src/ssr.ts",
28
+ "types": {
29
+ "import": "./dist/esm/ssr.d.mts",
30
+ "require": "./dist/cjs/ssr.d.ts"
31
+ },
32
+ "import": "./dist/esm/ssr.mjs",
33
+ "require": "./dist/cjs/ssr.js"
18
34
  }
19
35
  },
20
36
  "files": [
@@ -50,15 +66,15 @@
50
66
  "license": "MIT",
51
67
  "sideEffects": false,
52
68
  "dependencies": {
53
- "@real-router/core": "^0.52.0",
54
69
  "@real-router/route-utils": "^0.2.2",
55
- "@real-router/sources": "^0.8.1"
70
+ "@real-router/sources": "^0.8.2",
71
+ "@real-router/core": "^0.53.0"
56
72
  },
57
73
  "devDependencies": {
58
74
  "@testing-library/jest-dom": "6.9.1",
59
75
  "@vue/test-utils": "2.4.6",
60
76
  "vue": "3.5.13",
61
- "@real-router/browser-plugin": "^0.17.1"
77
+ "@real-router/browser-plugin": "^0.17.2"
62
78
  },
63
79
  "peerDependencies": {
64
80
  "vue": ">=3.3.0"
@@ -13,6 +13,41 @@ import type { ScrollRestorationOptions } from "./dom-utils";
13
13
  import type { Router } from "@real-router/core";
14
14
  import type { PropType } from "vue";
15
15
 
16
+ interface Disposable {
17
+ destroy: () => void;
18
+ }
19
+
20
+ /**
21
+ * Watch a dependency tuple and (re)create a toggleable utility (announcer /
22
+ * scroll-restorer / view-transitions). The factory returns `undefined` to
23
+ * mean "feature disabled" — no utility is created and no cleanup is wired.
24
+ * When a utility IS returned, its `destroy()` is registered via `onCleanup`,
25
+ * so flipping any dep (incl. the feature flag) tears down the previous
26
+ * instance before constructing the next.
27
+ *
28
+ * Extracted from three near-identical `watch(... { immediate: true })` blocks
29
+ * (announceNavigation / scrollRestoration / viewTransitions) — DRY without
30
+ * losing the per-utility dep tuple shape.
31
+ */
32
+ function watchToggleableUtility<D extends readonly unknown[]>(
33
+ deps: () => D,
34
+ factory: (current: D) => Disposable | undefined,
35
+ ): void {
36
+ watch(
37
+ deps,
38
+ (current, _prev, onCleanup) => {
39
+ const utility = factory(current);
40
+
41
+ if (utility) {
42
+ onCleanup(() => {
43
+ utility.destroy();
44
+ });
45
+ }
46
+ },
47
+ { immediate: true },
48
+ );
49
+ }
50
+
16
51
  export const RouterProvider = defineComponent({
17
52
  name: "RouterProvider",
18
53
  props: {
@@ -33,30 +68,20 @@ export const RouterProvider = defineComponent({
33
68
  },
34
69
  },
35
70
  setup(props, { slots }) {
36
- // Reactive announceNavigation: setting prop true/false at runtime now
71
+ // Reactive announceNavigation: setting prop true/false at runtime
37
72
  // creates/destroys the announcer accordingly. Prior implementation read
38
73
  // the prop only inside onMounted, so toggling it post-mount silently no-op'd.
39
- watch(
74
+ watchToggleableUtility(
40
75
  () => [props.router, props.announceNavigation] as const,
41
- ([router, enabled], _prev, onCleanup) => {
42
- if (!enabled) {
43
- return;
44
- }
45
-
46
- const announcer = createRouteAnnouncer(router);
47
-
48
- onCleanup(() => {
49
- announcer.destroy();
50
- });
51
- },
52
- { immediate: true },
76
+ ([router, enabled]) =>
77
+ enabled ? createRouteAnnouncer(router) : undefined,
53
78
  );
54
79
 
55
80
  // Watch by primitives so inline `{ mode: "restore" }` doesn't thrash.
56
81
  // scrollContainer is a getter invoked lazily on every event inside the
57
82
  // utility — swapping its reference doesn't change the resolved element,
58
83
  // so we intentionally omit it from watched sources.
59
- watch(
84
+ watchToggleableUtility(
60
85
  () =>
61
86
  [
62
87
  props.router,
@@ -66,45 +91,26 @@ export const RouterProvider = defineComponent({
66
91
  props.scrollRestoration?.behavior,
67
92
  props.scrollRestoration?.storageKey,
68
93
  ] as const,
69
- (
70
- [router, enabled, mode, anchorScrolling, behavior, storageKey],
71
- _prev,
72
- onCleanup,
73
- ) => {
94
+ ([router, enabled, mode, anchorScrolling, behavior, storageKey]) => {
74
95
  if (!enabled) {
75
96
  return;
76
97
  }
77
98
 
78
- const sr = createScrollRestoration(router, {
99
+ return createScrollRestoration(router, {
79
100
  mode,
80
101
  anchorScrolling,
81
102
  behavior,
82
103
  storageKey,
83
104
  scrollContainer: props.scrollRestoration?.scrollContainer,
84
105
  });
85
-
86
- onCleanup(() => {
87
- sr.destroy();
88
- });
89
106
  },
90
- { immediate: true },
91
107
  );
92
108
 
93
109
  // Reactive viewTransitions: toggling prop creates/destroys the utility.
94
- watch(
110
+ watchToggleableUtility(
95
111
  () => [props.router, props.viewTransitions] as const,
96
- ([router, enabled], _prev, onCleanup) => {
97
- if (!enabled) {
98
- return;
99
- }
100
-
101
- const vt = createViewTransitions(router);
102
-
103
- onCleanup(() => {
104
- vt.destroy();
105
- });
106
- },
107
- { immediate: true },
112
+ ([router, enabled]) =>
113
+ enabled ? createViewTransitions(router) : undefined,
108
114
  );
109
115
 
110
116
  // Push this provider's router on the v-link directive stack so nested
@@ -0,0 +1,47 @@
1
+ import { defineComponent } from "vue";
2
+
3
+ import { useDeferred } from "../composables/useDeferred";
4
+
5
+ /**
6
+ * Reads `useDeferred(name)` and hands the resolved value to the `default`
7
+ * scoped slot via Vue's native `async setup()` Suspense pattern. Wrap in
8
+ * `<Streamed>` (or Vue's `<Suspense>`).
9
+ *
10
+ * ```vue-html
11
+ * <Streamed>
12
+ * <Await name="reviews" v-slot="{ value }">
13
+ * <ReviewList :items="value" />
14
+ * </Await>
15
+ * <template #fallback>
16
+ * <Spinner />
17
+ * </template>
18
+ * </Streamed>
19
+ * ```
20
+ *
21
+ * Or with the render function:
22
+ *
23
+ * ```ts
24
+ * h(Await, { name: "reviews" }, {
25
+ * default: ({ value }: { value: Review[] }) => h(ReviewList, { items: value }),
26
+ * });
27
+ * ```
28
+ *
29
+ * Implementation: `async setup()` awaits the deferred promise. Vue's
30
+ * `<Suspense>` boundary catches the pending promise and shows the fallback
31
+ * until resolution. Rejection bubbles to the nearest `onErrorCaptured`
32
+ * handler.
33
+ */
34
+ export const Await = defineComponent({
35
+ name: "Await",
36
+ props: {
37
+ /** Deferred key declared in the loader's `defer({ deferred: { <name>: ... } })`. */
38
+ name: { type: String, required: true },
39
+ },
40
+ async setup(props, { slots }) {
41
+ const value = await useDeferred(props.name);
42
+
43
+ return () => slots.default?.({ value });
44
+ },
45
+ });
46
+
47
+ export type AwaitProps = InstanceType<typeof Await>["$props"];
@@ -0,0 +1,16 @@
1
+ import { defineComponent, onMounted, ref } from "vue";
2
+
3
+ export const ClientOnly = defineComponent({
4
+ name: "ClientOnly",
5
+ setup(_, { slots }) {
6
+ const mounted = ref(false);
7
+
8
+ onMounted(() => {
9
+ mounted.value = true;
10
+ });
11
+
12
+ return () => (mounted.value ? slots.default?.() : slots.fallback?.());
13
+ },
14
+ });
15
+
16
+ export type ClientOnlyProps = InstanceType<typeof ClientOnly>["$props"];
@@ -0,0 +1,74 @@
1
+ import { defineComponent, inject } from "vue";
2
+
3
+ import { HTTP_STATUS_KEY } from "../context";
4
+
5
+ // Module-scope render function — returns null since the component emits no DOM.
6
+ // Hoisted to satisfy `unicorn/consistent-function-scoping` and to avoid
7
+ // re-creating the closure on every component instantiation.
8
+ const renderNull = (): null => null;
9
+
10
+ /**
11
+ * Render-time HTTP status declaration. Mount inside a route component (typical
12
+ * use case: a glob `*` route's NotFound page) when the status is decided by
13
+ * the rendered tree rather than a loader.
14
+ *
15
+ * Writes `code` to the nearest `<HttpStatusProvider>`'s sink during `setup()`
16
+ * and renders nothing. With no provider mounted (the standard client-side
17
+ * case) the component is a silent no-op — same component tree hydrates
18
+ * without touching the DOM or warning about mismatches.
19
+ *
20
+ * Loader-driven errors (`LoaderNotFound` → 404, `LoaderRedirect` → 30x) keep
21
+ * working as before; this component covers render-time decisions only.
22
+ *
23
+ * Last write wins when several `<HttpStatusCode />` instances mount in the
24
+ * same render pass — sink reflects the last component that ran.
25
+ *
26
+ * ```vue-html
27
+ * <HttpStatusProvider :sink="sink">
28
+ * <RouterProvider :router="router">
29
+ * <App />
30
+ * </RouterProvider>
31
+ * </HttpStatusProvider>
32
+ *
33
+ * <!-- inside NotFound.vue -->
34
+ * <HttpStatusCode :code="404" />
35
+ * ```
36
+ *
37
+ * **`renderToWebStream` (streaming SSR):** Vue 3 `<Suspense>` is
38
+ * chunked-blocking — Vue waits for every `async setup()` inside a boundary
39
+ * before emitting the chunks past it. So in practice `<HttpStatusCode />`
40
+ * inside a `<Suspense>` boundary still writes to the sink before the
41
+ * response headers flush. Still, prefer mounting in the shell to avoid
42
+ * coupling the contract to that particular Vue 3 streaming behaviour. With
43
+ * `renderToString` there is no ordering concern at all.
44
+ *
45
+ * **Hydration symmetry:** `<HttpStatusProvider>` wraps a render slot, so
46
+ * Vue emits a fragment marker pair (`<!--[-->` / `<!--]-->`) around its
47
+ * children server-side. Mount the same `<HttpStatusProvider>` on the
48
+ * client (with a throwaway sink) to keep the marker count balanced — see
49
+ * the `ssr/` example's `entry-client.ts`. Otherwise hydration logs
50
+ * "Hydration completed but contains mismatches".
51
+ *
52
+ * **Valid `code` range:** Node's `res.end()` throws `Invalid status code`
53
+ * on `NaN`, `0`, negative values, or values `> 999` — this surfaces as a
54
+ * 5xx / dropped connection, not silent corruption. Pass a real HTTP status
55
+ * integer (commonly 4xx/5xx; 100-999 is what Node accepts).
56
+ */
57
+ export const HttpStatusCode = defineComponent({
58
+ name: "HttpStatusCode",
59
+ props: {
60
+ /** HTTP status to apply to the response. Common values: 404, 410, 451, 503. */
61
+ code: { type: Number, required: true },
62
+ },
63
+ setup(props) {
64
+ const sink = inject(HTTP_STATUS_KEY, null);
65
+
66
+ if (sink) {
67
+ sink.code = props.code;
68
+ }
69
+
70
+ return renderNull;
71
+ },
72
+ });
73
+
74
+ export type HttpStatusCodeProps = InstanceType<typeof HttpStatusCode>["$props"];
@@ -0,0 +1,22 @@
1
+ import { defineComponent, provide } from "vue";
2
+
3
+ import { HTTP_STATUS_KEY } from "../context";
4
+
5
+ import type { HttpStatusSink } from "../utils/createHttpStatusSink";
6
+ import type { PropType } from "vue";
7
+
8
+ export const HttpStatusProvider = defineComponent({
9
+ name: "HttpStatusProvider",
10
+ props: {
11
+ sink: { type: Object as PropType<HttpStatusSink>, required: true },
12
+ },
13
+ setup(props, { slots }) {
14
+ provide(HTTP_STATUS_KEY, props.sink);
15
+
16
+ return () => slots.default?.();
17
+ },
18
+ });
19
+
20
+ export type HttpStatusProviderProps = InstanceType<
21
+ typeof HttpStatusProvider
22
+ >["$props"];
@@ -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 change — not on every
101
- // reactive read inside the source factory (the watchEffect alternative
102
- // would also re-subscribe whenever isActive itself changed).
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, routeParams, activeStrict, ignoreQueryParams, hash],
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
- const restAttributes: Record<string, unknown> = {};
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 } as Record<string, unknown>;
186
210
 
187
- for (const key of Object.keys(attrs)) {
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",