@real-router/vue 0.12.0 → 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
@@ -8,7 +8,11 @@ import {
8
8
  } from "vue";
9
9
 
10
10
  import { Match, NotFound, Self } from "./components";
11
- import { buildRenderList, collectElements } from "./helpers";
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
- const emptyKeepAlivePlaceholder = markRaw(
70
- defineComponent({
71
- name: "KeepAlive-placeholder",
72
- render() {
73
- return null;
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, { default: () => h(emptyKeepAlivePlaceholder) }),
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
- const { rendered, fallback } = buildRenderList(
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
- strict = false,
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
- export const useRoute = <P extends Params = Params>(): Omit<
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 Omit<RouteContext<P>, "route"> & {
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");
@@ -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. Kept for tests and direct callers.
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
+ }