Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions docs/vue-support-tracker.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Test-count arithmetic doesn't add up.

PR-22 states a "package total 58" and PR-23 says "46 new tests ... package total 108". 58 + 46 = 104, not 108 — a 4-test discrepancy. The individual per-file counts (7+7+9+23=46) are internally consistent, so the "108" running total looks like the error.

🔧 Proposed fix
-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.
+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 104. 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.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.
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 104. 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.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/vue-support-tracker.md` at line 205, The test-count summary in the Vue
support tracker has a running-total mismatch: the per-file counts in the PR-23
summary add up correctly, but the stated package total does not match the prior
total from PR-22. Update the summary text in docs/vue-support-tracker.md so the
cumulative package total is arithmetically consistent with the earlier “package
total 58” and the 46 new tests, and keep the existing per-file counts and
feature notes unchanged.


**PR-24 (S): `@modular-vue/testing`.**
`renderModule` with `@testing-library/vue`, `mock-store.ts`, `resolveModule` re-export. (`renderJourney` lands with PR-32.)
Expand Down Expand Up @@ -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 |
Expand Down
71 changes: 71 additions & 0 deletions packages/frontend-core/src/route-data-warn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <unknown> when neither id nor routeId is present", () => {
process.env.NODE_ENV = "development";
const warn = createRouteDataOverrideWarner(
Expand Down Expand Up @@ -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
Expand Down
54 changes: 39 additions & 15 deletions packages/frontend-core/src/route-data-warn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,27 +30,32 @@ 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.
Comment on lines +33 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

JSDoc overstates when position-folding applies to the dedup key.

The docstring says the match position is folded into both the dedup key and the message when the ids would otherwise collide, but in the implementation the dedup key always folds in the index whenever it's defined (prevPart/nextPart), regardless of whether prevId === nextId. Only the message label folding is conditioned on collision (ambiguous). This could mislead a future reader into assuming the dedup key stays untouched for non-colliding ids.

✏️ Suggested wording fix
- * 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.
+ * a vue-router `path` is not unique (nameless index routes share their
+ * parent's `path`), the match's position is always folded into the dedup
+ * key (when known), and is additionally surfaced in the message only when
+ * the ids would otherwise collide.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* 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.
* 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 is always folded into the dedup
* key (when known), and is additionally surfaced in the message only when
* the ids would otherwise collide.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend-core/src/route-data-warn.ts` around lines 33 - 38, Update
the JSDoc in route-data-warn so it matches the actual dedup logic in the route
matcher warning helper: the hierarchy position is always included in the dedup
key via the prevPart/nextPart handling, not only when ids collide. Keep the
collision-specific wording only for the message label behavior tied to the
ambiguous case, and adjust the text near the route match/id description so
future readers understand the difference between the dedup key and the displayed
message.

*
* Dedup state lives on the returned closure, not module-globally — under
* dev HMR each module re-evaluation allocates a fresh warner, so a
* previously-suppressed override may warn again after a hot reload. That's
* 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
* evolve across minor versions if the runtime hooks change.
*
* @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,
Expand All @@ -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.`,
Expand All @@ -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 "<unknown>";
}
22 changes: 22 additions & 0 deletions packages/frontend-core/src/route-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
26 changes: 23 additions & 3 deletions packages/frontend-core/src/route-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -63,9 +73,15 @@ export function mergeRouteStaticData<T extends object>(
const merged: Record<string, unknown> = {};
// Only allocate the source-tracking map when a caller actually wants
// override notifications; the common production path has no overhead.
const sources: Record<string, unknown> | 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<string, { match: unknown; index: number }> | 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
Expand All @@ -80,19 +96,23 @@ export function mergeRouteStaticData<T extends object>(
// `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<T>;
}
Loading
Loading