diff --git a/docs/vue-support-tracker.md b/docs/vue-support-tracker.md index 9179d201..5e7a0974 100644 --- a/docs/vue-support-tracker.md +++ b/docs/vue-support-tracker.md @@ -1,6 +1,6 @@ # Vue support initiative: plan and tracker -Status: **Phase 2 in progress** (Phase 0: PR-01, PR-02, PR-03 landed; Phase 1: PR-10, PR-11, PR-12 landed; Phase 2: PR-20, PR-21, PR-22 landed). Last updated: 2026-07-06. +Status: **Phase 2 in progress** (Phase 0: PR-01, PR-02, PR-03 landed; Phase 1: PR-10, PR-11, PR-12 landed; Phase 2: PR-20, PR-21, PR-22, PR-23 landed). Last updated: 2026-07-06. Background and feasibility reasoning: [vue-port-analysis.md](./vue-port-analysis.md). This document is the single source of truth for the multi-PR effort to bring the framework to Vue 3, including full Journeys and Compositions support. Update the status board and per-PR checkboxes as PRs land; record decision outcomes in the Decisions section. @@ -189,9 +189,20 @@ Deviations from the plan, all forced by the framework: Error-message prefixes are `[@modular-vue/runtime]`. Acceptance: met. The integration suite (`app.test.ts`) boots a memory-history router with two modules, navigates between them (asserting both routing and injected navigation/modules/slots), exercises lazy mounting after `createRouter`, and covers the `beforeEach` auth guard, extra-plugin install, and mode exclusivity. 28 new tests across `route-builder.test.ts` (9), `app.test.ts` (11), and `resolve-manifest.test.ts` (8); package total 58. Full workspace typecheck (120 tasks) and `vite build` (JS + dts) pass; externals (`vue`, `vue-router`, `@modular-frontend/core`, `@modular-vue/vue`, `@modular-vue/core`) stay unbundled. -**PR-23 (M): `@modular-vue/runtime` part 3: zones and route data.** -`zones.ts`, `active-zones.ts`, `route-data.ts` over `useRoute().matched` and `route.meta`, funneling through core's `mergeRouteStaticData` (deepest-wins) and `createRouteDataOverrideWarner`. Port `zones.test.tsx`, `active-zones.test.tsx`, `route-data.test.tsx`, `slots.test.ts`. -Acceptance: deepest-wins merge behavior matches the React suites, including the override warning cases. +**PR-23 (M): `@modular-vue/runtime` part 3: zones and route data.** Done. +Added `zones.ts`, `active-zones.ts`, `route-data.ts` to `packages/vue-router-runtime`, each the Vue analog of the same-named React file (React sources: `react-router-runtime/src/zones.ts`, `active-zones.ts`, `route-data.ts`). All three read `useRoute().matched`, take zones/static data off each record's `meta` (vue-router's analog of React Router's `handle`, per the PR-20 `route-meta.ts` convention), and funnel through core's `mergeRouteStaticData` (deepest-wins) with a `createRouteDataOverrideWarner` dev warning. `useActiveZones` layers the active module's descriptor `zones` over the route zones, module-wins. Error-message-free surface; the runtime index now exports `useZones`, `useActiveZones`, `useRouteData`. + +Two changes in `@modular-frontend/core`, both anticipated by the warner's own "New runtimes … should add their package name here" note: + +- `createRouteDataOverrideWarner`'s `RouteDataRuntimeLabel` gained `"@modular-vue/runtime"` and `RouteDataFieldLabel` gained `"meta"` (the vue-router field), so the Vue composables pass the same compile-time-checked labels the React/TanStack runtimes do. +- `readMatchId` (which builds the override dedup key and the warning's route identifiers) now falls back to a vue-router record's `name`, then `path`, after `id`/`routeId` — vue-router matched records carry no `id`. Covered by a new frontend-core test. + +Deviations from the plan, all forced by the framework: + +- The React hooks return a plain object recomputed each render; the Vue composables return a `ComputedRef` driven by the reactive `useRoute()`, so the merged map recomputes on navigation. Consumers read `.value` (or let the template auto-unwrap). `useActiveZones` additionally accepts `activeModuleId` as a `MaybeRefOrGetter` and reads it via `toValue`, so a tab switcher's changing id re-drives the merge — the reactive analog of React reading the argument each render. +- The zones/active-zones/route-data tests mock `vue-router`'s `useRoute` (mirroring the React suites mocking `useMatches`) rather than booting a real router, and assert on `.value`. The `slots.test.ts` port pulls `buildSlotsManifest` / `collectDynamicSlotFactories` / `evaluateDynamicSlots` from `@modular-frontend/core` and `createSlotsSignal` from `@modular-vue/vue` (the runtime re-export barrel), matching the React source case-for-case. + +Error-message prefixes are `[@modular-vue/runtime]`. Acceptance: met. Deepest-wins merge, undefined-skips-inherit, and module-over-route precedence match the React suites; two added reactivity tests cover route-change recompute (`useZones`) and reactive `activeModuleId` re-merge (`useActiveZones`). 46 new tests across `zones.test.ts` (7), `active-zones.test.ts` (7), `route-data.test.ts` (9), `slots.test.ts` (23); package total 108. Full workspace typecheck (120 tasks) and `vite build` (JS + dts) pass; externals (`vue`, `vue-router`, `@modular-frontend/core`, `@modular-vue/vue`, `@modular-vue/core`) stay unbundled. **PR-24 (S): `@modular-vue/testing`.** `renderModule` with `@testing-library/vue`, `mock-store.ts`, `resolveModule` re-export. (`renderJourney` lands with PR-32.) @@ -296,7 +307,7 @@ Update the Status column as PRs move: `todo` → `in progress` → `in review` | PR-20 | @modular-vue/core | M | PR-10 | done | | PR-21 | runtime: registry | M | PR-20 | done | | PR-22 | runtime: route building, app plugin, guards | M | PR-21 | done | -| PR-23 | runtime: zones and route data | M | PR-22 | todo | +| PR-23 | runtime: zones and route data | M | PR-22 | done | | PR-24 | @modular-vue/testing | S | PR-23 | todo | | PR-30 | vue journeys: provider and composables | M | PR-02, PR-10 | todo | | PR-31 | vue journeys: outlet | L | PR-30 | todo | diff --git a/packages/frontend-core/src/route-data-warn.test.ts b/packages/frontend-core/src/route-data-warn.test.ts index d89e7f62..dab24db0 100644 --- a/packages/frontend-core/src/route-data-warn.test.ts +++ b/packages/frontend-core/src/route-data-warn.test.ts @@ -116,6 +116,48 @@ describe("createRouteDataOverrideWarner", () => { expect(message).toContain("/parent/leaf"); }); + it("falls back to vue-router record name, then path, when id/routeId are absent", () => { + process.env.NODE_ENV = "development"; + const warn = createRouteDataOverrideWarner("@modular-vue/runtime", "useZones", "meta")!; + + // Ancestor has a named route; descendant only has a path. + warn({ + key: "DetailPanel", + previousValue: "A", + nextValue: "B", + previousMatch: { name: "billing" }, + nextMatch: { path: "/billing/:id" }, + }); + + const message = String(warnSpy.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("[@modular-vue/runtime]"); + expect(message).toContain("meta"); + expect(message).toContain("billing"); + expect(message).toContain("/billing/:id"); + }); + + it("disambiguates the message by match position when two records share the same id", () => { + // vue-router nameless index routes report the same `path` as their + // parent, so prevId === nextId. Without the position the message reads + // as a route overriding itself; with it the two records are distinct. + process.env.NODE_ENV = "development"; + const warn = createRouteDataOverrideWarner("@modular-vue/runtime", "useZones", "meta")!; + + warn({ + key: "HeaderActions", + previousValue: "A", + nextValue: "B", + previousMatch: { path: "/dashboard" }, + nextMatch: { path: "/dashboard" }, + previousIndex: 0, + nextIndex: 1, + }); + + const message = String(warnSpy.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("/dashboard (match 1)"); + expect(message).toContain("/dashboard (match 0)"); + }); + it("uses when neither id nor routeId is present", () => { process.env.NODE_ENV = "development"; const warn = createRouteDataOverrideWarner( @@ -228,6 +270,35 @@ describe("createRouteDataOverrideWarner", () => { expect(warnSpy).toHaveBeenCalledTimes(2); }); + it("does not collapse distinct overrides that share an id but sit at different positions", () => { + // Two nameless index routes both resolve to `/dashboard`, so readMatchId + // returns the same id for each override site. Folding the match position + // into the dedup key keeps the second real clobber from being silenced. + process.env.NODE_ENV = "development"; + const warn = createRouteDataOverrideWarner("@modular-vue/runtime", "useZones", "meta")!; + + warn({ + key: "HeaderActions", + previousValue: "A", + nextValue: "B", + previousMatch: { path: "/dashboard" }, + nextMatch: { path: "/dashboard" }, + previousIndex: 0, + nextIndex: 1, + }); + warn({ + key: "HeaderActions", + previousValue: "B", + nextValue: "C", + previousMatch: { path: "/dashboard" }, + nextMatch: { path: "/dashboard" }, + previousIndex: 1, + nextIndex: 2, + }); + + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + it("uses an unambiguous separator so a space in any triple part can't collide", () => { // Regression rail for the dedup-key serialization. Before the // separator switched from a plain space to \x1F, the triples diff --git a/packages/frontend-core/src/route-data-warn.ts b/packages/frontend-core/src/route-data-warn.ts index 4863e039..a6cb4373 100644 --- a/packages/frontend-core/src/route-data-warn.ts +++ b/packages/frontend-core/src/route-data-warn.ts @@ -2,20 +2,21 @@ import { isDevEnv } from "./dev-env.js"; import type { RouteStaticDataOverrideInfo } from "./route-data.js"; /** - * Package label for the warning prefix. Closed set: the two runtime + * Package label for the warning prefix. Closed set: the first-party runtime * packages that wrap `mergeRouteStaticData` with `useZones` / - * `useRouteData`. New runtimes that integrate `mergeRouteStaticData` - * should add their package name here. + * `useRouteData` (React Router, TanStack Router, vue-router). New runtimes + * that integrate `mergeRouteStaticData` should add their package name here. */ export type RouteDataRuntimeLabel = | "@react-router-modules/runtime" - | "@tanstack-react-modules/runtime"; + | "@tanstack-react-modules/runtime" + | "@modular-vue/runtime"; /** Hook surfacing the warning. Closed set: the two route-data hooks. */ export type RouteDataHookName = "useZones" | "useRouteData"; /** Human label for the merged field — router-specific. */ -export type RouteDataFieldLabel = "handle" | "staticData"; +export type RouteDataFieldLabel = "handle" | "staticData" | "meta"; /** * Build an `onOverride` callback for `mergeRouteStaticData` that logs a @@ -29,8 +30,12 @@ export type RouteDataFieldLabel = "handle" | "staticData"; * * Dedup key is `(key, previousMatchId, nextMatchId)` so the warning fires * once per unique override per process — not once per render. Match `id`s - * are read off the match object at warn time; both React Router and - * TanStack Router expose a stable `id` field on `useMatches()` entries. + * are read off the match object at warn time: React Router and TanStack + * Router expose a stable `id`/`routeId` on `useMatches()` entries, and + * vue-router's matched records fall back to their `name` or `path`. Because + * a vue-router `path` is not unique (nameless index routes share their + * parent's `path`), the match's position in the hierarchy is folded into + * both the dedup key and the message when the ids would otherwise collide. * * Dedup state lives on the returned closure, not module-globally — under * dev HMR each module re-evaluation allocates a fresh warner, so a @@ -38,9 +43,10 @@ export type RouteDataFieldLabel = "handle" | "staticData"; * the expected behavior; the dedup is "once per process per warner * instance", not "once per process forever." * - * Cross-package helper consumed by `@react-router-modules/runtime` and - * `@tanstack-react-modules/runtime` to back the override warning fired by - * `useZones` and `useRouteData`. The parameter types are deliberately + * Cross-package helper consumed by `@react-router-modules/runtime`, + * `@tanstack-react-modules/runtime`, and `@modular-vue/runtime` to back the + * override warning fired by `useZones` and `useRouteData`. The parameter + * types are deliberately * narrow literal unions — they catch label typos at compile time and pin * the contract to first-party runtime hooks. The warning format and dedup * key shape are not part of the public stability contract; they may @@ -48,8 +54,8 @@ export type RouteDataFieldLabel = "handle" | "staticData"; * * @param runtimeLabel Package label for the warning prefix. * @param hookName Hook surfacing the warning. - * @param fieldLabel Field being merged: `"handle"` (React Router) or - * `"staticData"` (TanStack). + * @param fieldLabel Field being merged: `"handle"` (React Router), + * `"staticData"` (TanStack), or `"meta"` (vue-router). */ export function createRouteDataOverrideWarner( runtimeLabel: RouteDataRuntimeLabel, @@ -62,16 +68,30 @@ export function createRouteDataOverrideWarner( return (info) => { const prevId = readMatchId(info.previousMatch); const nextId = readMatchId(info.nextMatch); + // The router id is not always unique: vue-router nameless index routes + // share their parent's `path`, so an index child and its parent read the + // same id. Fold the match position into both the dedup key and the label + // so distinct override sites don't collide (silently suppressing a real + // clobber) and the message doesn't read as a route overriding itself. + const prevPart = info.previousIndex === undefined ? prevId : `${prevId}#${info.previousIndex}`; + const nextPart = info.nextIndex === undefined ? nextId : `${nextId}#${info.nextIndex}`; // Use ASCII Unit Separator (\x1F) between the triple parts so a // space in any one component (theoretical for route IDs, possible // for keys) can't bleed into another and create a false dedup hit. - const dedupKey = `${info.key}\x1F${prevId}\x1F${nextId}`; + const dedupKey = `${info.key}\x1F${prevPart}\x1F${nextPart}`; if (seen.has(dedupKey)) return; seen.add(dedupKey); + // Only surface the position when the two ids are otherwise identical — + // keeps the message byte-identical for React/TanStack (unique ids) and + // disambiguates the vue-router same-`path` case. + const ambiguous = prevId === nextId && info.previousIndex !== undefined; + const prevLabel = ambiguous ? `${prevId} (match ${info.previousIndex})` : prevId; + const nextLabel = ambiguous ? `${nextId} (match ${info.nextIndex})` : nextId; + // eslint-disable-next-line no-console console.warn( - `[${runtimeLabel}] ${hookName}: route "${nextId}" overrides ${fieldLabel} key "${info.key}" already set by ancestor "${prevId}". ` + + `[${runtimeLabel}] ${hookName}: route "${nextLabel}" overrides ${fieldLabel} key "${info.key}" already set by ancestor "${prevLabel}". ` + `If this override is intentional, ignore this warning. ` + `If "${info.key}" is owned by the shell layout, the descendant route should not declare it — ` + `omit the key to inherit, or set it to \`null\` to explicitly clear it.`, @@ -81,9 +101,13 @@ export function createRouteDataOverrideWarner( function readMatchId(match: unknown): string { if (match && typeof match === "object") { - const m = match as { id?: unknown; routeId?: unknown }; + const m = match as { id?: unknown; routeId?: unknown; name?: unknown; path?: unknown }; if (typeof m.id === "string") return m.id; if (typeof m.routeId === "string") return m.routeId; + // vue-router matched records carry no `id`; their `name` (when set) or + // `path` is the stable identifier. + if (typeof m.name === "string") return m.name; + if (typeof m.path === "string") return m.path; } return ""; } diff --git a/packages/frontend-core/src/route-data.test.ts b/packages/frontend-core/src/route-data.test.ts index addc296d..250df7e1 100644 --- a/packages/frontend-core/src/route-data.test.ts +++ b/packages/frontend-core/src/route-data.test.ts @@ -144,9 +144,31 @@ describe("mergeRouteStaticData", () => { nextValue: "ChildTitle", previousMatch: parent, nextMatch: child, + // Positions in the matched hierarchy — the always-unique identifier + // the warner uses to disambiguate matches with a shared router id. + previousIndex: 0, + nextIndex: 1, }); }); + it("reports each match's hierarchy position, skipping matches that contribute no data", () => { + const onOverride = vi.fn<(info: RouteStaticDataOverrideInfo) => void>(); + // The intermediate match (index 1) has no handle, so the override is + // between index 0 and index 2 — the counter still advances per match. + const matches: RRMatch[] = [ + { id: "/", handle: { HeaderTitle: "Root" } }, + { id: "/layout" }, + { id: "/layout/leaf", handle: { HeaderTitle: "Leaf" } }, + ]; + + mergeRouteStaticData(matches, getHandle, { onOverride }); + + expect(onOverride).toHaveBeenCalledTimes(1); + expect(onOverride).toHaveBeenCalledWith( + expect.objectContaining({ previousIndex: 0, nextIndex: 2 }), + ); + }); + it("does not fire when a deeper match contributes a brand-new key", () => { const onOverride = vi.fn<(info: RouteStaticDataOverrideInfo) => void>(); const matches: RRMatch[] = [ diff --git a/packages/frontend-core/src/route-data.ts b/packages/frontend-core/src/route-data.ts index d661cb7f..b3a4992b 100644 --- a/packages/frontend-core/src/route-data.ts +++ b/packages/frontend-core/src/route-data.ts @@ -13,6 +13,16 @@ export interface RouteStaticDataOverrideInfo { previousMatch: unknown; /** The deeper match that contributed `nextValue`. */ nextMatch: unknown; + /** + * Zero-based position of `previousMatch` in the matched hierarchy + * (root = 0). A stable, always-unique identifier for the match within a + * single merge pass — unlike the router-specific id, which is not unique + * for vue-router nameless index routes (they share their parent's `path`). + * `undefined` only when the info is hand-constructed (e.g. in tests). + */ + previousIndex?: number; + /** Zero-based position of `nextMatch` in the matched hierarchy. */ + nextIndex?: number; } export interface MergeRouteStaticDataOptions { @@ -63,9 +73,15 @@ export function mergeRouteStaticData( const merged: Record = {}; // Only allocate the source-tracking map when a caller actually wants // override notifications; the common production path has no overhead. - const sources: Record | null = options?.onOverride ? {} : null; + // Track each key's contributing match *and its position* — the position + // is the only always-unique identifier (vue-router nameless index routes + // share their parent's `path`, so router ids can collide). + const sources: Record | null = options?.onOverride + ? {} + : null; const onOverride = options?.onOverride; + let index = 0; for (const match of matches) { const data = getData(match); // `typeof null === "object"` is the well-known JS footgun — the truthiness @@ -80,19 +96,23 @@ export function mergeRouteStaticData( // `null` is the documented "explicit clear" escape hatch — // skip onOverride for it so the canonical intentional path // is silent and only real value-replacing overrides warn. + const previous = sources[key]; onOverride!({ key, previousValue: merged[key], nextValue: value, - previousMatch: sources[key], + previousMatch: previous.match, nextMatch: match, + previousIndex: previous.index, + nextIndex: index, }); } merged[key] = value; - if (sources) sources[key] = match; + if (sources) sources[key] = { match, index }; } } } + index++; } return merged as Partial; } diff --git a/packages/vue-router-runtime/src/active-zones.test.ts b/packages/vue-router-runtime/src/active-zones.test.ts new file mode 100644 index 00000000..c86926d7 --- /dev/null +++ b/packages/vue-router-runtime/src/active-zones.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ref } from "vue"; + +vi.mock("vue-router", () => ({ + useRoute: vi.fn(), +})); + +vi.mock("@modular-vue/vue", () => ({ + useModules: vi.fn(), +})); + +import { useRoute } from "vue-router"; +import { useModules } from "@modular-vue/vue"; +import type { UiComponent } from "@modular-frontend/core"; +import { useActiveZones } from "./active-zones.js"; + +interface TestZones { + detailPanel?: UiComponent; + headerActions?: UiComponent; +} + +const mockUseRoute = vi.mocked(useRoute); +const mockUseModules = vi.mocked(useModules); + +function route(matched: unknown[]) { + return { matched } as ReturnType; +} + +function PanelA() { + return null; +} +function PanelB() { + return null; +} +function PanelC() { + return null; +} + +beforeEach(() => { + mockUseRoute.mockReturnValue(route([])); + mockUseModules.mockReturnValue([]); +}); + +describe("useActiveZones", () => { + it("returns route zones when no activeModuleId is given", () => { + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA } }])); + + const result = useActiveZones(); + expect(result.value.detailPanel).toBe(PanelA); + }); + + it("returns route zones when activeModuleId is null", () => { + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA } }])); + + const result = useActiveZones(null); + expect(result.value.detailPanel).toBe(PanelA); + }); + + it("returns route zones when active module has no zones", () => { + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA } }])); + mockUseModules.mockReturnValue([{ id: "billing", version: "1.0.0" }]); + + const result = useActiveZones("billing"); + expect(result.value.detailPanel).toBe(PanelA); + }); + + it("returns route zones when active module is not found", () => { + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA } }])); + mockUseModules.mockReturnValue([{ id: "billing", version: "1.0.0" }]); + + const result = useActiveZones("unknown"); + expect(result.value.detailPanel).toBe(PanelA); + }); + + it("merges module zones over route zones", () => { + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA, headerActions: PanelB } }])); + mockUseModules.mockReturnValue([ + { id: "billing", version: "1.0.0", zones: { detailPanel: PanelC } }, + ]); + + const result = useActiveZones("billing"); + expect(result.value.detailPanel).toBe(PanelC); + expect(result.value.headerActions).toBe(PanelB); + }); + + it("module zones override route zones for the same key", () => { + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA } }])); + mockUseModules.mockReturnValue([ + { id: "billing", version: "1.0.0", zones: { detailPanel: PanelB } }, + ]); + + const result = useActiveZones("billing"); + expect(result.value.detailPanel).toBe(PanelB); + }); + + it("tracks a reactive activeModuleId, re-merging when it changes", () => { + // A tab switcher passes a ref; flipping it must re-drive the merge so the + // newly active module's zones win. + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA } }])); + mockUseModules.mockReturnValue([ + { id: "billing", version: "1.0.0", zones: { detailPanel: PanelB } }, + ]); + + const activeId = ref(null); + const result = useActiveZones(activeId); + // No active module → route zone. + expect(result.value.detailPanel).toBe(PanelA); + + activeId.value = "billing"; + // Active module contributes → module zone wins. + expect(result.value.detailPanel).toBe(PanelB); + }); +}); diff --git a/packages/vue-router-runtime/src/active-zones.ts b/packages/vue-router-runtime/src/active-zones.ts new file mode 100644 index 00000000..661de915 --- /dev/null +++ b/packages/vue-router-runtime/src/active-zones.ts @@ -0,0 +1,42 @@ +import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from "vue"; +import { useModules } from "@modular-vue/vue"; +import type { ZoneMapOf } from "@modular-vue/core"; +import { useZones } from "./zones.js"; + +/** + * Read zone components from both the matched route hierarchy AND the + * currently active module (identified by `activeModuleId`). + * + * This unifies two zone contribution patterns: + * - **Route-based modules** contribute zones via vue-router's `meta` + * - **Tab-based modules** contribute zones via the `zones` field on their descriptor + * + * When both sources provide a value for the same zone key, the module's + * contribution wins. + * + * `activeModuleId` accepts a plain value, a ref, or a getter: pass a ref or + * getter when the active module changes over the shell's lifetime (e.g. a tab + * switcher) so the merged map recomputes. Returns a `ComputedRef` for the same + * reason `useZones` does — the matched route hierarchy is reactive. + */ +export function useActiveZones>( + activeModuleId?: MaybeRefOrGetter, +): ComputedRef> { + const routeZones = useZones(); + const modules = useModules(); + + return computed(() => { + const id = toValue(activeModuleId); + if (!id) { + return routeZones.value; + } + + const activeMod = modules.find((m) => m.id === id); + if (!activeMod?.zones) { + return routeZones.value; + } + + // Module zones override route zones for the same key + return { ...routeZones.value, ...activeMod.zones } as Partial; + }); +} diff --git a/packages/vue-router-runtime/src/index.ts b/packages/vue-router-runtime/src/index.ts index 44a4e840..3d41ddb1 100644 --- a/packages/vue-router-runtime/src/index.ts +++ b/packages/vue-router-runtime/src/index.ts @@ -55,3 +55,8 @@ export { ModuleErrorBoundary, } from "@modular-vue/vue"; export type { SlotsSignal } from "@modular-vue/vue"; + +// Zones and route data (router-specific) — read over `useRoute().matched` +export { useZones } from "./zones.js"; +export { useActiveZones } from "./active-zones.js"; +export { useRouteData } from "./route-data.js"; diff --git a/packages/vue-router-runtime/src/route-data.test.ts b/packages/vue-router-runtime/src/route-data.test.ts new file mode 100644 index 00000000..68e26f9f --- /dev/null +++ b/packages/vue-router-runtime/src/route-data.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, expectTypeOf } from "vitest"; + +vi.mock("vue-router", () => ({ + useRoute: vi.fn(), +})); + +import { useRoute } from "vue-router"; +import { useRouteData } from "./route-data.js"; + +/** + * `useRouteData` is the relaxed-typing counterpart to `useZones` — same + * deepest-wins merge over `route.meta`, but values don't have to be component + * types. These tests exercise the behaviors the shell relies on: merging + * across route hierarchies, deepest-wins conflict resolution, preserving + * parent values when a deeper match doesn't override, and handling of matches + * with no `meta` at all. + */ + +const mockUseRoute = vi.mocked(useRoute); + +function route(matched: unknown[]) { + return { matched } as ReturnType; +} + +describe("useRouteData", () => { + describe("merge semantics", () => { + it("returns an empty object when no matches contribute meta", () => { + mockUseRoute.mockReturnValue(route([{ meta: {} }, { meta: {} }])); + expect(useRouteData>().value).toEqual({}); + }); + + it("returns values from a single matched route", () => { + mockUseRoute.mockReturnValue(route([{ meta: { headerVariant: "portal" } }])); + const data = useRouteData<{ headerVariant?: string }>(); + expect(data.value.headerVariant).toBe("portal"); + }); + + it("deepest match wins for the same key — child route overrides parent", () => { + mockUseRoute.mockReturnValue( + route([{ meta: { headerVariant: "portal" } }, { meta: { headerVariant: "project" } }]), + ); + const data = useRouteData<{ headerVariant?: string }>(); + expect(data.value.headerVariant).toBe("project"); + }); + + it("merges distinct keys across the hierarchy", () => { + mockUseRoute.mockReturnValue( + route([{ meta: { pageTitle: "Parent" } }, { meta: { headerVariant: "project" } }]), + ); + const data = useRouteData<{ pageTitle?: string; headerVariant?: string }>(); + expect(data.value).toEqual({ pageTitle: "Parent", headerVariant: "project" }); + }); + + it("skips undefined values so a parent's value isn't clobbered by a child that didn't declare the key", () => { + // Typical case: a parent sets headerVariant, an intermediate layout + // doesn't touch it (so its meta doesn't include the key at all), and a + // leaf neither sets nor overrides it — parent's value survives. + mockUseRoute.mockReturnValue( + route([ + { meta: { headerVariant: "portal" } }, + { meta: {} }, + { meta: { headerVariant: undefined } }, + ]), + ); + const data = useRouteData<{ headerVariant?: string }>(); + expect(data.value.headerVariant).toBe("portal"); + }); + + it("tolerates matches with no meta field at all", () => { + mockUseRoute.mockReturnValue(route([{}, { meta: { headerVariant: "portal" } }, {}])); + const data = useRouteData<{ headerVariant?: string }>(); + expect(data.value.headerVariant).toBe("portal"); + }); + }); + + describe("value types", () => { + it("accepts non-component values — strings, numbers, objects, enums", () => { + interface RouteData { + headerVariant?: "portal" | "project"; + maxResults?: number; + pageTitle?: string; + featureFlags?: { experimentalSearch: boolean }; + } + + mockUseRoute.mockReturnValue( + route([ + { + meta: { + headerVariant: "project" as const, + maxResults: 50, + pageTitle: "Requests", + featureFlags: { experimentalSearch: true }, + }, + }, + ]), + ); + + const data = useRouteData(); + expect(data.value.headerVariant).toBe("project"); + expect(data.value.maxResults).toBe(50); + expect(data.value.pageTitle).toBe("Requests"); + expect(data.value.featureFlags).toEqual({ experimentalSearch: true }); + }); + + it("type-level: returned value shape is Partial — every key is optional", () => { + interface RouteData { + headerVariant: "portal" | "project"; + pageTitle: string; + } + mockUseRoute.mockReturnValue(route([])); + const data = useRouteData(); + expectTypeOf(data.value).toEqualTypeOf>(); + }); + }); + + describe("coexists with useZones in the same meta", () => { + it("reads only the keys declared in TRouteData, leaving component zones untouched for useZones", () => { + // A route can legitimately contribute both: a HeaderActions component + // (consumed by useZones) AND a headerVariant enum (consumed by + // useRouteData). Both composables read the same match, each narrows to + // its own declared shape. + interface RouteData { + headerVariant?: "portal" | "project"; + } + function HeaderActions() { + return null; + } + mockUseRoute.mockReturnValue(route([{ meta: { HeaderActions, headerVariant: "project" } }])); + + const data = useRouteData(); + expect(data.value.headerVariant).toBe("project"); + // Non-declared keys are still present in the merged value — the + // composable doesn't filter; it's the consumer's TypeScript signature + // that narrows access. This is intentional so the two composables don't + // have to coordinate on key sets. + expect((data.value as Record).HeaderActions).toBe(HeaderActions); + }); + }); +}); diff --git a/packages/vue-router-runtime/src/route-data.ts b/packages/vue-router-runtime/src/route-data.ts new file mode 100644 index 00000000..6605c97d --- /dev/null +++ b/packages/vue-router-runtime/src/route-data.ts @@ -0,0 +1,91 @@ +import { computed, type ComputedRef } from "vue"; +import { useRoute } from "vue-router"; +import { createRouteDataOverrideWarner, mergeRouteStaticData } from "@modular-frontend/core"; + +/** + * Read merged `meta` values from the currently matched route hierarchy — + * the "non-component zone" escape hatch. + * + * `useZones` is the component-typed channel: each value must be a + * `UiComponent | undefined` so the shell can render it in a layout + * region. That constraint is a useful rail 95% of the time, but it gets in + * the way for non-component metadata the module wants to attach to a route — + * a header variant enum, a page title string, an analytics event name, a + * per-route feature flag. `useRouteData` is the relaxed-typing counterpart: + * same deepest-wins merge over `route.meta`, no constraint on values. + * + * Two composables, two channels: keep components in `meta` fields consumed by + * `useZones`, keep metadata in fields consumed by `useRouteData`. They can + * co-exist in the same `meta` object because they read the same match + * values; each composable only surfaces the keys you've declared in its type. + * + * @example + * ```ts + * // Declare both shapes explicitly — zones for renderable components, + * // route data for everything else. + * interface AppZones { + * HeaderActions?: UiComponent + * DetailPanel?: UiComponent + * } + * interface AppRouteData { + * headerVariant?: "portal" | "project" | "setup" + * pageTitle?: string + * } + * + * // A route can contribute to both: + * meta: { + * HeaderActions: ProjectActions, // → useZones() + * headerVariant: "project" as const, // → useRouteData() + * } + * + * // Layout reads each channel with its own typing: + * const zones = useZones() + * const routeData = useRouteData() + * // routeData.value.headerVariant, routeData.value.pageTitle + * ``` + * + * ## Merge semantics + * + * Walks matched records root-to-leaf, deepest match wins per key. + * `undefined` values at a deeper level don't override an ancestor — + * **omit the key (or set it to `undefined`) to inherit**. Set the key + * to `null` to **explicitly clear** an ancestor's value; the consuming + * shell decides how to render `null` (typically: as if the field was + * never set, but distinct from "still loading"). + * + * In dev (NODE_ENV !== "production"), this composable logs a deduped + * `console.warn` whenever a deeper match overrides a key already set by + * an ancestor. The warning is intended to catch accidental clobbers of + * shell-owned route data (e.g. `headerVariant`); ignore it when the + * override is intentional. + * + * ## Returned object contains all meta keys, not just declared ones + * + * The merged value is the raw merged `meta` — TypeScript narrows what you + * can *access* via `TRouteData`, but every key present across matches is + * still there at runtime. If a route declared a component zone (e.g. + * `HeaderActions`) on the same `meta` object, it appears here too. + * + * This is intentional: the two composables (`useZones` / `useRouteData`) + * don't have to coordinate on key sets, so a migration can split meta fields + * between them incrementally. The consequence is that code that iterates the + * merged value (`Object.keys(useRouteData().value)`, `JSON.stringify`, etc.) + * will see component entries mixed with data entries — read by declared key, + * not by iteration. + * + * ## Return value + * + * Returns a `ComputedRef` driven by `useRoute()`, so the merged data + * recomputes when navigation changes the matched hierarchy. Read + * `routeData.value` in script, or let the template auto-unwrap it. + */ +const onOverride = createRouteDataOverrideWarner("@modular-vue/runtime", "useRouteData", "meta"); + +export function useRouteData(): ComputedRef> { + const route = useRoute(); + return computed(() => + mergeRouteStaticData(route.matched, (match) => (match as { meta?: unknown }).meta, { + onOverride, + }), + ); +} diff --git a/packages/vue-router-runtime/src/slots.test.ts b/packages/vue-router-runtime/src/slots.test.ts new file mode 100644 index 00000000..dd04d29e --- /dev/null +++ b/packages/vue-router-runtime/src/slots.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi } from "vitest"; +import { + buildSlotsManifest, + collectDynamicSlotFactories, + evaluateDynamicSlots, +} from "@modular-frontend/core"; +import type { DynamicSlotFactory, SlotFilter } from "@modular-frontend/core"; +import { createSlotsSignal } from "@modular-vue/vue"; +import type { ModuleDescriptor } from "@modular-vue/core"; + +// Plain interface — no index signature or SlotMap extends needed +interface TestSlots { + commands: { id: string; label: string }[]; + badges: { type: string }[]; +} + +interface TestDeps { + auth: { user: { role: string } | null }; +} + +function fakeModule( + overrides: Partial> = {}, +): ModuleDescriptor { + return { + id: overrides.id ?? "test", + version: "0.1.0", + ...overrides, + }; +} + +describe("buildSlotsManifest", () => { + it("returns empty object when no modules have slots", () => { + const result = buildSlotsManifest([ + fakeModule({ id: "a" }), + fakeModule({ id: "b" }), + ]); + + expect(result).toEqual({}); + }); + + it("collects slots from a single module", () => { + const result = buildSlotsManifest([ + fakeModule({ + id: "billing", + slots: { + commands: [{ id: "cmd-1", label: "Open Billing" }], + }, + }), + ]); + + expect(result.commands).toEqual([{ id: "cmd-1", label: "Open Billing" }]); + }); + + it("concatenates slots from multiple modules", () => { + const result = buildSlotsManifest([ + fakeModule({ + id: "billing", + slots: { + commands: [{ id: "cmd-1", label: "Open Billing" }], + badges: [{ type: "overdue" }], + }, + }), + fakeModule({ + id: "users", + slots: { + commands: [{ id: "cmd-2", label: "View Users" }], + }, + }), + ]); + + expect(result.commands).toEqual([ + { id: "cmd-1", label: "Open Billing" }, + { id: "cmd-2", label: "View Users" }, + ]); + expect(result.badges).toEqual([{ type: "overdue" }]); + }); + + it("skips modules without slots property", () => { + const result = buildSlotsManifest([ + fakeModule({ id: "no-slots" }), + fakeModule({ + id: "with-slots", + slots: { commands: [{ id: "cmd-1", label: "Test" }] }, + }), + ]); + + expect(result.commands).toEqual([{ id: "cmd-1", label: "Test" }]); + }); + + it("handles empty slot arrays", () => { + const result = buildSlotsManifest([ + fakeModule({ + id: "empty", + slots: { commands: [] }, + }), + ]); + + expect(result.commands).toEqual([]); + }); + + it("initializes declared keys from defaults even when no module contributes", () => { + const result = buildSlotsManifest([fakeModule({ id: "no-slots" })], { + commands: [], + badges: [], + }); + + expect(result.commands).toEqual([]); + expect(result.badges).toEqual([]); + }); + + it("appends module contributions to defaults", () => { + const result = buildSlotsManifest( + [ + fakeModule({ + id: "billing", + slots: { commands: [{ id: "cmd-1", label: "Open Billing" }] }, + }), + ], + { commands: [], badges: [] }, + ); + + expect(result.commands).toEqual([{ id: "cmd-1", label: "Open Billing" }]); + expect(result.badges).toEqual([]); + }); +}); + +describe("collectDynamicSlotFactories", () => { + it("returns empty array when no modules have dynamicSlots", () => { + const factories = collectDynamicSlotFactories([ + fakeModule({ id: "a" }), + fakeModule({ id: "b" }), + ]); + + expect(factories).toEqual([]); + }); + + it("collects dynamicSlots functions from modules that have them", () => { + const dynamicFn = (deps: TestDeps) => ({ + commands: deps.auth.user ? [{ id: "dyn", label: "Dynamic" }] : [], + }); + + const factories = collectDynamicSlotFactories([ + fakeModule({ id: "static-only", slots: { commands: [{ id: "s", label: "S" }] } }), + fakeModule({ id: "dynamic", dynamicSlots: dynamicFn }), + ]); + + expect(factories).toHaveLength(1); + expect(factories[0]).toBe(dynamicFn); + }); + + it("collects from multiple modules", () => { + const fn1 = () => ({ commands: [] }); + const fn2 = () => ({ badges: [] }); + + const factories = collectDynamicSlotFactories([ + fakeModule({ id: "a", dynamicSlots: fn1 as any }), + fakeModule({ id: "b", dynamicSlots: fn2 as any }), + ]); + + expect(factories).toHaveLength(2); + }); +}); + +describe("evaluateDynamicSlots", () => { + const baseSlots: TestSlots = { + commands: [{ id: "static-1", label: "Static Command" }], + badges: [], + }; + + it("returns base slots when no factories are provided", () => { + const result = evaluateDynamicSlots(baseSlots, [], {}); + + expect(result.commands).toEqual([{ id: "static-1", label: "Static Command" }]); + expect(result.badges).toEqual([]); + }); + + it("merges dynamic contributions with base slots", () => { + const factory: DynamicSlotFactory = () => ({ + commands: [{ id: "dyn-1", label: "Dynamic Command" }], + }); + + const result = evaluateDynamicSlots(baseSlots, [factory], {}); + + expect(result.commands).toEqual([ + { id: "static-1", label: "Static Command" }, + { id: "dyn-1", label: "Dynamic Command" }, + ]); + }); + + it("evaluates factory with deps and conditionally contributes", () => { + const factory: DynamicSlotFactory = (deps) => { + const auth = deps.auth as { user: { role: string } | null }; + return { + commands: auth.user?.role === "admin" ? [{ id: "admin", label: "Admin Panel" }] : [], + }; + }; + + // With admin user + const adminResult = evaluateDynamicSlots(baseSlots, [factory], { + auth: { user: { role: "admin" } }, + }); + expect(adminResult.commands).toEqual([ + { id: "static-1", label: "Static Command" }, + { id: "admin", label: "Admin Panel" }, + ]); + + // With regular user + const userResult = evaluateDynamicSlots(baseSlots, [factory], { + auth: { user: { role: "viewer" } }, + }); + expect(userResult.commands).toEqual([{ id: "static-1", label: "Static Command" }]); + + // With no user (logged out) + const anonResult = evaluateDynamicSlots(baseSlots, [factory], { + auth: { user: null }, + }); + expect(anonResult.commands).toEqual([{ id: "static-1", label: "Static Command" }]); + }); + + it("merges contributions from multiple factories", () => { + const factory1: DynamicSlotFactory = () => ({ + commands: [{ id: "dyn-1", label: "From Factory 1" }], + }); + const factory2: DynamicSlotFactory = () => ({ + commands: [{ id: "dyn-2", label: "From Factory 2" }], + badges: [{ type: "new" }], + }); + + const result = evaluateDynamicSlots(baseSlots, [factory1, factory2], {}); + + expect(result.commands).toEqual([ + { id: "static-1", label: "Static Command" }, + { id: "dyn-1", label: "From Factory 1" }, + { id: "dyn-2", label: "From Factory 2" }, + ]); + expect(result.badges).toEqual([{ type: "new" }]); + }); + + it("initializes new slot keys from dynamic contributions", () => { + const factory: DynamicSlotFactory = () => ({ + badges: [{ type: "urgent" }], + }); + + const sparseBase = { commands: [{ id: "cmd", label: "Cmd" }] } as unknown as TestSlots; + const result = evaluateDynamicSlots(sparseBase, [factory], {}); + + expect(result.badges).toEqual([{ type: "urgent" }]); + }); + + it("does not mutate the base slots", () => { + const factory: DynamicSlotFactory = () => ({ + commands: [{ id: "dyn", label: "Dynamic" }], + }); + + const originalCommands = [...baseSlots.commands]; + evaluateDynamicSlots(baseSlots, [factory], {}); + + expect(baseSlots.commands).toEqual(originalCommands); + }); + + it("skips factory that returns null/undefined", () => { + const factory: DynamicSlotFactory = () => null as any; + + const result = evaluateDynamicSlots(baseSlots, [factory], {}); + + expect(result.commands).toEqual([{ id: "static-1", label: "Static Command" }]); + }); + + describe("with slotFilter", () => { + it("applies filter after merging all contributions", () => { + const factory: DynamicSlotFactory = () => ({ + commands: [{ id: "dyn-1", label: "Dynamic" }], + }); + + const filter: SlotFilter = (slots) => ({ + ...slots, + commands: (slots.commands as { id: string; label: string }[]).filter( + (cmd) => cmd.id !== "static-1", + ), + }); + + const result = evaluateDynamicSlots(baseSlots, [factory], {}, filter); + + expect(result.commands).toEqual([{ id: "dyn-1", label: "Dynamic" }]); + }); + + it("receives current deps in the filter", () => { + const filter: SlotFilter = (slots, deps) => { + const auth = deps.auth as { user: { role: string } | null }; + if (auth.user?.role !== "admin") { + return { + ...slots, + commands: (slots.commands as { id: string; label: string }[]).filter( + (cmd) => !cmd.id.startsWith("admin"), + ), + }; + } + return slots; + }; + + const slotsWithAdmin: TestSlots = { + commands: [ + { id: "public-1", label: "Public" }, + { id: "admin-1", label: "Admin Only" }, + ], + badges: [], + }; + + // Non-admin: admin items filtered out + const viewerResult = evaluateDynamicSlots( + slotsWithAdmin, + [], + { auth: { user: { role: "viewer" } } }, + filter, + ); + expect(viewerResult.commands).toEqual([{ id: "public-1", label: "Public" }]); + + // Admin: all items kept + const adminResult = evaluateDynamicSlots( + slotsWithAdmin, + [], + { auth: { user: { role: "admin" } } }, + filter, + ); + expect(adminResult.commands).toEqual([ + { id: "public-1", label: "Public" }, + { id: "admin-1", label: "Admin Only" }, + ]); + }); + + it("works with no factories, only a filter", () => { + const filter: SlotFilter = (slots) => ({ + ...slots, + commands: [], + }); + + const result = evaluateDynamicSlots(baseSlots, [], {}, filter); + + expect(result.commands).toEqual([]); + expect(result.badges).toEqual([]); + }); + }); +}); + +describe("createSlotsSignal", () => { + it("notifies all subscribers when notify is called", () => { + const signal = createSlotsSignal(); + const listener1 = vi.fn(); + const listener2 = vi.fn(); + + signal.subscribe(listener1); + signal.subscribe(listener2); + signal.notify(); + + expect(listener1).toHaveBeenCalledOnce(); + expect(listener2).toHaveBeenCalledOnce(); + }); + + it("does not call unsubscribed listeners", () => { + const signal = createSlotsSignal(); + const listener = vi.fn(); + + const unsubscribe = signal.subscribe(listener); + unsubscribe(); + signal.notify(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("handles notify with no subscribers", () => { + const signal = createSlotsSignal(); + // Should not throw + signal.notify(); + }); +}); diff --git a/packages/vue-router-runtime/src/zones.test.ts b/packages/vue-router-runtime/src/zones.test.ts new file mode 100644 index 00000000..63e46186 --- /dev/null +++ b/packages/vue-router-runtime/src/zones.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest"; +import { reactive } from "vue"; + +vi.mock("vue-router", () => ({ + useRoute: vi.fn(), +})); + +import { useRoute } from "vue-router"; +import type { UiComponent } from "@modular-frontend/core"; +import { useZones } from "./zones.js"; + +interface TestZones { + detailPanel?: UiComponent; + headerActions?: UiComponent; +} + +const mockUseRoute = vi.mocked(useRoute); + +// Each matched record carries zones on `meta` (vue-router's analog of the +// React Router `handle` channel). +function route(matched: unknown[]) { + return { matched } as ReturnType; +} + +function PanelA() { + return null; +} +function PanelB() { + return null; +} + +describe("useZones", () => { + it("returns empty object when no matches have meta", () => { + mockUseRoute.mockReturnValue(route([{ meta: {} }, { meta: {} }])); + const result = useZones(); + expect(result.value).toEqual({}); + }); + + it("returns zone component from matched route", () => { + mockUseRoute.mockReturnValue(route([{ meta: { detailPanel: PanelA } }])); + const result = useZones(); + expect(result.value.detailPanel).toBe(PanelA); + }); + + it("deepest match wins for the same zone key", () => { + mockUseRoute.mockReturnValue( + route([{ meta: { detailPanel: PanelA } }, { meta: { detailPanel: PanelB } }]), + ); + const result = useZones(); + expect(result.value.detailPanel).toBe(PanelB); + }); + + it("merges zones across the match hierarchy", () => { + mockUseRoute.mockReturnValue( + route([{ meta: { headerActions: PanelA } }, { meta: { detailPanel: PanelB } }]), + ); + const result = useZones(); + expect(result.value.headerActions).toBe(PanelA); + expect(result.value.detailPanel).toBe(PanelB); + }); + + it("skips undefined values so parent zone is preserved", () => { + mockUseRoute.mockReturnValue( + route([{ meta: { detailPanel: PanelA } }, { meta: { detailPanel: undefined } }]), + ); + const result = useZones(); + expect(result.value.detailPanel).toBe(PanelA); + }); + + it("handles matches with no meta", () => { + mockUseRoute.mockReturnValue(route([{}, { meta: { detailPanel: PanelA } }])); + const result = useZones(); + expect(result.value.detailPanel).toBe(PanelA); + }); + + it("recomputes when the matched hierarchy changes", () => { + // The composable returns a ComputedRef over the reactive route — a + // navigation that swaps `matched` re-drives the merge on next read. + const current = reactive({ matched: [{ meta: { detailPanel: PanelA } }] }); + mockUseRoute.mockReturnValue(current as unknown as ReturnType); + const result = useZones(); + expect(result.value.detailPanel).toBe(PanelA); + + current.matched = [{ meta: { detailPanel: PanelB } }]; + expect(result.value.detailPanel).toBe(PanelB); + }); +}); diff --git a/packages/vue-router-runtime/src/zones.ts b/packages/vue-router-runtime/src/zones.ts new file mode 100644 index 00000000..67af3501 --- /dev/null +++ b/packages/vue-router-runtime/src/zones.ts @@ -0,0 +1,68 @@ +import { computed, type ComputedRef } from "vue"; +import { useRoute } from "vue-router"; +import { createRouteDataOverrideWarner, mergeRouteStaticData } from "@modular-frontend/core"; +import type { ZoneMapOf } from "@modular-vue/core"; + +/** + * Read zone components contributed by the currently matched route hierarchy. + * + * Zones ride on vue-router's `meta` (the analog of React Router's `handle`) + * on individual routes. This composable walks all matched records from root + * to leaf via `useRoute().matched` and returns a merged map where the deepest + * match wins for each zone key. + * + * @example + * // In the shell layout's