@real-router/core 0.82.0 → 0.82.2

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 (56) hide show
  1. package/dist/cjs/Router-DunclyU6.js +2 -0
  2. package/dist/cjs/Router-DunclyU6.js.map +1 -0
  3. package/dist/cjs/Router.d.ts +13 -0
  4. package/dist/cjs/Router.d.ts.map +1 -1
  5. package/dist/cjs/Router.js +1 -1
  6. package/dist/cjs/Router.js.map +1 -1
  7. package/dist/cjs/api.js +1 -2
  8. package/dist/cjs/helpers.js +1 -1
  9. package/dist/cjs/helpers.js.map +1 -1
  10. package/dist/cjs/index.js +1 -2
  11. package/dist/cjs/namespaces/NavigationNamespace/InFlightNavigation.js +2 -0
  12. package/dist/cjs/namespaces/NavigationNamespace/InFlightNavigation.js.map +1 -0
  13. package/dist/cjs/namespaces/NavigationNamespace/NavigationNamespace.js +1 -1
  14. package/dist/cjs/namespaces/NavigationNamespace/NavigationNamespace.js.map +1 -1
  15. package/dist/cjs/namespaces/NavigationNamespace/constants.js +1 -1
  16. package/dist/cjs/namespaces/NavigationNamespace/constants.js.map +1 -1
  17. package/dist/cjs/namespaces/NavigationNamespace/transition/errorHandling.js +1 -1
  18. package/dist/cjs/namespaces/NavigationNamespace/transition/errorHandling.js.map +1 -1
  19. package/dist/cjs/namespaces/NavigationNamespace/transition/executeNavigation.js +2 -0
  20. package/dist/cjs/namespaces/NavigationNamespace/transition/executeNavigation.js.map +1 -0
  21. package/dist/cjs/namespaces/NavigationNamespace/transition/guardPhase.js +1 -1
  22. package/dist/cjs/namespaces/NavigationNamespace/transition/guardPhase.js.map +1 -1
  23. package/dist/cjs/namespaces/NavigationNamespace/transition/navigateToNotFound.js +2 -0
  24. package/dist/cjs/namespaces/NavigationNamespace/transition/navigateToNotFound.js.map +1 -0
  25. package/dist/cjs/namespaces/RoutesNamespace/RoutesNamespace.js +1 -1
  26. package/dist/cjs/namespaces/RoutesNamespace/RoutesNamespace.js.map +1 -1
  27. package/dist/cjs/namespaces/RoutesNamespace/helpers.js +1 -1
  28. package/dist/cjs/namespaces/RoutesNamespace/helpers.js.map +1 -1
  29. package/dist/cjs/namespaces/RoutesNamespace/routesStore.d.ts +19 -0
  30. package/dist/cjs/namespaces/RoutesNamespace/routesStore.d.ts.map +1 -1
  31. package/dist/cjs/namespaces/RoutesNamespace/routesStore.js +1 -1
  32. package/dist/cjs/namespaces/RoutesNamespace/routesStore.js.map +1 -1
  33. package/dist/cjs/namespaces/StateNamespace/StateNamespace.js +1 -1
  34. package/dist/cjs/namespaces/StateNamespace/StateNamespace.js.map +1 -1
  35. package/dist/cjs/pipeline/canonicalize.js +1 -1
  36. package/dist/cjs/pipeline/canonicalize.js.map +1 -1
  37. package/dist/cjs/pipeline/materialize.js +1 -1
  38. package/dist/cjs/pipeline/materialize.js.map +1 -1
  39. package/dist/cjs/pipeline/port.d.ts +8 -0
  40. package/dist/cjs/pipeline/port.d.ts.map +1 -1
  41. package/dist/cjs/validation.js +1 -2
  42. package/dist/esm/Router-yitiDzP1.mjs +2 -0
  43. package/dist/esm/Router-yitiDzP1.mjs.map +1 -0
  44. package/dist/esm/Router.d.mts +13 -0
  45. package/dist/esm/Router.d.mts.map +1 -1
  46. package/dist/esm/api.mjs +1 -1
  47. package/dist/esm/index.mjs +1 -1
  48. package/dist/esm/namespaces/RoutesNamespace/routesStore.d.mts +19 -0
  49. package/dist/esm/namespaces/RoutesNamespace/routesStore.d.mts.map +1 -1
  50. package/dist/esm/pipeline/port.d.mts +8 -0
  51. package/dist/esm/pipeline/port.d.mts.map +1 -1
  52. package/package.json +1 -1
  53. package/dist/cjs/Router-CCmcEi-S.js +0 -2
  54. package/dist/cjs/Router-CCmcEi-S.js.map +0 -1
  55. package/dist/esm/Router-DEpNuwBW.mjs +0 -2
  56. package/dist/esm/Router-DEpNuwBW.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"routesStore.js","names":["createRouteTree","createMatcher","createEmptyConfig","resolveForwardChain","STANDARD_ROUTE_KEYS","sanitizeRoute","queryParamsOf","routeTreeToDefinitions"],"sources":["../../../../src/namespaces/RoutesNamespace/routesStore.ts"],"sourcesContent":["// packages/core/src/namespaces/RoutesNamespace/routesStore.ts\n\nimport { DEFAULT_ROUTE_NAME, STANDARD_ROUTE_KEYS } from \"./constants\";\nimport { resolveForwardChain } from \"./forwardChain\";\nimport {\n assertRouteDefaultChannelsFor,\n assignConfigEntries,\n createEmptyConfig,\n queryParamsOf,\n sanitizeRoute,\n} from \"./helpers\";\nimport { assertChannelCorrect } from \"../../channels\";\nimport {\n createMatcher,\n createRouteTree,\n routeTreeToDefinitions,\n} from \"../../engine\";\n\nimport type { RouteConfig, RoutesDependencies } from \"./types\";\nimport type {\n CreateMatcherOptions,\n Matcher,\n RouteDefinition,\n RouteTree,\n} from \"../../engine\";\nimport type {\n DefaultDependencies,\n ForwardToCallback,\n GuardFn,\n Params,\n ParamsSearch,\n SearchParams,\n RouteConfigUpdate,\n RouterLogger,\n GuardFnFactory,\n Route,\n} from \"../../types\";\nimport type { RouteLifecycleNamespace } from \"../RouteLifecycleNamespace\";\n\n// =============================================================================\n// Interfaces\n// =============================================================================\n\nexport interface RoutesStore<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> {\n /**\n * DERIVED VIEW, not stored state: reconstructed from `tree` on every access\n * via `routeTreeToDefinitions` (the lossless inverse cloneRouter already\n * relies on — the `~` absolute marker is restored, child order is the\n * definition order). The tree is the single source of truth, so a third\n * retained copy of the route table (~30 B/route) is not kept. Every reader\n * is a cold CRUD/plugin-registration path; the derive is O(N).\n *\n * The returned array is a FRESH snapshot each time — mutating it never\n * affects the store (pass an explicitly-mutated snapshot to\n * `commitTreeChanges` instead, as `remove` does).\n */\n readonly definitions: RouteDefinition[];\n readonly config: RouteConfig;\n tree: RouteTree;\n matcher: Matcher;\n /**\n * Per-route-name cache of URL (path) param names, derived from `matcher` and\n * read by `RoutesNamespace.getUrlParams` (powers `areStatesEqual` /\n * `isActiveRoute`). Cleared on every `matcher` rebuild so comparisons never\n * stay frozen to a route's pre-mutation param shape (#723).\n */\n readonly urlParamsCache: Map<string, string[]>;\n /**\n * Per-route-name cache of declared query param names (`?a&b` across the\n * route's segments), read by `RoutesNamespace.getQueryParams` — THE registry\n * every channel mechanism classifies through and the URL build prints from\n * (#1556). Same lifecycle as `urlParamsCache`: cleared on every `matcher`\n * rebuild.\n */\n readonly queryParamsCache: Map<string, string[]>;\n resolvedForwardMap: Record<string, string>;\n routeCustomFields: Record<string, Record<string, unknown>>;\n rootPath: string;\n readonly matcherOptions: CreateMatcherOptions | undefined;\n depsStore: RoutesDependencies<Dependencies> | undefined;\n lifecycleNamespace: RouteLifecycleNamespace<Dependencies> | undefined;\n readonly pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>;\n readonly pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>;\n}\n\n// =============================================================================\n// Tree operations\n// =============================================================================\n\nfunction rebuildTree(\n definitions: readonly RouteDefinition[],\n rootPath: string,\n matcherOptions: CreateMatcherOptions | undefined,\n): { tree: RouteTree; matcher: Matcher } {\n const tree = createRouteTree(DEFAULT_ROUTE_NAME, rootPath, definitions);\n const matcher = createMatcher(matcherOptions);\n\n matcher.registerTree(tree);\n\n return { tree, matcher };\n}\n\n/**\n * Rebuilds tree+matcher in place from `definitions` (defaults to the current\n * tree's own derived definitions — the same-table case, e.g. a rootPath\n * change).\n */\nexport function rebuildTreeInPlace<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n definitions: readonly RouteDefinition[] = store.definitions,\n): void {\n const result = rebuildTree(definitions, store.rootPath, store.matcherOptions);\n\n store.tree = result.tree;\n store.matcher = result.matcher;\n store.urlParamsCache.clear();\n store.queryParamsCache.clear();\n}\n\n/**\n * Prepare-then-commit root-path change.\n *\n * A root `?`-declaration declares the name on EVERY route at once, so a\n * `defaultParams` that was legal a moment ago can stop being legal without any\n * route changing — the one mutation where re-checking the WHOLE config is not\n * redundant. Built into locals first so a rejected root path leaves the store\n * exactly as it was, matching the atomicity `add` / `replace` promise.\n */\nexport function applyRootPath<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(store: RoutesStore<Dependencies>, newRootPath: string): void {\n const prepared = rebuildTree(\n store.definitions,\n newRootPath,\n store.matcherOptions,\n );\n\n assertRouteDefaultChannelsFor(prepared.matcher, store.config, \"setRootPath\");\n\n store.rootPath = newRootPath;\n store.tree = prepared.tree;\n store.matcher = prepared.matcher;\n store.urlParamsCache.clear();\n store.queryParamsCache.clear();\n}\n\nexport function commitTreeChanges<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n definitions: readonly RouteDefinition[],\n): void {\n rebuildTreeInPlace(store, definitions);\n store.resolvedForwardMap = refreshForwardMap(store.config);\n}\n\n// =============================================================================\n// Store reset\n// =============================================================================\n\n/**\n * Clears all routes and resets config.\n * Does NOT clear lifecycle handlers or state — caller handles that.\n */\nexport function resetStore<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(store: RoutesStore<Dependencies>): void {\n clearRouteData(store);\n rebuildTreeInPlace(store, []);\n}\n\n/**\n * Clears route data without rebuilding the tree.\n * Used by replace() to avoid double rebuild (clearRouteData + commitTreeChanges).\n * `definitions` needs no clearing — it is derived from the tree, which the\n * caller rebuilds (resetStore → empty, replace → the new artifacts).\n */\nexport function clearRouteData<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(store: RoutesStore<Dependencies>): void {\n Object.assign(store.config, createEmptyConfig());\n\n store.resolvedForwardMap = Object.create(null) as Record<string, string>;\n store.routeCustomFields = Object.create(null) as Record<\n string,\n Record<string, unknown>\n >;\n}\n\n// =============================================================================\n// Forward map\n// =============================================================================\n\nexport function refreshForwardMap(config: RouteConfig): Record<string, string> {\n const map = Object.create(null) as Record<string, string>;\n\n for (const fromRoute of Object.keys(config.forwardMap)) {\n map[fromRoute] = resolveForwardChain(fromRoute, config.forwardMap);\n }\n\n return map;\n}\n\n// =============================================================================\n// Route handler registration\n// =============================================================================\n\n/**\n * Throws if `forwardTo` is an async function (native or transpiled). Async\n * forwardTo callbacks break the synchronous matchPath/buildPath contract.\n * Runs inside `registerForwardTo` (the add/replace build path, before any store\n * mutation) AND inside `getRoutesApi`'s `updateForwardTo` (the update path), so\n * `update(name, { forwardTo: async })` is rejected at registration with the same\n * actionable error instead of deferring a generic TypeError to navigation (#967).\n */\nexport function assertForwardToNotAsync(\n forwardTo: unknown,\n fullName: string,\n): void {\n if (typeof forwardTo !== \"function\") {\n return;\n }\n\n const isNativeAsync =\n (forwardTo as { constructor: { name: string } }).constructor.name ===\n \"AsyncFunction\";\n const isTranspiledAsync = (forwardTo as { toString: () => string })\n .toString()\n .includes(\"__awaiter\");\n\n if (isNativeAsync || isTranspiledAsync) {\n throw new TypeError(\n `forwardTo callback cannot be async for route \"${fullName}\". ` +\n `Async functions break matchPath/buildPath.`,\n );\n }\n}\n\nfunction registerForwardTo<Dependencies extends DefaultDependencies>(\n route: Route<Dependencies>,\n fullName: string,\n config: RouteConfig,\n logger: RouterLogger,\n): void {\n if (route.canActivate) {\n /* v8 ignore next -- @preserve: edge case, both string and function tested separately */\n const forwardTarget =\n typeof route.forwardTo === \"string\" ? route.forwardTo : \"[dynamic]\";\n\n logger.warn(\n \"real-router\",\n `Route \"${fullName}\" has both forwardTo and canActivate. ` +\n `canActivate will be ignored because forwardTo creates a redirect (industry standard). ` +\n `Move canActivate to the target route \"${forwardTarget}\".`,\n );\n }\n\n if (route.canDeactivate) {\n /* v8 ignore next -- @preserve: edge case, both string and function tested separately */\n const forwardTarget =\n typeof route.forwardTo === \"string\" ? route.forwardTo : \"[dynamic]\";\n\n logger.warn(\n \"real-router\",\n `Route \"${fullName}\" has both forwardTo and canDeactivate. ` +\n `canDeactivate will be ignored because forwardTo creates a redirect (industry standard). ` +\n `Move canDeactivate to the target route \"${forwardTarget}\".`,\n );\n }\n\n assertForwardToNotAsync(route.forwardTo, fullName);\n\n // forwardTo is guaranteed to exist at this point\n if (typeof route.forwardTo === \"string\") {\n config.forwardMap[fullName] = route.forwardTo;\n } else {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n config.forwardFnMap[fullName] = route.forwardTo!;\n }\n}\n\nfunction registerSingleRouteHandlers<Dependencies extends DefaultDependencies>(\n route: Route<Dependencies>,\n fullName: string,\n config: RouteConfig,\n routeCustomFields: Record<string, Record<string, unknown>>,\n pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>,\n pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>,\n logger: RouterLogger,\n): void {\n const customFields = Object.fromEntries(\n Object.entries(route).filter(([key]) => !STANDARD_ROUTE_KEYS.has(key)),\n );\n\n if (Object.keys(customFields).length > 0) {\n routeCustomFields[fullName] = customFields;\n }\n\n // Guards are collected here and registered into the lifecycle later — by\n // `adoptRouteArtifacts` (add/replace) or `RoutesNamespace.flushPendingGuards`\n // (initial routes, the final step of the Router constructor — #1331) — so\n // the build stays a pure, side-effect-free preparation step.\n if (route.canActivate) {\n pendingCanActivate.set(fullName, route.canActivate);\n }\n\n if (route.canDeactivate) {\n pendingCanDeactivate.set(fullName, route.canDeactivate);\n }\n\n if (route.forwardTo) {\n registerForwardTo(route, fullName, config, logger);\n }\n\n if (route.decodeParams) {\n const decode = route.decodeParams;\n\n config.decoders[fullName] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if a user-provided decoder violates its `{ params, search }` return type\n decode(channels) ?? channels;\n }\n\n if (route.encodeParams) {\n const encode = route.encodeParams;\n\n config.encoders[fullName] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if a user-provided encoder violates its `{ params, search }` return type\n encode(channels) ?? channels;\n }\n\n if (route.defaultParams) {\n config.defaultParams[fullName] = route.defaultParams;\n }\n\n if (route.defaultSearch) {\n config.defaultSearch[fullName] = route.defaultSearch;\n }\n}\n\nfunction registerAllRouteHandlers<Dependencies extends DefaultDependencies>(\n routes: readonly Route<Dependencies>[],\n config: RouteConfig,\n routeCustomFields: Record<string, Record<string, unknown>>,\n pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>,\n pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>,\n logger: RouterLogger,\n parentName = \"\",\n): void {\n for (const route of routes) {\n const fullName = parentName ? `${parentName}.${route.name}` : route.name;\n\n registerSingleRouteHandlers(\n route,\n fullName,\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n logger,\n );\n\n if (route.children) {\n registerAllRouteHandlers(\n route.children,\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n logger,\n fullName,\n );\n }\n }\n}\n\n// =============================================================================\n// Prepare-then-commit (issue #698)\n//\n// add()/replace() build the complete new store state into LOCAL structures, and\n// only swap it into the store once every core-level error has surfaced from the\n// build itself (async/circular forwardTo throw in registerAllRouteHandlers /\n// refreshForwardMap; invalid path constraint throws in rebuildTree). The store\n// is mutated only by `adoptRouteArtifacts`, which compiles every prepared guard\n// factory BEFORE the swap (#956): a factory that throws on compile (or returns a\n// non-function) aborts there, with the store still untouched. So all error\n// classes — core-level build errors AND malformed guard factories — surface\n// before any mutation, leaving the existing routes intact (full atomicity). The\n// silent-corruption cases route-tree never throws on (duplicate name vs an\n// existing route, a name duplicated within the batch, missing parent) are caught\n// up front by `assertAddable`.\n// =============================================================================\n\n/**\n * The fully-built, ready-to-swap result of preparing a route mutation. Holds\n * everything `adoptRouteArtifacts` assigns into the store.\n */\ninterface RouteArtifacts<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> {\n readonly config: RouteConfig;\n readonly routeCustomFields: Record<string, Record<string, unknown>>;\n readonly pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>;\n readonly pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>;\n readonly tree: RouteTree;\n readonly matcher: Matcher;\n readonly resolvedForwardMap: Record<string, string>;\n}\n\n/** Null-proto shallow clone of a RouteConfig (preserves every sub-map's contents). */\nfunction cloneConfig(config: RouteConfig): RouteConfig {\n const clone = createEmptyConfig();\n\n assignConfigEntries(clone, config);\n\n return clone;\n}\n\n/**\n * Returns a new definitions array with `added` inserted, without mutating the\n * input. For a top-level add the existing definitions are shallow-copied and\n * `added` appended. For a parented add the spine down to the parent is cloned\n * (siblings/other branches are shared by reference) and `added` appended to the\n * parent's children. Caller guarantees the parent path exists (see assertAddable).\n */\nfunction insertAddedDefinitions(\n definitions: readonly RouteDefinition[],\n added: RouteDefinition[],\n parentSegments: readonly string[],\n): RouteDefinition[] {\n if (parentSegments.length === 0) {\n return [...definitions, ...added];\n }\n\n const [head, ...rest] = parentSegments;\n\n return definitions.map((def) => {\n if (def.name !== head) {\n return def;\n }\n\n const children = def.children ?? [];\n\n return {\n ...def,\n children:\n rest.length === 0\n ? [...children, ...added]\n : insertAddedDefinitions(children, added, rest),\n };\n });\n}\n\n/** Depth-first walk yielding each route's full dotted name (no side effects). */\nfunction walkRouteNames<Dependencies extends DefaultDependencies>(\n routes: readonly Route<Dependencies>[],\n parentName: string,\n onName: (fullName: string) => void,\n): void {\n for (const route of routes) {\n const fullName = parentName ? `${parentName}.${route.name}` : route.name;\n\n onName(fullName);\n\n if (route.children) {\n walkRouteNames(route.children, fullName, onName);\n }\n }\n}\n\n/**\n * Rejects a route name duplicated WITHIN a single batch — the silent-overwrite\n * case route-tree stays last-wins on (#953 for `add`, #968 for `replace`). Walks\n * the same depth-first dotted names, but tracks them in a local Set: a name seen\n * twice in one array means the caller's second route would silently shadow the\n * first (`matchPath` for the first route's path becomes unreachable). Mirrors\n * validation-plugin's batch-dup message (route-tree `checkBatchNameDuplicate`)\n * so the no-plugin error matches the with-plugin one. `methodName` is \"addRoute\"\n * for both add and replace — the plugin reports \"addRoute\" for replace batches\n * too, so this keeps with/without-plugin parity.\n */\nexport function assertNoDuplicateNamesInBatch<\n Dependencies extends DefaultDependencies,\n>(\n routes: readonly Route<Dependencies>[],\n parentName: string,\n methodName: string,\n): void {\n const seen = new Set<string>();\n\n walkRouteNames(routes, parentName, (fullName) => {\n if (seen.has(fullName)) {\n throw new Error(\n `[router.${methodName}] Duplicate route \"${fullName}\" in batch`,\n );\n }\n\n seen.add(fullName);\n });\n}\n\nconst INTERNAL_ROUTE_PREFIX = \"@@\";\n\n/**\n * Rejects a single (bare) route name that uses the reserved \"@@\" prefix —\n * internal / system names such as UNKNOWN_ROUTE (`\"@@router/UNKNOWN_ROUTE\"`).\n * Mutating such a name would let a real URL `matchPath` to a state with\n * `name === UNKNOWN_ROUTE`, silently conflating a genuine route with \"not\n * found\". This always-on guard protected all four mutators (#238) until the\n * validation-extraction (`d1ebff80`) demoted it to the opt-in\n * validation-plugin; only `add` was restored (#954), so `remove`/`update`\n * regained it via this helper (#1047). Mirrors validation-plugin's\n * `throwIfInternalRoute` message so the no-plugin error matches the with-plugin\n * one.\n */\nexport function assertNoInternalRouteName(\n name: string,\n methodName: string,\n): void {\n if (name.startsWith(INTERNAL_ROUTE_PREFIX)) {\n throw new Error(\n `[router.${methodName}] Route name \"${name}\" uses the reserved \"${INTERNAL_ROUTE_PREFIX}\" prefix. Routes with this prefix are internal and cannot be modified through the public API.`,\n );\n }\n}\n\n/**\n * Batch counterpart to {@link assertNoInternalRouteName}: rejects any route in\n * the batch (recursing children) whose BARE leaf name uses the reserved \"@@\"\n * prefix (the prefix is on the leaf, not the dotted fullName). Used by `add`\n * (#954) and `replace` (#1047).\n */\nexport function assertNoInternalNamesInBatch<\n Dependencies extends DefaultDependencies,\n>(routes: readonly Route<Dependencies>[], methodName: string): void {\n for (const route of routes) {\n assertNoInternalRouteName(route.name, methodName);\n\n if (route.children) {\n assertNoInternalNamesInBatch(route.children, methodName);\n }\n }\n}\n\n/**\n * Rejects two routes that share the same `path` at the same parent level WITHIN\n * a single `add` batch (#955). The matcher resolves a path collision last-wins,\n * so the earlier route stays addressable by name (`has` / `buildPath`) but is\n * unreachable by URL (`matchPath` returns the later route) — a silent shadow.\n * Paths only collide among siblings, so seen paths are tracked per parent\n * fullName. Mirrors validation-plugin's message (route-tree\n * `checkBatchPathDuplicate`) so the no-plugin error matches the with-plugin one.\n * Scoped to the batch (not the existing tree) per #955 — the in-batch case the\n * issue describes.\n */\nexport function assertNoDuplicatePathsInBatch<\n Dependencies extends DefaultDependencies,\n>(\n routes: readonly Route<Dependencies>[],\n parentName: string,\n methodName: string,\n): void {\n const seenByParent = new Map<string, Set<string>>();\n\n const walk = (\n siblings: readonly Route<Dependencies>[],\n parent: string,\n ): void => {\n for (const route of siblings) {\n const paths = seenByParent.get(parent);\n\n if (paths?.has(route.path)) {\n throw new Error(\n `[router.${methodName}] Path \"${route.path}\" is already defined`,\n );\n }\n\n if (paths) {\n paths.add(route.path);\n } else {\n seenByParent.set(parent, new Set([route.path]));\n }\n\n if (route.children) {\n walk(route.children, parent ? `${parent}.${route.name}` : route.name);\n }\n }\n };\n\n walk(routes, parentName);\n}\n\n/**\n * Up-front guard for `add` against the corruptions route-tree stays silent on: a\n * missing `parent`, a name that collides with an EXISTING route, a name\n * duplicated WITHIN the batch, a reserved \"@@\"-prefixed name (which would shadow\n * an internal/system route name), and a path duplicated among siblings WITHIN\n * the batch (any of which would otherwise be silently overwritten/shadowed).\n * Throws before any build.\n */\nexport function assertAddable<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n routes: readonly Route<Dependencies>[],\n parentName: string | undefined,\n): void {\n assertNoInternalNamesInBatch(routes, \"addRoute\");\n\n if (parentName !== undefined && !store.matcher.hasRoute(parentName)) {\n throw new Error(\n `[router.addRoute] Parent route \"${parentName}\" does not exist`,\n );\n }\n\n walkRouteNames(routes, parentName ?? \"\", (fullName) => {\n if (store.matcher.hasRoute(fullName)) {\n throw new Error(`[router.addRoute] Route \"${fullName}\" already exists`);\n }\n });\n\n assertNoDuplicateNamesInBatch(routes, parentName ?? \"\", \"addRoute\");\n assertNoDuplicatePathsInBatch(routes, parentName ?? \"\", \"addRoute\");\n}\n\n/**\n * Builds RouteArtifacts from a final definitions array and the routes whose\n * handlers (config + guards) populate `config`/`routeCustomFields`. Guards are\n * collected into the returned pending maps (depsStore is intentionally omitted\n * so nothing compiles or touches the lifecycle here). THROWS on async/circular\n * forwardTo and invalid path constraint — before the caller mutates the store.\n *\n * Takes a single args object: the positional list hit 8 parameters (S107) when\n * the per-router `logger` (#724) joined it, and named fields read better at the\n * two call sites anyway.\n */\nfunction buildArtifacts<Dependencies extends DefaultDependencies>({\n definitions,\n routesForHandlers,\n config,\n routeCustomFields,\n handlerParentName,\n rootPath,\n matcherOptions,\n logger,\n}: {\n definitions: readonly RouteDefinition[];\n routesForHandlers: readonly Route<Dependencies>[];\n config: RouteConfig;\n routeCustomFields: Record<string, Record<string, unknown>>;\n handlerParentName: string;\n rootPath: string;\n matcherOptions: CreateMatcherOptions | undefined;\n logger: RouterLogger;\n}): RouteArtifacts<Dependencies> {\n const pendingCanActivate = new Map<string, GuardFnFactory<Dependencies>>();\n const pendingCanDeactivate = new Map<string, GuardFnFactory<Dependencies>>();\n\n registerAllRouteHandlers(\n routesForHandlers,\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n logger,\n handlerParentName,\n );\n\n const resolvedForwardMap = refreshForwardMap(config);\n const { tree, matcher } = rebuildTree(definitions, rootPath, matcherOptions);\n\n return {\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n tree,\n matcher,\n resolvedForwardMap,\n };\n}\n\n/** Builds the merged artifacts for an incremental `add` (existing ∪ new). */\nexport function buildAddArtifacts<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n routes: readonly Route<Dependencies>[],\n parentName: string | undefined,\n logger: RouterLogger,\n): RouteArtifacts<Dependencies> {\n const definitions = insertAddedDefinitions(\n store.definitions,\n routes.map((route) => sanitizeRoute(route)),\n parentName === undefined ? [] : parentName.split(\".\"),\n );\n\n return buildArtifacts({\n definitions,\n routesForHandlers: routes,\n config: cloneConfig(store.config),\n routeCustomFields: Object.assign(\n Object.create(null) as Record<string, Record<string, unknown>>,\n store.routeCustomFields,\n ),\n handlerParentName: parentName ?? \"\",\n rootPath: store.rootPath,\n matcherOptions: store.matcherOptions,\n logger,\n });\n}\n\n/** Builds the fresh artifacts for a full `replace` (standalone new set). */\nexport function buildReplaceArtifacts<Dependencies extends DefaultDependencies>(\n routes: readonly Route<Dependencies>[],\n rootPath: string,\n matcherOptions: CreateMatcherOptions | undefined,\n logger: RouterLogger,\n): RouteArtifacts<Dependencies> {\n return buildArtifacts({\n definitions: routes.map((route) => sanitizeRoute(route)),\n routesForHandlers: routes,\n config: createEmptyConfig(),\n routeCustomFields: Object.create(null) as Record<\n string,\n Record<string, unknown>\n >,\n handlerParentName: \"\",\n rootPath,\n matcherOptions,\n logger,\n });\n}\n\n/**\n * Compiles every pending guard factory up front, returning\n * `[name, factory, compiledFn]` triples for installation. THROWS from `compile`\n * on the first factory that throws on compile or returns a non-function — the\n * pre-swap validation that makes `adoptRouteArtifacts` atomic for malformed\n * guards (#956). Compiling here (not at install) means a factory with\n * compile-time side effects runs exactly once.\n */\nfunction compilePendingGuards<Dependencies extends DefaultDependencies>(\n pending: Map<string, GuardFnFactory<Dependencies>>,\n compile: (\n handler: GuardFnFactory<Dependencies>,\n methodName: string,\n ) => GuardFn,\n methodName: string,\n): [string, GuardFnFactory<Dependencies>, GuardFn][] {\n const compiled: [string, GuardFnFactory<Dependencies>, GuardFn][] = [];\n\n for (const [name, factory] of pending) {\n compiled.push([name, factory, compile(factory, methodName)]);\n }\n\n return compiled;\n}\n\n/** Pre-compiled guard triples for {@link adoptRouteArtifacts} install. */\nexport interface CompiledArtifactGuards<\n Dependencies extends DefaultDependencies,\n> {\n activate: [string, GuardFnFactory<Dependencies>, GuardFn][];\n deactivate: [string, GuardFnFactory<Dependencies>, GuardFn][];\n}\n\n/**\n * Compiles an artifacts' pending guard factories up front (#956), THROWING on\n * the first factory that throws on compile or returns a non-function.\n *\n * `replaceRoutes` calls this in its PREPARE phase — **before**\n * `clearDefinitionGuards()` — and hands the result to `adoptRouteArtifacts`, so\n * a compile-throw aborts with BOTH the tree AND the old definition guards intact\n * (#1193, mirroring #1046's handler-limit hoist). `add` has no clear step, so\n * `adoptRouteArtifacts` compiles inline for it.\n */\nexport function compileArtifactGuards<Dependencies extends DefaultDependencies>(\n artifacts: RouteArtifacts<Dependencies>,\n deps: RoutesDependencies<Dependencies>,\n): CompiledArtifactGuards<Dependencies> {\n return {\n activate: compilePendingGuards(\n artifacts.pendingCanActivate,\n deps.compileGuard,\n \"canActivate\",\n ),\n deactivate: compilePendingGuards(\n artifacts.pendingCanDeactivate,\n deps.compileGuard,\n \"canDeactivate\",\n ),\n };\n}\n\n/**\n * Commits prepared artifacts into the store in place. Every pending guard\n * factory is compiled BEFORE the tree/config swap (#956): a factory that throws\n * on compile (or returns a non-function) aborts here with the store untouched,\n * so `add`/`replace` are atomic for malformed guards too — not just core build\n * errors. The tree/config assignments are pure and cannot throw; the\n * pre-compiled guards are then installed without re-compiling (the factory ran\n * once, at the pre-compile above). `depsStore` is always set on a wired router,\n * which is the only path that reaches `add`/`replace`.\n *\n * ⚠ **The config-time channel check (`assertRouteDefaultChannels`) is the\n * CALLER's PREPARE step, not this function's.** It used to run here, one line\n * before the swap, which is early enough for `add` and too late for `replace`:\n * `replace` erases the old definition guards BEFORE calling this, so a batch\n * this check refused left the tree intact and the guards gone — a previously\n * guarded route freely activatable. That is the #1193 fail-open shape verbatim,\n * which is why the guard COMPILE was hoisted into the callers; the channel\n * check now sits beside it, for the same reason. Keeping this function\n * throw-free is what makes its \"atomic swap\" contract true rather than nearly\n * true.\n */\nexport function adoptRouteArtifacts<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n artifacts: RouteArtifacts<Dependencies>,\n precompiled?: CompiledArtifactGuards<Dependencies>,\n): void {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depsStore is set once the router is wired; add/replace only run on a wired router (constructor-time registration uses createRoutesStore)\n const deps = store.depsStore!;\n\n // Pre-swap compile: surfaces a malformed guard factory before any mutation.\n // `replace()` pre-compiles in its PREPARE phase (BEFORE clearDefinitionGuards)\n // and passes the result here, so a compile-throw never erases the old\n // definition guards (#1193); `add` has no clear step and compiles inline.\n const { activate: compiledActivate, deactivate: compiledDeactivate } =\n precompiled ?? compileArtifactGuards(artifacts, deps);\n\n // Atomic swap — pure assignments, cannot throw. (`definitions` is derived\n // from `tree`, so swapping the tree IS the definitions swap.)\n Object.assign(store.config, artifacts.config);\n store.routeCustomFields = artifacts.routeCustomFields;\n store.tree = artifacts.tree;\n store.matcher = artifacts.matcher;\n store.urlParamsCache.clear();\n store.queryParamsCache.clear();\n store.resolvedForwardMap = artifacts.resolvedForwardMap;\n\n // Install pre-compiled guards — no re-compile, no throw.\n for (const [name, factory, fn] of compiledActivate) {\n deps.addActivateGuard(name, factory, fn);\n }\n\n for (const [name, factory, fn] of compiledDeactivate) {\n deps.addDeactivateGuard(name, factory, fn);\n }\n}\n\n/**\n * COMMIT core for `update()` — the field-patch counterpart to\n * {@link adoptRouteArtifacts} (add/replace) / {@link commitTreeChanges} (remove)\n * / {@link resetStore} (clear), co-located here so all four route-CRUD commit\n * cores live in one file. Stays **NO_TREE_REBUILD**: an O(1) per-field patch\n * that never rebuilds the tree/matcher (so it must NOT funnel through\n * `adoptRouteArtifacts`).\n *\n * Prepare-then-commit (#951): every throwing step runs in PREPARE, before any\n * store write, so a rejected update leaves the route's prior config fully intact\n * — an async/cyclic `forwardTo` (#967), a guard factory that throws on compile\n * (#956 seam), a throwing custom-field getter, and the #961 handler-limit\n * pre-flight (#1046). Returns the structural fields for the caller's conditional\n * TREE_CHANGED emit, computed from the single destructure here so core invokes\n * each user getter once (#797 / #952 `null`-clears-definition-only preserved).\n */\nexport function commitRouteUpdate<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n lifecycle: RouteLifecycleNamespace<Dependencies>,\n name: string,\n updates: RouteConfigUpdate<Dependencies>,\n): {\n forwardTo?: string | ForwardToCallback<Dependencies> | null | undefined;\n defaultParams?: Params | null | undefined;\n defaultSearch?: SearchParams | null | undefined;\n decodeParams?: ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n encodeParams?: ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n} {\n const {\n forwardTo,\n defaultParams,\n defaultSearch,\n decodeParams,\n encodeParams,\n canActivate,\n canDeactivate,\n } = updates;\n\n // ===== PREPARE — compute every change into LOCALS. Any throw here aborts\n // before a single store write, so the whole field set is applied\n // all-or-nothing (#951).\n\n // Channel check on the INCOMING value, in PREPARE: `update` does not rebuild\n // the tree (NO_TREE_REBUILD), so the route's declarations are the ones the\n // matcher already holds. Checked before any write, so a mis-channelled\n // `defaultParams` aborts the whole update rather than landing half-applied.\n if (defaultParams !== undefined && defaultParams !== null) {\n assertChannelCorrect(\n \"updateRoute\",\n name,\n defaultParams,\n queryParamsOf(store, name),\n \"this route's `defaultParams`\",\n \"Move it to `defaultSearch`\",\n );\n }\n\n const forwardToPlan =\n forwardTo === undefined\n ? undefined\n : prepareForwardTo(name, forwardTo, store.config);\n\n const nextCustomFields = prepareCustomFields(store, name, updates);\n\n // Guard factories are compiled NOW (a throwing factory surfaces in PREPARE);\n // the precompiled function is installed in COMMIT without re-invoking the\n // factory, so a factory side effect runs exactly once (reuses the #956\n // compile-then-install seam). Compiled after the other prepares so a throw\n // upstream skips invoking the factory at all.\n const activateFn =\n canActivate === undefined || canActivate === null\n ? undefined\n : lifecycle.compileGuardFactory(canActivate, \"canActivate\");\n const deactivateFn =\n canDeactivate === undefined || canDeactivate === null\n ? undefined\n : lifecycle.compileGuardFactory(canDeactivate, \"canDeactivate\");\n\n // Pre-flight the #961 handler-limit before the COMMIT writes, so an at-limit\n // update that adds a NEW guard slot aborts before forwardTo / scalar config\n // land (#1046, #951). A slot is new only when `name` does not already hold a\n // guard of that type — an overwrite does not count.\n lifecycle.preflightHandlerLimit(\n activateFn === undefined ? [] : [name],\n deactivateFn === undefined ? [] : [name],\n false,\n );\n\n // ===== COMMIT — pure writes from here; nothing below throws.\n // Custom (plugin-defined) fields. Consumers read these lazily via\n // getRouteConfig (lifecycle hooks, preload, searchSchema), so no TREE_CHANGED\n // is needed — the next read sees the new value; the caller's emit stays\n // structural-only by design (О-7).\n if (nextCustomFields !== undefined) {\n if (Object.keys(nextCustomFields).length > 0) {\n store.routeCustomFields[name] = nextCustomFields;\n } else {\n delete store.routeCustomFields[name];\n }\n }\n\n if (forwardToPlan !== undefined) {\n store.config.forwardMap = forwardToPlan.forwardMap;\n store.config.forwardFnMap = forwardToPlan.forwardFnMap;\n store.resolvedForwardMap = forwardToPlan.resolved;\n }\n\n commitScalarConfig(store, name, {\n defaultParams,\n defaultSearch,\n decodeParams,\n encodeParams,\n });\n\n // Install the guards from their PREPARE-phase precompiled functions; a `null`\n // clears the definition-origin guard only (#952). See commitGuardUpdate.\n commitGuardUpdate(lifecycle, \"activate\", name, canActivate, activateFn);\n commitGuardUpdate(lifecycle, \"deactivate\", name, canDeactivate, deactivateFn);\n\n return {\n forwardTo,\n defaultParams,\n defaultSearch,\n decodeParams,\n encodeParams,\n };\n}\n\n/**\n * PREPARE step for a `forwardTo` update (#951 atomicity): computes the new\n * forward maps and the resolved forward chain into LOCALS and returns them\n * WITHOUT touching the store. A throw here — an async `forwardTo` (#967) or a\n * cycle surfaced by `refreshForwardMap` — aborts `update()` before any field is\n * committed. The caller writes the returned bundle into the store in its COMMIT\n * phase. (Mirrors the build-then-swap shape of #698, but the swap is deferred to\n * the caller so it can be sequenced with the other prepared fields.)\n */\nfunction prepareForwardTo<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n name: string,\n forwardTo: string | ForwardToCallback<Dependencies> | null,\n config: RouteConfig,\n): {\n forwardMap: RouteConfig[\"forwardMap\"];\n forwardFnMap: RouteConfig[\"forwardFnMap\"];\n resolved: Record<string, string>;\n} {\n // #967: reject an async forwardTo at update time — parity with add/replace\n // (registerForwardTo runs the same check on the build path). A no-op for\n // string/null. Without this the async callback is stored silently and\n // surfaces later as a generic \"must return a string, got object\" TypeError\n // from #resolveDynamicForward at navigation. Runs first, before any clone.\n assertForwardToNotAsync(forwardTo, name);\n\n const forwardMap = Object.assign(\n Object.create(null) as RouteConfig[\"forwardMap\"],\n config.forwardMap,\n );\n const forwardFnMap = Object.assign(\n Object.create(null) as RouteConfig[\"forwardFnMap\"],\n config.forwardFnMap,\n );\n\n if (forwardTo === null) {\n delete forwardMap[name];\n delete forwardFnMap[name];\n } else if (typeof forwardTo === \"string\") {\n delete forwardFnMap[name];\n forwardMap[name] = forwardTo;\n } else {\n delete forwardMap[name];\n forwardFnMap[name] = forwardTo;\n }\n\n const resolved = refreshForwardMap({ ...config, forwardMap });\n\n return { forwardMap, forwardFnMap, resolved };\n}\n\n/**\n * PREPARE step for a route's plugin-defined **custom fields** (#951) — the\n * `update` counterpart to how `add`/`replace` register them\n * (`registerSingleRouteHandlers`). A custom field is any patch key not in\n * {@link STANDARD_ROUTE_KEYS}.\n *\n * Computes the merged record and RETURNS it for the caller to commit;\n * `undefined` means no custom-field key was present, so the caller leaves the\n * store untouched. Semantics mirror the scalar fields in\n * {@link commitScalarConfig}: shallow-merge by patch key, `null` removes a\n * single field, `undefined` is a no-op (leaves the field untouched). When the\n * merge empties the record, the caller drops the whole entry so `getRouteConfig`\n * returns `undefined` — symmetric with `add`, which only stores a record when at\n * least one custom field exists.\n *\n * Reading the custom-field getters HERE (in PREPARE, not at commit) is what lets\n * a throwing getter abort the whole update before any field is written. The\n * merged record is a **fresh object**, never mutated in place: `cloneRouter`\n * shares per-route custom-field records by reference (`Object.assign`), so\n * replacing the reference keeps a clone isolated from post-clone updates on the\n * source.\n */\nfunction prepareCustomFields<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n name: string,\n updates: RouteConfigUpdate<Dependencies>,\n): Record<string, unknown> | undefined {\n let next: Record<string, unknown> | undefined;\n\n // `Object.keys` (not `Object.entries`): a value is read only AFTER the\n // standard-key guard, so structural-field getters — already read once by\n // `update`'s destructuring — are not re-invoked. `Object.entries` would read\n // every value eagerly, double-invoking a `defaultParams`/`forwardTo` getter\n // and breaking the \"user getter called once\" invariant.\n // eslint-disable-next-line unicorn/prefer-object-iterable-methods -- see above\n for (const key of Object.keys(updates)) {\n if (STANDARD_ROUTE_KEYS.has(key)) {\n continue;\n }\n\n const value = (updates as Record<string, unknown>)[key];\n\n // `undefined` mirrors the structural path: leave the field untouched.\n if (value === undefined) {\n continue;\n }\n\n // Clone-on-first-write — keeps clones (which alias this record) isolated.\n next ??= { ...store.routeCustomFields[name] };\n\n if (value === null) {\n delete next[key];\n } else {\n next[key] = value;\n }\n }\n\n return next;\n}\n\n/**\n * Applies one nullable scalar-config update in place: `undefined` is a no-op\n * (field not in the patch), `null` deletes the entry, any other value sets it.\n */\nfunction commitScalarField<T>(\n map: Record<string, T>,\n name: string,\n value: T | null | undefined,\n): void {\n if (value === undefined) {\n return;\n }\n\n if (value === null) {\n delete map[name];\n } else {\n map[name] = value;\n }\n}\n\n/**\n * COMMIT step for the scalar config fields of an update (#951): writes\n * `defaultParams` / `defaultSearch` / `decodeParams` / `encodeParams` in place.\n * These assignments\n * are pure and never throw, so they run in the COMMIT phase after every throwing\n * field has been validated in PREPARE. `forwardTo` is handled separately — it\n * has its own throwing prepare step ({@link prepareForwardTo}).\n */\nfunction commitScalarConfig<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n name: string,\n updates: {\n defaultParams?: Params | null | undefined;\n defaultSearch?: SearchParams | null | undefined;\n decodeParams?:\n ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n encodeParams?:\n ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n },\n): void {\n commitScalarField(store.config.defaultParams, name, updates.defaultParams);\n commitScalarField(store.config.defaultSearch, name, updates.defaultSearch);\n\n if (updates.decodeParams !== undefined) {\n if (updates.decodeParams === null) {\n delete store.config.decoders[name];\n } else {\n const decoder = updates.decodeParams;\n\n store.config.decoders[name] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if user-provided decoder violates its `{ params, search }` return type\n decoder(channels) ?? channels;\n }\n }\n\n if (updates.encodeParams !== undefined) {\n if (updates.encodeParams === null) {\n delete store.config.encoders[name];\n } else {\n const encoder = updates.encodeParams;\n\n store.config.encoders[name] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if user-provided encoder violates its `{ params, search }` return type\n encoder(channels) ?? channels;\n }\n }\n}\n\n/**\n * COMMIT step for one guard field of an update (#951). `undefined` is a no-op;\n * `null` clears the DEFINITION-origin guard only, preserving an external guard\n * (#952); a factory installs together with its PREPARE-phase `precompiledFn`\n * (no re-compile — #956 seam). Extracted from `update()` so its prepare/commit\n * orchestration stays within the cognitive-complexity budget.\n */\nfunction commitGuardUpdate<Dependencies extends DefaultDependencies>(\n lifecycle: RouteLifecycleNamespace<Dependencies>,\n kind: \"activate\" | \"deactivate\",\n name: string,\n value: GuardFnFactory<Dependencies> | null | undefined,\n precompiledFn: GuardFn | undefined,\n): void {\n if (value === undefined) {\n return;\n }\n\n if (kind === \"activate\") {\n if (value === null) {\n lifecycle.clearCanActivate(name, \"definition\");\n } else {\n lifecycle.addCanActivate(name, value, true, precompiledFn);\n }\n } else if (value === null) {\n lifecycle.clearCanDeactivate(name, \"definition\");\n } else {\n lifecycle.addCanDeactivate(name, value, true, precompiledFn);\n }\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport function createRoutesStore<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n routes: Route<Dependencies>[],\n matcherOptions: CreateMatcherOptions | undefined,\n logger: RouterLogger,\n): RoutesStore<Dependencies> {\n // Initial routes are a standalone set at rootPath \"\" — same build the\n // prepare-then-commit `replace` path uses. Guards land in the pending maps,\n // flushed by `flushPendingGuards()` at the end of the Router constructor\n // (#1331); `setDependencies` itself is a pure assignment.\n //\n // Reject the silent-corruption cases BEFORE building, giving the constructor\n // parity with `add()` / `replace()` — the third and last route-population\n // entry point (#1351): within-batch duplicate names (#953/#968) and reserved\n // \"@@\" names (#954). Without these the constructor silently last-wins a\n // duplicate-name sibling (the first route is dropped → its deep-link 404s)\n // while add/replace throw. `methodName` is \"addRoute\" so all three entry\n // points surface the identical bare-core error. (Duplicate PATHS are already\n // rejected downstream by the path-matcher backstop #1153, so they are not\n // re-checked here.)\n assertNoInternalNamesInBatch(routes, \"addRoute\");\n assertNoDuplicateNamesInBatch(routes, \"\", \"addRoute\");\n\n const artifacts = buildReplaceArtifacts(routes, \"\", matcherOptions, logger);\n\n const store: RoutesStore<Dependencies> = {\n // Deferred access: the getter runs only after `store` is initialized.\n get definitions() {\n return routeTreeToDefinitions(store.tree);\n },\n config: artifacts.config,\n tree: artifacts.tree,\n matcher: artifacts.matcher,\n urlParamsCache: new Map(),\n queryParamsCache: new Map(),\n resolvedForwardMap: artifacts.resolvedForwardMap,\n routeCustomFields: artifacts.routeCustomFields,\n rootPath: \"\",\n matcherOptions,\n depsStore: undefined,\n lifecycleNamespace: undefined,\n pendingCanActivate: artifacts.pendingCanActivate,\n pendingCanDeactivate: artifacts.pendingCanDeactivate,\n };\n\n // Same config-time channel check the add/replace path runs, so the\n // constructor is not the one population entry point that accepts a config\n // whose own state the router would then reject on `start()`.\n assertRouteDefaultChannelsFor(store.matcher, store.config, \"addRoute\");\n\n return store;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"routesStore.js","names":["createRouteTree","createMatcher","createEmptyConfig","anyForwardConfigured","resolveForwardChain","STANDARD_ROUTE_KEYS","sanitizeRoute","queryParamsOf","routeTreeToDefinitions"],"sources":["../../../../src/namespaces/RoutesNamespace/routesStore.ts"],"sourcesContent":["// packages/core/src/namespaces/RoutesNamespace/routesStore.ts\n\nimport { DEFAULT_ROUTE_NAME, STANDARD_ROUTE_KEYS } from \"./constants\";\nimport { resolveForwardChain } from \"./forwardChain\";\nimport {\n anyForwardConfigured,\n assertRouteDefaultChannelsFor,\n assignConfigEntries,\n createEmptyConfig,\n queryParamsOf,\n sanitizeRoute,\n} from \"./helpers\";\nimport { assertChannelCorrect } from \"../../channels\";\nimport {\n createMatcher,\n createRouteTree,\n routeTreeToDefinitions,\n} from \"../../engine\";\n\nimport type { RouteConfig, RoutesDependencies } from \"./types\";\nimport type {\n CreateMatcherOptions,\n Matcher,\n RouteDefinition,\n RouteTree,\n} from \"../../engine\";\nimport type {\n DefaultDependencies,\n ForwardToCallback,\n GuardFn,\n Params,\n ParamsSearch,\n SearchParams,\n RouteConfigUpdate,\n RouterLogger,\n GuardFnFactory,\n Route,\n} from \"../../types\";\nimport type { RouteLifecycleNamespace } from \"../RouteLifecycleNamespace\";\n\n// =============================================================================\n// Interfaces\n// =============================================================================\n\nexport interface RoutesStore<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> {\n /**\n * DERIVED VIEW, not stored state: reconstructed from `tree` on every access\n * via `routeTreeToDefinitions` (the lossless inverse cloneRouter already\n * relies on — the `~` absolute marker is restored, child order is the\n * definition order). The tree is the single source of truth, so a third\n * retained copy of the route table (~30 B/route) is not kept. Every reader\n * is a cold CRUD/plugin-registration path; the derive is O(N).\n *\n * The returned array is a FRESH snapshot each time — mutating it never\n * affects the store (pass an explicitly-mutated snapshot to\n * `commitTreeChanges` instead, as `remove` does).\n */\n readonly definitions: RouteDefinition[];\n readonly config: RouteConfig;\n tree: RouteTree;\n matcher: Matcher;\n /**\n * Per-route-name cache of URL (path) param names, derived from `matcher` and\n * read by `RoutesNamespace.getUrlParams` (powers `areStatesEqual` /\n * `isActiveRoute`). Cleared on every `matcher` rebuild so comparisons never\n * stay frozen to a route's pre-mutation param shape (#723).\n */\n readonly urlParamsCache: Map<string, string[]>;\n /**\n * Per-route-name cache of declared query param names (`?a&b` across the\n * route's segments), read by `RoutesNamespace.getQueryParams` — THE registry\n * every channel mechanism classifies through and the URL build prints from\n * (#1556). Same lifecycle as `urlParamsCache`: cleared on every `matcher`\n * rebuild.\n */\n readonly queryParamsCache: Map<string, string[]>;\n resolvedForwardMap: Record<string, string>;\n\n /**\n * Does ANY route in the tree forward? Read by `isActiveRoute` before its\n * `forwardTo` arm's per-route gate, and worth its own field for a reason that\n * is measurable rather than aesthetic (#1595): the two maps behind that gate\n * are `Object.create(null)` dictionaries, which V8 keeps in dictionary mode,\n * and two lookups on them cost ~14 ns — paid by every route in the tree for a\n * feature only forwarding routes use, on every `<Link>` render across six\n * adapters. A tree with no `forwardTo` at all — the common case — answers with\n * one boolean load instead.\n *\n * ⚠ Maintained ONLY through {@link adoptForwardState}, together with\n * `resolvedForwardMap`. The two are views of the same config and a stale\n * `false` here silently switches the arm OFF, which is a correctness bug\n * wearing a performance change's clothes: a `<Link>` to a forwarding route\n * would render inactive again (the defect #1573 shipped the arm to fix).\n * Pinned across every route-CRUD path by `isActiveRoute-forward-arm` in\n * `tests/functional/routes/is-active-route.test.ts`.\n */\n hasAnyForward: boolean;\n routeCustomFields: Record<string, Record<string, unknown>>;\n rootPath: string;\n readonly matcherOptions: CreateMatcherOptions | undefined;\n depsStore: RoutesDependencies<Dependencies> | undefined;\n lifecycleNamespace: RouteLifecycleNamespace<Dependencies> | undefined;\n readonly pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>;\n readonly pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>;\n}\n\n// =============================================================================\n// Tree operations\n// =============================================================================\n\nfunction rebuildTree(\n definitions: readonly RouteDefinition[],\n rootPath: string,\n matcherOptions: CreateMatcherOptions | undefined,\n): { tree: RouteTree; matcher: Matcher } {\n const tree = createRouteTree(DEFAULT_ROUTE_NAME, rootPath, definitions);\n const matcher = createMatcher(matcherOptions);\n\n matcher.registerTree(tree);\n\n return { tree, matcher };\n}\n\n/**\n * Rebuilds tree+matcher in place from `definitions` (defaults to the current\n * tree's own derived definitions — the same-table case, e.g. a rootPath\n * change).\n */\nexport function rebuildTreeInPlace<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n definitions: readonly RouteDefinition[] = store.definitions,\n): void {\n const result = rebuildTree(definitions, store.rootPath, store.matcherOptions);\n\n store.tree = result.tree;\n store.matcher = result.matcher;\n store.urlParamsCache.clear();\n store.queryParamsCache.clear();\n}\n\n/**\n * Prepare-then-commit root-path change.\n *\n * A root `?`-declaration declares the name on EVERY route at once, so a\n * `defaultParams` that was legal a moment ago can stop being legal without any\n * route changing — the one mutation where re-checking the WHOLE config is not\n * redundant. Built into locals first so a rejected root path leaves the store\n * exactly as it was, matching the atomicity `add` / `replace` promise.\n */\nexport function applyRootPath<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(store: RoutesStore<Dependencies>, newRootPath: string): void {\n const prepared = rebuildTree(\n store.definitions,\n newRootPath,\n store.matcherOptions,\n );\n\n assertRouteDefaultChannelsFor(prepared.matcher, store.config, \"setRootPath\");\n\n store.rootPath = newRootPath;\n store.tree = prepared.tree;\n store.matcher = prepared.matcher;\n store.urlParamsCache.clear();\n store.queryParamsCache.clear();\n}\n\nexport function commitTreeChanges<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n definitions: readonly RouteDefinition[],\n): void {\n rebuildTreeInPlace(store, definitions);\n adoptForwardState(store, refreshForwardMap(store.config));\n}\n\n// =============================================================================\n// Store reset\n// =============================================================================\n\n/**\n * Clears all routes and resets config.\n * Does NOT clear lifecycle handlers or state — caller handles that.\n */\nexport function resetStore<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(store: RoutesStore<Dependencies>): void {\n clearRouteData(store);\n rebuildTreeInPlace(store, []);\n}\n\n/**\n * Clears route data without rebuilding the tree.\n * Used by replace() to avoid double rebuild (clearRouteData + commitTreeChanges).\n * `definitions` needs no clearing — it is derived from the tree, which the\n * caller rebuilds (resetStore → empty, replace → the new artifacts).\n */\nexport function clearRouteData<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(store: RoutesStore<Dependencies>): void {\n Object.assign(store.config, createEmptyConfig());\n\n adoptForwardState(store, Object.create(null) as Record<string, string>);\n store.routeCustomFields = Object.create(null) as Record<\n string,\n Record<string, unknown>\n >;\n}\n\n// =============================================================================\n// Forward map\n// =============================================================================\n\n/**\n * The ONE way `resolvedForwardMap` and `hasAnyForward` move (#1595). They are two\n * views of the same forward config, so every site that re-derives one derives the\n * other here — a site that assigned only the map would leave a stale `false`\n * behind, and `isActiveRoute` would stop consulting its `forwardTo` arm.\n */\nfunction adoptForwardState<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n resolved: Record<string, string>,\n): void {\n store.resolvedForwardMap = resolved;\n store.hasAnyForward = anyForwardConfigured(store.config);\n}\n\nexport function refreshForwardMap(config: RouteConfig): Record<string, string> {\n const map = Object.create(null) as Record<string, string>;\n\n for (const fromRoute of Object.keys(config.forwardMap)) {\n map[fromRoute] = resolveForwardChain(fromRoute, config.forwardMap);\n }\n\n return map;\n}\n\n// =============================================================================\n// Route handler registration\n// =============================================================================\n\n/**\n * Throws if `forwardTo` is an async function (native or transpiled). Async\n * forwardTo callbacks break the synchronous matchPath/buildPath contract.\n * Runs inside `registerForwardTo` (the add/replace build path, before any store\n * mutation) AND inside `getRoutesApi`'s `updateForwardTo` (the update path), so\n * `update(name, { forwardTo: async })` is rejected at registration with the same\n * actionable error instead of deferring a generic TypeError to navigation (#967).\n */\nexport function assertForwardToNotAsync(\n forwardTo: unknown,\n fullName: string,\n): void {\n if (typeof forwardTo !== \"function\") {\n return;\n }\n\n const isNativeAsync =\n (forwardTo as { constructor: { name: string } }).constructor.name ===\n \"AsyncFunction\";\n const isTranspiledAsync = (forwardTo as { toString: () => string })\n .toString()\n .includes(\"__awaiter\");\n\n if (isNativeAsync || isTranspiledAsync) {\n throw new TypeError(\n `forwardTo callback cannot be async for route \"${fullName}\". ` +\n `Async functions break matchPath/buildPath.`,\n );\n }\n}\n\nfunction registerForwardTo<Dependencies extends DefaultDependencies>(\n route: Route<Dependencies>,\n fullName: string,\n config: RouteConfig,\n logger: RouterLogger,\n): void {\n if (route.canActivate) {\n /* v8 ignore next -- @preserve: edge case, both string and function tested separately */\n const forwardTarget =\n typeof route.forwardTo === \"string\" ? route.forwardTo : \"[dynamic]\";\n\n logger.warn(\n \"real-router\",\n `Route \"${fullName}\" has both forwardTo and canActivate. ` +\n `canActivate will be ignored because forwardTo creates a redirect (industry standard). ` +\n `Move canActivate to the target route \"${forwardTarget}\".`,\n );\n }\n\n if (route.canDeactivate) {\n /* v8 ignore next -- @preserve: edge case, both string and function tested separately */\n const forwardTarget =\n typeof route.forwardTo === \"string\" ? route.forwardTo : \"[dynamic]\";\n\n logger.warn(\n \"real-router\",\n `Route \"${fullName}\" has both forwardTo and canDeactivate. ` +\n `canDeactivate will be ignored because forwardTo creates a redirect (industry standard). ` +\n `Move canDeactivate to the target route \"${forwardTarget}\".`,\n );\n }\n\n assertForwardToNotAsync(route.forwardTo, fullName);\n\n // forwardTo is guaranteed to exist at this point\n if (typeof route.forwardTo === \"string\") {\n config.forwardMap[fullName] = route.forwardTo;\n } else {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n config.forwardFnMap[fullName] = route.forwardTo!;\n }\n}\n\nfunction registerSingleRouteHandlers<Dependencies extends DefaultDependencies>(\n route: Route<Dependencies>,\n fullName: string,\n config: RouteConfig,\n routeCustomFields: Record<string, Record<string, unknown>>,\n pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>,\n pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>,\n logger: RouterLogger,\n): void {\n const customFields = Object.fromEntries(\n Object.entries(route).filter(([key]) => !STANDARD_ROUTE_KEYS.has(key)),\n );\n\n if (Object.keys(customFields).length > 0) {\n routeCustomFields[fullName] = customFields;\n }\n\n // Guards are collected here and registered into the lifecycle later — by\n // `adoptRouteArtifacts` (add/replace) or `RoutesNamespace.flushPendingGuards`\n // (initial routes, the final step of the Router constructor — #1331) — so\n // the build stays a pure, side-effect-free preparation step.\n if (route.canActivate) {\n pendingCanActivate.set(fullName, route.canActivate);\n }\n\n if (route.canDeactivate) {\n pendingCanDeactivate.set(fullName, route.canDeactivate);\n }\n\n if (route.forwardTo) {\n registerForwardTo(route, fullName, config, logger);\n }\n\n if (route.decodeParams) {\n const decode = route.decodeParams;\n\n config.decoders[fullName] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if a user-provided decoder violates its `{ params, search }` return type\n decode(channels) ?? channels;\n }\n\n if (route.encodeParams) {\n const encode = route.encodeParams;\n\n config.encoders[fullName] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if a user-provided encoder violates its `{ params, search }` return type\n encode(channels) ?? channels;\n }\n\n if (route.defaultParams) {\n config.defaultParams[fullName] = route.defaultParams;\n }\n\n if (route.defaultSearch) {\n config.defaultSearch[fullName] = route.defaultSearch;\n }\n}\n\nfunction registerAllRouteHandlers<Dependencies extends DefaultDependencies>(\n routes: readonly Route<Dependencies>[],\n config: RouteConfig,\n routeCustomFields: Record<string, Record<string, unknown>>,\n pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>,\n pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>,\n logger: RouterLogger,\n parentName = \"\",\n): void {\n for (const route of routes) {\n const fullName = parentName ? `${parentName}.${route.name}` : route.name;\n\n registerSingleRouteHandlers(\n route,\n fullName,\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n logger,\n );\n\n if (route.children) {\n registerAllRouteHandlers(\n route.children,\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n logger,\n fullName,\n );\n }\n }\n}\n\n// =============================================================================\n// Prepare-then-commit (issue #698)\n//\n// add()/replace() build the complete new store state into LOCAL structures, and\n// only swap it into the store once every core-level error has surfaced from the\n// build itself (async/circular forwardTo throw in registerAllRouteHandlers /\n// refreshForwardMap; invalid path constraint throws in rebuildTree). The store\n// is mutated only by `adoptRouteArtifacts`, which compiles every prepared guard\n// factory BEFORE the swap (#956): a factory that throws on compile (or returns a\n// non-function) aborts there, with the store still untouched. So all error\n// classes — core-level build errors AND malformed guard factories — surface\n// before any mutation, leaving the existing routes intact (full atomicity). The\n// silent-corruption cases route-tree never throws on (duplicate name vs an\n// existing route, a name duplicated within the batch, missing parent) are caught\n// up front by `assertAddable`.\n// =============================================================================\n\n/**\n * The fully-built, ready-to-swap result of preparing a route mutation. Holds\n * everything `adoptRouteArtifacts` assigns into the store.\n */\ninterface RouteArtifacts<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> {\n readonly config: RouteConfig;\n readonly routeCustomFields: Record<string, Record<string, unknown>>;\n readonly pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>;\n readonly pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>;\n readonly tree: RouteTree;\n readonly matcher: Matcher;\n readonly resolvedForwardMap: Record<string, string>;\n}\n\n/** Null-proto shallow clone of a RouteConfig (preserves every sub-map's contents). */\nfunction cloneConfig(config: RouteConfig): RouteConfig {\n const clone = createEmptyConfig();\n\n assignConfigEntries(clone, config);\n\n return clone;\n}\n\n/**\n * Returns a new definitions array with `added` inserted, without mutating the\n * input. For a top-level add the existing definitions are shallow-copied and\n * `added` appended. For a parented add the spine down to the parent is cloned\n * (siblings/other branches are shared by reference) and `added` appended to the\n * parent's children. Caller guarantees the parent path exists (see assertAddable).\n */\nfunction insertAddedDefinitions(\n definitions: readonly RouteDefinition[],\n added: RouteDefinition[],\n parentSegments: readonly string[],\n): RouteDefinition[] {\n if (parentSegments.length === 0) {\n return [...definitions, ...added];\n }\n\n const [head, ...rest] = parentSegments;\n\n return definitions.map((def) => {\n if (def.name !== head) {\n return def;\n }\n\n const children = def.children ?? [];\n\n return {\n ...def,\n children:\n rest.length === 0\n ? [...children, ...added]\n : insertAddedDefinitions(children, added, rest),\n };\n });\n}\n\n/** Depth-first walk yielding each route's full dotted name (no side effects). */\nfunction walkRouteNames<Dependencies extends DefaultDependencies>(\n routes: readonly Route<Dependencies>[],\n parentName: string,\n onName: (fullName: string) => void,\n): void {\n for (const route of routes) {\n const fullName = parentName ? `${parentName}.${route.name}` : route.name;\n\n onName(fullName);\n\n if (route.children) {\n walkRouteNames(route.children, fullName, onName);\n }\n }\n}\n\n/**\n * Rejects a route name duplicated WITHIN a single batch — the silent-overwrite\n * case route-tree stays last-wins on (#953 for `add`, #968 for `replace`). Walks\n * the same depth-first dotted names, but tracks them in a local Set: a name seen\n * twice in one array means the caller's second route would silently shadow the\n * first (`matchPath` for the first route's path becomes unreachable). Mirrors\n * validation-plugin's batch-dup message (route-tree `checkBatchNameDuplicate`)\n * so the no-plugin error matches the with-plugin one. `methodName` is \"addRoute\"\n * for both add and replace — the plugin reports \"addRoute\" for replace batches\n * too, so this keeps with/without-plugin parity.\n */\nexport function assertNoDuplicateNamesInBatch<\n Dependencies extends DefaultDependencies,\n>(\n routes: readonly Route<Dependencies>[],\n parentName: string,\n methodName: string,\n): void {\n const seen = new Set<string>();\n\n walkRouteNames(routes, parentName, (fullName) => {\n if (seen.has(fullName)) {\n throw new Error(\n `[router.${methodName}] Duplicate route \"${fullName}\" in batch`,\n );\n }\n\n seen.add(fullName);\n });\n}\n\nconst INTERNAL_ROUTE_PREFIX = \"@@\";\n\n/**\n * Rejects a single (bare) route name that uses the reserved \"@@\" prefix —\n * internal / system names such as UNKNOWN_ROUTE (`\"@@router/UNKNOWN_ROUTE\"`).\n * Mutating such a name would let a real URL `matchPath` to a state with\n * `name === UNKNOWN_ROUTE`, silently conflating a genuine route with \"not\n * found\". This always-on guard protected all four mutators (#238) until the\n * validation-extraction (`d1ebff80`) demoted it to the opt-in\n * validation-plugin; only `add` was restored (#954), so `remove`/`update`\n * regained it via this helper (#1047). Mirrors validation-plugin's\n * `throwIfInternalRoute` message so the no-plugin error matches the with-plugin\n * one.\n */\nexport function assertNoInternalRouteName(\n name: string,\n methodName: string,\n): void {\n if (name.startsWith(INTERNAL_ROUTE_PREFIX)) {\n throw new Error(\n `[router.${methodName}] Route name \"${name}\" uses the reserved \"${INTERNAL_ROUTE_PREFIX}\" prefix. Routes with this prefix are internal and cannot be modified through the public API.`,\n );\n }\n}\n\n/**\n * Batch counterpart to {@link assertNoInternalRouteName}: rejects any route in\n * the batch (recursing children) whose BARE leaf name uses the reserved \"@@\"\n * prefix (the prefix is on the leaf, not the dotted fullName). Used by `add`\n * (#954) and `replace` (#1047).\n */\nexport function assertNoInternalNamesInBatch<\n Dependencies extends DefaultDependencies,\n>(routes: readonly Route<Dependencies>[], methodName: string): void {\n for (const route of routes) {\n assertNoInternalRouteName(route.name, methodName);\n\n if (route.children) {\n assertNoInternalNamesInBatch(route.children, methodName);\n }\n }\n}\n\n/**\n * Rejects two routes that share the same `path` at the same parent level WITHIN\n * a single `add` batch (#955). The matcher resolves a path collision last-wins,\n * so the earlier route stays addressable by name (`has` / `buildPath`) but is\n * unreachable by URL (`matchPath` returns the later route) — a silent shadow.\n * Paths only collide among siblings, so seen paths are tracked per parent\n * fullName. Mirrors validation-plugin's message (route-tree\n * `checkBatchPathDuplicate`) so the no-plugin error matches the with-plugin one.\n * Scoped to the batch (not the existing tree) per #955 — the in-batch case the\n * issue describes.\n */\nexport function assertNoDuplicatePathsInBatch<\n Dependencies extends DefaultDependencies,\n>(\n routes: readonly Route<Dependencies>[],\n parentName: string,\n methodName: string,\n): void {\n const seenByParent = new Map<string, Set<string>>();\n\n const walk = (\n siblings: readonly Route<Dependencies>[],\n parent: string,\n ): void => {\n for (const route of siblings) {\n const paths = seenByParent.get(parent);\n\n if (paths?.has(route.path)) {\n throw new Error(\n `[router.${methodName}] Path \"${route.path}\" is already defined`,\n );\n }\n\n if (paths) {\n paths.add(route.path);\n } else {\n seenByParent.set(parent, new Set([route.path]));\n }\n\n if (route.children) {\n walk(route.children, parent ? `${parent}.${route.name}` : route.name);\n }\n }\n };\n\n walk(routes, parentName);\n}\n\n/**\n * Up-front guard for `add` against the corruptions route-tree stays silent on: a\n * missing `parent`, a name that collides with an EXISTING route, a name\n * duplicated WITHIN the batch, a reserved \"@@\"-prefixed name (which would shadow\n * an internal/system route name), and a path duplicated among siblings WITHIN\n * the batch (any of which would otherwise be silently overwritten/shadowed).\n * Throws before any build.\n */\nexport function assertAddable<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n routes: readonly Route<Dependencies>[],\n parentName: string | undefined,\n): void {\n assertNoInternalNamesInBatch(routes, \"addRoute\");\n\n if (parentName !== undefined && !store.matcher.hasRoute(parentName)) {\n throw new Error(\n `[router.addRoute] Parent route \"${parentName}\" does not exist`,\n );\n }\n\n walkRouteNames(routes, parentName ?? \"\", (fullName) => {\n if (store.matcher.hasRoute(fullName)) {\n throw new Error(`[router.addRoute] Route \"${fullName}\" already exists`);\n }\n });\n\n assertNoDuplicateNamesInBatch(routes, parentName ?? \"\", \"addRoute\");\n assertNoDuplicatePathsInBatch(routes, parentName ?? \"\", \"addRoute\");\n}\n\n/**\n * Builds RouteArtifacts from a final definitions array and the routes whose\n * handlers (config + guards) populate `config`/`routeCustomFields`. Guards are\n * collected into the returned pending maps (depsStore is intentionally omitted\n * so nothing compiles or touches the lifecycle here). THROWS on async/circular\n * forwardTo and invalid path constraint — before the caller mutates the store.\n *\n * Takes a single args object: the positional list hit 8 parameters (S107) when\n * the per-router `logger` (#724) joined it, and named fields read better at the\n * two call sites anyway.\n */\nfunction buildArtifacts<Dependencies extends DefaultDependencies>({\n definitions,\n routesForHandlers,\n config,\n routeCustomFields,\n handlerParentName,\n rootPath,\n matcherOptions,\n logger,\n}: {\n definitions: readonly RouteDefinition[];\n routesForHandlers: readonly Route<Dependencies>[];\n config: RouteConfig;\n routeCustomFields: Record<string, Record<string, unknown>>;\n handlerParentName: string;\n rootPath: string;\n matcherOptions: CreateMatcherOptions | undefined;\n logger: RouterLogger;\n}): RouteArtifacts<Dependencies> {\n const pendingCanActivate = new Map<string, GuardFnFactory<Dependencies>>();\n const pendingCanDeactivate = new Map<string, GuardFnFactory<Dependencies>>();\n\n registerAllRouteHandlers(\n routesForHandlers,\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n logger,\n handlerParentName,\n );\n\n const resolvedForwardMap = refreshForwardMap(config);\n const { tree, matcher } = rebuildTree(definitions, rootPath, matcherOptions);\n\n return {\n config,\n routeCustomFields,\n pendingCanActivate,\n pendingCanDeactivate,\n tree,\n matcher,\n resolvedForwardMap,\n };\n}\n\n/** Builds the merged artifacts for an incremental `add` (existing ∪ new). */\nexport function buildAddArtifacts<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n routes: readonly Route<Dependencies>[],\n parentName: string | undefined,\n logger: RouterLogger,\n): RouteArtifacts<Dependencies> {\n const definitions = insertAddedDefinitions(\n store.definitions,\n routes.map((route) => sanitizeRoute(route)),\n parentName === undefined ? [] : parentName.split(\".\"),\n );\n\n return buildArtifacts({\n definitions,\n routesForHandlers: routes,\n config: cloneConfig(store.config),\n routeCustomFields: Object.assign(\n Object.create(null) as Record<string, Record<string, unknown>>,\n store.routeCustomFields,\n ),\n handlerParentName: parentName ?? \"\",\n rootPath: store.rootPath,\n matcherOptions: store.matcherOptions,\n logger,\n });\n}\n\n/** Builds the fresh artifacts for a full `replace` (standalone new set). */\nexport function buildReplaceArtifacts<Dependencies extends DefaultDependencies>(\n routes: readonly Route<Dependencies>[],\n rootPath: string,\n matcherOptions: CreateMatcherOptions | undefined,\n logger: RouterLogger,\n): RouteArtifacts<Dependencies> {\n return buildArtifacts({\n definitions: routes.map((route) => sanitizeRoute(route)),\n routesForHandlers: routes,\n config: createEmptyConfig(),\n routeCustomFields: Object.create(null) as Record<\n string,\n Record<string, unknown>\n >,\n handlerParentName: \"\",\n rootPath,\n matcherOptions,\n logger,\n });\n}\n\n/**\n * Compiles every pending guard factory up front, returning\n * `[name, factory, compiledFn]` triples for installation. THROWS from `compile`\n * on the first factory that throws on compile or returns a non-function — the\n * pre-swap validation that makes `adoptRouteArtifacts` atomic for malformed\n * guards (#956). Compiling here (not at install) means a factory with\n * compile-time side effects runs exactly once.\n */\nfunction compilePendingGuards<Dependencies extends DefaultDependencies>(\n pending: Map<string, GuardFnFactory<Dependencies>>,\n compile: (\n handler: GuardFnFactory<Dependencies>,\n methodName: string,\n ) => GuardFn,\n methodName: string,\n): [string, GuardFnFactory<Dependencies>, GuardFn][] {\n const compiled: [string, GuardFnFactory<Dependencies>, GuardFn][] = [];\n\n for (const [name, factory] of pending) {\n compiled.push([name, factory, compile(factory, methodName)]);\n }\n\n return compiled;\n}\n\n/** Pre-compiled guard triples for {@link adoptRouteArtifacts} install. */\nexport interface CompiledArtifactGuards<\n Dependencies extends DefaultDependencies,\n> {\n activate: [string, GuardFnFactory<Dependencies>, GuardFn][];\n deactivate: [string, GuardFnFactory<Dependencies>, GuardFn][];\n}\n\n/**\n * Compiles an artifacts' pending guard factories up front (#956), THROWING on\n * the first factory that throws on compile or returns a non-function.\n *\n * `replaceRoutes` calls this in its PREPARE phase — **before**\n * `clearDefinitionGuards()` — and hands the result to `adoptRouteArtifacts`, so\n * a compile-throw aborts with BOTH the tree AND the old definition guards intact\n * (#1193, mirroring #1046's handler-limit hoist). `add` has no clear step, so\n * `adoptRouteArtifacts` compiles inline for it.\n */\nexport function compileArtifactGuards<Dependencies extends DefaultDependencies>(\n artifacts: RouteArtifacts<Dependencies>,\n deps: RoutesDependencies<Dependencies>,\n): CompiledArtifactGuards<Dependencies> {\n return {\n activate: compilePendingGuards(\n artifacts.pendingCanActivate,\n deps.compileGuard,\n \"canActivate\",\n ),\n deactivate: compilePendingGuards(\n artifacts.pendingCanDeactivate,\n deps.compileGuard,\n \"canDeactivate\",\n ),\n };\n}\n\n/**\n * Commits prepared artifacts into the store in place. Every pending guard\n * factory is compiled BEFORE the tree/config swap (#956): a factory that throws\n * on compile (or returns a non-function) aborts here with the store untouched,\n * so `add`/`replace` are atomic for malformed guards too — not just core build\n * errors. The tree/config assignments are pure and cannot throw; the\n * pre-compiled guards are then installed without re-compiling (the factory ran\n * once, at the pre-compile above). `depsStore` is always set on a wired router,\n * which is the only path that reaches `add`/`replace`.\n *\n * ⚠ **The config-time channel check (`assertRouteDefaultChannels`) is the\n * CALLER's PREPARE step, not this function's.** It used to run here, one line\n * before the swap, which is early enough for `add` and too late for `replace`:\n * `replace` erases the old definition guards BEFORE calling this, so a batch\n * this check refused left the tree intact and the guards gone — a previously\n * guarded route freely activatable. That is the #1193 fail-open shape verbatim,\n * which is why the guard COMPILE was hoisted into the callers; the channel\n * check now sits beside it, for the same reason. Keeping this function\n * throw-free is what makes its \"atomic swap\" contract true rather than nearly\n * true.\n */\nexport function adoptRouteArtifacts<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n artifacts: RouteArtifacts<Dependencies>,\n precompiled?: CompiledArtifactGuards<Dependencies>,\n): void {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depsStore is set once the router is wired; add/replace only run on a wired router (constructor-time registration uses createRoutesStore)\n const deps = store.depsStore!;\n\n // Pre-swap compile: surfaces a malformed guard factory before any mutation.\n // `replace()` pre-compiles in its PREPARE phase (BEFORE clearDefinitionGuards)\n // and passes the result here, so a compile-throw never erases the old\n // definition guards (#1193); `add` has no clear step and compiles inline.\n const { activate: compiledActivate, deactivate: compiledDeactivate } =\n precompiled ?? compileArtifactGuards(artifacts, deps);\n\n // Atomic swap — pure assignments, cannot throw. (`definitions` is derived\n // from `tree`, so swapping the tree IS the definitions swap.)\n Object.assign(store.config, artifacts.config);\n store.routeCustomFields = artifacts.routeCustomFields;\n store.tree = artifacts.tree;\n store.matcher = artifacts.matcher;\n store.urlParamsCache.clear();\n store.queryParamsCache.clear();\n adoptForwardState(store, artifacts.resolvedForwardMap);\n\n // Install pre-compiled guards — no re-compile, no throw.\n for (const [name, factory, fn] of compiledActivate) {\n deps.addActivateGuard(name, factory, fn);\n }\n\n for (const [name, factory, fn] of compiledDeactivate) {\n deps.addDeactivateGuard(name, factory, fn);\n }\n}\n\n/**\n * COMMIT core for `update()` — the field-patch counterpart to\n * {@link adoptRouteArtifacts} (add/replace) / {@link commitTreeChanges} (remove)\n * / {@link resetStore} (clear), co-located here so all four route-CRUD commit\n * cores live in one file. Stays **NO_TREE_REBUILD**: an O(1) per-field patch\n * that never rebuilds the tree/matcher (so it must NOT funnel through\n * `adoptRouteArtifacts`).\n *\n * Prepare-then-commit (#951): every throwing step runs in PREPARE, before any\n * store write, so a rejected update leaves the route's prior config fully intact\n * — an async/cyclic `forwardTo` (#967), a guard factory that throws on compile\n * (#956 seam), a throwing custom-field getter, and the #961 handler-limit\n * pre-flight (#1046). Returns the structural fields for the caller's conditional\n * TREE_CHANGED emit, computed from the single destructure here so core invokes\n * each user getter once (#797 / #952 `null`-clears-definition-only preserved).\n */\nexport function commitRouteUpdate<Dependencies extends DefaultDependencies>(\n store: RoutesStore<Dependencies>,\n lifecycle: RouteLifecycleNamespace<Dependencies>,\n name: string,\n updates: RouteConfigUpdate<Dependencies>,\n): {\n forwardTo?: string | ForwardToCallback<Dependencies> | null | undefined;\n defaultParams?: Params | null | undefined;\n defaultSearch?: SearchParams | null | undefined;\n decodeParams?: ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n encodeParams?: ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n} {\n const {\n forwardTo,\n defaultParams,\n defaultSearch,\n decodeParams,\n encodeParams,\n canActivate,\n canDeactivate,\n } = updates;\n\n // ===== PREPARE — compute every change into LOCALS. Any throw here aborts\n // before a single store write, so the whole field set is applied\n // all-or-nothing (#951).\n\n // Channel check on the INCOMING value, in PREPARE: `update` does not rebuild\n // the tree (NO_TREE_REBUILD), so the route's declarations are the ones the\n // matcher already holds. Checked before any write, so a mis-channelled\n // `defaultParams` aborts the whole update rather than landing half-applied.\n if (defaultParams !== undefined && defaultParams !== null) {\n assertChannelCorrect(\n \"updateRoute\",\n name,\n defaultParams,\n queryParamsOf(store, name),\n \"this route's `defaultParams`\",\n \"Move it to `defaultSearch`\",\n );\n }\n\n const forwardToPlan =\n forwardTo === undefined\n ? undefined\n : prepareForwardTo(name, forwardTo, store.config);\n\n const nextCustomFields = prepareCustomFields(store, name, updates);\n\n // Guard factories are compiled NOW (a throwing factory surfaces in PREPARE);\n // the precompiled function is installed in COMMIT without re-invoking the\n // factory, so a factory side effect runs exactly once (reuses the #956\n // compile-then-install seam). Compiled after the other prepares so a throw\n // upstream skips invoking the factory at all.\n const activateFn =\n canActivate === undefined || canActivate === null\n ? undefined\n : lifecycle.compileGuardFactory(canActivate, \"canActivate\");\n const deactivateFn =\n canDeactivate === undefined || canDeactivate === null\n ? undefined\n : lifecycle.compileGuardFactory(canDeactivate, \"canDeactivate\");\n\n // Pre-flight the #961 handler-limit before the COMMIT writes, so an at-limit\n // update that adds a NEW guard slot aborts before forwardTo / scalar config\n // land (#1046, #951). A slot is new only when `name` does not already hold a\n // guard of that type — an overwrite does not count.\n lifecycle.preflightHandlerLimit(\n activateFn === undefined ? [] : [name],\n deactivateFn === undefined ? [] : [name],\n false,\n );\n\n // ===== COMMIT — pure writes from here; nothing below throws.\n // Custom (plugin-defined) fields. Consumers read these lazily via\n // getRouteConfig (lifecycle hooks, preload, searchSchema), so no TREE_CHANGED\n // is needed — the next read sees the new value; the caller's emit stays\n // structural-only by design (О-7).\n if (nextCustomFields !== undefined) {\n if (Object.keys(nextCustomFields).length > 0) {\n store.routeCustomFields[name] = nextCustomFields;\n } else {\n delete store.routeCustomFields[name];\n }\n }\n\n if (forwardToPlan !== undefined) {\n store.config.forwardMap = forwardToPlan.forwardMap;\n store.config.forwardFnMap = forwardToPlan.forwardFnMap;\n adoptForwardState(store, forwardToPlan.resolved);\n }\n\n commitScalarConfig(store, name, {\n defaultParams,\n defaultSearch,\n decodeParams,\n encodeParams,\n });\n\n // Install the guards from their PREPARE-phase precompiled functions; a `null`\n // clears the definition-origin guard only (#952). See commitGuardUpdate.\n commitGuardUpdate(lifecycle, \"activate\", name, canActivate, activateFn);\n commitGuardUpdate(lifecycle, \"deactivate\", name, canDeactivate, deactivateFn);\n\n return {\n forwardTo,\n defaultParams,\n defaultSearch,\n decodeParams,\n encodeParams,\n };\n}\n\n/**\n * PREPARE step for a `forwardTo` update (#951 atomicity): computes the new\n * forward maps and the resolved forward chain into LOCALS and returns them\n * WITHOUT touching the store. A throw here — an async `forwardTo` (#967) or a\n * cycle surfaced by `refreshForwardMap` — aborts `update()` before any field is\n * committed. The caller writes the returned bundle into the store in its COMMIT\n * phase. (Mirrors the build-then-swap shape of #698, but the swap is deferred to\n * the caller so it can be sequenced with the other prepared fields.)\n */\nfunction prepareForwardTo<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n name: string,\n forwardTo: string | ForwardToCallback<Dependencies> | null,\n config: RouteConfig,\n): {\n forwardMap: RouteConfig[\"forwardMap\"];\n forwardFnMap: RouteConfig[\"forwardFnMap\"];\n resolved: Record<string, string>;\n} {\n // #967: reject an async forwardTo at update time — parity with add/replace\n // (registerForwardTo runs the same check on the build path). A no-op for\n // string/null. Without this the async callback is stored silently and\n // surfaces later as a generic \"must return a string, got object\" TypeError\n // from #resolveDynamicForward at navigation. Runs first, before any clone.\n assertForwardToNotAsync(forwardTo, name);\n\n const forwardMap = Object.assign(\n Object.create(null) as RouteConfig[\"forwardMap\"],\n config.forwardMap,\n );\n const forwardFnMap = Object.assign(\n Object.create(null) as RouteConfig[\"forwardFnMap\"],\n config.forwardFnMap,\n );\n\n if (forwardTo === null) {\n delete forwardMap[name];\n delete forwardFnMap[name];\n } else if (typeof forwardTo === \"string\") {\n delete forwardFnMap[name];\n forwardMap[name] = forwardTo;\n } else {\n delete forwardMap[name];\n forwardFnMap[name] = forwardTo;\n }\n\n const resolved = refreshForwardMap({ ...config, forwardMap });\n\n return { forwardMap, forwardFnMap, resolved };\n}\n\n/**\n * PREPARE step for a route's plugin-defined **custom fields** (#951) — the\n * `update` counterpart to how `add`/`replace` register them\n * (`registerSingleRouteHandlers`). A custom field is any patch key not in\n * {@link STANDARD_ROUTE_KEYS}.\n *\n * Computes the merged record and RETURNS it for the caller to commit;\n * `undefined` means no custom-field key was present, so the caller leaves the\n * store untouched. Semantics mirror the scalar fields in\n * {@link commitScalarConfig}: shallow-merge by patch key, `null` removes a\n * single field, `undefined` is a no-op (leaves the field untouched). When the\n * merge empties the record, the caller drops the whole entry so `getRouteConfig`\n * returns `undefined` — symmetric with `add`, which only stores a record when at\n * least one custom field exists.\n *\n * Reading the custom-field getters HERE (in PREPARE, not at commit) is what lets\n * a throwing getter abort the whole update before any field is written. The\n * merged record is a **fresh object**, never mutated in place: `cloneRouter`\n * shares per-route custom-field records by reference (`Object.assign`), so\n * replacing the reference keeps a clone isolated from post-clone updates on the\n * source.\n */\nfunction prepareCustomFields<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n name: string,\n updates: RouteConfigUpdate<Dependencies>,\n): Record<string, unknown> | undefined {\n let next: Record<string, unknown> | undefined;\n\n // `Object.keys` (not `Object.entries`): a value is read only AFTER the\n // standard-key guard, so structural-field getters — already read once by\n // `update`'s destructuring — are not re-invoked. `Object.entries` would read\n // every value eagerly, double-invoking a `defaultParams`/`forwardTo` getter\n // and breaking the \"user getter called once\" invariant.\n // eslint-disable-next-line unicorn/prefer-object-iterable-methods -- see above\n for (const key of Object.keys(updates)) {\n if (STANDARD_ROUTE_KEYS.has(key)) {\n continue;\n }\n\n const value = (updates as Record<string, unknown>)[key];\n\n // `undefined` mirrors the structural path: leave the field untouched.\n if (value === undefined) {\n continue;\n }\n\n // Clone-on-first-write — keeps clones (which alias this record) isolated.\n next ??= { ...store.routeCustomFields[name] };\n\n if (value === null) {\n delete next[key];\n } else {\n next[key] = value;\n }\n }\n\n return next;\n}\n\n/**\n * Applies one nullable scalar-config update in place: `undefined` is a no-op\n * (field not in the patch), `null` deletes the entry, any other value sets it.\n */\nfunction commitScalarField<T>(\n map: Record<string, T>,\n name: string,\n value: T | null | undefined,\n): void {\n if (value === undefined) {\n return;\n }\n\n if (value === null) {\n delete map[name];\n } else {\n map[name] = value;\n }\n}\n\n/**\n * COMMIT step for the scalar config fields of an update (#951): writes\n * `defaultParams` / `defaultSearch` / `decodeParams` / `encodeParams` in place.\n * These assignments\n * are pure and never throw, so they run in the COMMIT phase after every throwing\n * field has been validated in PREPARE. `forwardTo` is handled separately — it\n * has its own throwing prepare step ({@link prepareForwardTo}).\n */\nfunction commitScalarConfig<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n store: RoutesStore<Dependencies>,\n name: string,\n updates: {\n defaultParams?: Params | null | undefined;\n defaultSearch?: SearchParams | null | undefined;\n decodeParams?:\n ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n encodeParams?:\n ((channels: ParamsSearch) => ParamsSearch) | null | undefined;\n },\n): void {\n commitScalarField(store.config.defaultParams, name, updates.defaultParams);\n commitScalarField(store.config.defaultSearch, name, updates.defaultSearch);\n\n if (updates.decodeParams !== undefined) {\n if (updates.decodeParams === null) {\n delete store.config.decoders[name];\n } else {\n const decoder = updates.decodeParams;\n\n store.config.decoders[name] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if user-provided decoder violates its `{ params, search }` return type\n decoder(channels) ?? channels;\n }\n }\n\n if (updates.encodeParams !== undefined) {\n if (updates.encodeParams === null) {\n delete store.config.encoders[name];\n } else {\n const encoder = updates.encodeParams;\n\n store.config.encoders[name] = (channels: ParamsSearch): ParamsSearch =>\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if user-provided encoder violates its `{ params, search }` return type\n encoder(channels) ?? channels;\n }\n }\n}\n\n/**\n * COMMIT step for one guard field of an update (#951). `undefined` is a no-op;\n * `null` clears the DEFINITION-origin guard only, preserving an external guard\n * (#952); a factory installs together with its PREPARE-phase `precompiledFn`\n * (no re-compile — #956 seam). Extracted from `update()` so its prepare/commit\n * orchestration stays within the cognitive-complexity budget.\n */\nfunction commitGuardUpdate<Dependencies extends DefaultDependencies>(\n lifecycle: RouteLifecycleNamespace<Dependencies>,\n kind: \"activate\" | \"deactivate\",\n name: string,\n value: GuardFnFactory<Dependencies> | null | undefined,\n precompiledFn: GuardFn | undefined,\n): void {\n if (value === undefined) {\n return;\n }\n\n if (kind === \"activate\") {\n if (value === null) {\n lifecycle.clearCanActivate(name, \"definition\");\n } else {\n lifecycle.addCanActivate(name, value, true, precompiledFn);\n }\n } else if (value === null) {\n lifecycle.clearCanDeactivate(name, \"definition\");\n } else {\n lifecycle.addCanDeactivate(name, value, true, precompiledFn);\n }\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport function createRoutesStore<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n routes: Route<Dependencies>[],\n matcherOptions: CreateMatcherOptions | undefined,\n logger: RouterLogger,\n): RoutesStore<Dependencies> {\n // Initial routes are a standalone set at rootPath \"\" — same build the\n // prepare-then-commit `replace` path uses. Guards land in the pending maps,\n // flushed by `flushPendingGuards()` at the end of the Router constructor\n // (#1331); `setDependencies` itself is a pure assignment.\n //\n // Reject the silent-corruption cases BEFORE building, giving the constructor\n // parity with `add()` / `replace()` — the third and last route-population\n // entry point (#1351): within-batch duplicate names (#953/#968) and reserved\n // \"@@\" names (#954). Without these the constructor silently last-wins a\n // duplicate-name sibling (the first route is dropped → its deep-link 404s)\n // while add/replace throw. `methodName` is \"addRoute\" so all three entry\n // points surface the identical bare-core error. (Duplicate PATHS are already\n // rejected downstream by the path-matcher backstop #1153, so they are not\n // re-checked here.)\n assertNoInternalNamesInBatch(routes, \"addRoute\");\n assertNoDuplicateNamesInBatch(routes, \"\", \"addRoute\");\n\n const artifacts = buildReplaceArtifacts(routes, \"\", matcherOptions, logger);\n\n const store: RoutesStore<Dependencies> = {\n // Deferred access: the getter runs only after `store` is initialized.\n get definitions() {\n return routeTreeToDefinitions(store.tree);\n },\n config: artifacts.config,\n tree: artifacts.tree,\n matcher: artifacts.matcher,\n urlParamsCache: new Map(),\n queryParamsCache: new Map(),\n resolvedForwardMap: artifacts.resolvedForwardMap,\n hasAnyForward: anyForwardConfigured(artifacts.config),\n routeCustomFields: artifacts.routeCustomFields,\n rootPath: \"\",\n matcherOptions,\n depsStore: undefined,\n lifecycleNamespace: undefined,\n pendingCanActivate: artifacts.pendingCanActivate,\n pendingCanDeactivate: artifacts.pendingCanDeactivate,\n };\n\n // Same config-time channel check the add/replace path runs, so the\n // constructor is not the one population entry point that accepts a config\n // whose own state the router would then reject on `start()`.\n assertRouteDefaultChannelsFor(store.matcher, store.config, \"addRoute\");\n\n return store;\n}\n"],"mappings":"+RAgHA,SAAS,EACP,EACA,EACA,EACuC,CACvC,IAAM,EAAOA,EAAAA,gBAAAA,GAAoC,EAAU,CAAW,EAChE,EAAUC,EAAAA,cAAc,CAAc,EAI5C,OAFA,EAAQ,aAAa,CAAI,EAElB,CAAE,OAAM,SAAQ,CACzB,CAOA,SAAgB,EAGd,EACA,EAA0C,EAAM,YAC1C,CACN,IAAM,EAAS,EAAY,EAAa,EAAM,SAAU,EAAM,cAAc,EAE5E,EAAM,KAAO,EAAO,KACpB,EAAM,QAAU,EAAO,QACvB,EAAM,eAAe,MAAM,EAC3B,EAAM,iBAAiB,MAAM,CAC/B,CAWA,SAAgB,EAEd,EAAkC,EAA2B,CAC7D,IAAM,EAAW,EACf,EAAM,YACN,EACA,EAAM,cACR,EAEA,EAAA,8BAA8B,EAAS,QAAS,EAAM,OAAQ,aAAa,EAE3E,EAAM,SAAW,EACjB,EAAM,KAAO,EAAS,KACtB,EAAM,QAAU,EAAS,QACzB,EAAM,eAAe,MAAM,EAC3B,EAAM,iBAAiB,MAAM,CAC/B,CAEA,SAAgB,EAGd,EACA,EACM,CACN,EAAmB,EAAO,CAAW,EACrC,EAAkB,EAAO,EAAkB,EAAM,MAAM,CAAC,CAC1D,CAUA,SAAgB,EAEd,EAAwC,CACxC,EAAe,CAAK,EACpB,EAAmB,EAAO,CAAC,CAAC,CAC9B,CAQA,SAAgB,EAEd,EAAwC,CACxC,OAAO,OAAO,EAAM,OAAQC,EAAAA,kBAAkB,CAAC,EAE/C,EAAkB,EAAO,OAAO,OAAO,IAAI,CAA2B,EACtE,EAAM,kBAAoB,OAAO,OAAO,IAAI,CAI9C,CAYA,SAAS,EACP,EACA,EACM,CACN,EAAM,mBAAqB,EAC3B,EAAM,cAAgBC,EAAAA,qBAAqB,EAAM,MAAM,CACzD,CAEA,SAAgB,EAAkB,EAA6C,CAC7E,IAAM,EAAM,OAAO,OAAO,IAAI,EAE9B,IAAK,IAAM,KAAa,OAAO,KAAK,EAAO,UAAU,EACnD,EAAI,GAAaC,EAAAA,oBAAoB,EAAW,EAAO,UAAU,EAGnE,OAAO,CACT,CAcA,SAAgB,EACd,EACA,EACM,CACN,GAAI,OAAO,GAAc,WACvB,OAGF,IAAM,EACH,EAAgD,YAAY,OAC7D,gBACI,EAAqB,EACxB,SAAS,CAAC,CACV,SAAS,WAAW,EAEvB,GAAI,GAAiB,EACnB,MAAU,UACR,iDAAiD,EAAS,8CAE5D,CAEJ,CAEA,SAAS,EACP,EACA,EACA,EACA,EACM,CACN,GAAI,EAAM,YAAa,CAErB,IAAM,EACJ,OAAO,EAAM,WAAc,SAAW,EAAM,UAAY,YAE1D,EAAO,KACL,cACA,UAAU,EAAS,oKAEwB,EAAc,GAC3D,CACF,CAEA,GAAI,EAAM,cAAe,CAEvB,IAAM,EACJ,OAAO,EAAM,WAAc,SAAW,EAAM,UAAY,YAE1D,EAAO,KACL,cACA,UAAU,EAAS,0KAE0B,EAAc,GAC7D,CACF,CAEA,EAAwB,EAAM,UAAW,CAAQ,EAG7C,OAAO,EAAM,WAAc,SAC7B,EAAO,WAAW,GAAY,EAAM,UAGpC,EAAO,aAAa,GAAY,EAAM,SAE1C,CAEA,SAAS,EACP,EACA,EACA,EACA,EACA,EACA,EACA,EACM,CACN,IAAM,EAAe,OAAO,YAC1B,OAAO,QAAQ,CAAK,CAAC,CAAC,QAAQ,CAAC,KAAS,CAACC,EAAAA,oBAAoB,IAAI,CAAG,CAAC,CACvE,EAsBA,GApBI,OAAO,KAAK,CAAY,CAAC,CAAC,OAAS,IACrC,EAAkB,GAAY,GAO5B,EAAM,aACR,EAAmB,IAAI,EAAU,EAAM,WAAW,EAGhD,EAAM,eACR,EAAqB,IAAI,EAAU,EAAM,aAAa,EAGpD,EAAM,WACR,EAAkB,EAAO,EAAU,EAAQ,CAAM,EAG/C,EAAM,aAAc,CACtB,IAAM,EAAS,EAAM,aAErB,EAAO,SAAS,GAAa,GAE3B,EAAO,CAAQ,GAAK,CACxB,CAEA,GAAI,EAAM,aAAc,CACtB,IAAM,EAAS,EAAM,aAErB,EAAO,SAAS,GAAa,GAE3B,EAAO,CAAQ,GAAK,CACxB,CAEI,EAAM,gBACR,EAAO,cAAc,GAAY,EAAM,eAGrC,EAAM,gBACR,EAAO,cAAc,GAAY,EAAM,cAE3C,CAEA,SAAS,EACP,EACA,EACA,EACA,EACA,EACA,EACA,EAAa,GACP,CACN,IAAK,IAAM,KAAS,EAAQ,CAC1B,IAAM,EAAW,EAAa,GAAG,EAAW,GAAG,EAAM,OAAS,EAAM,KAEpE,EACE,EACA,EACA,EACA,EACA,EACA,EACA,CACF,EAEI,EAAM,UACR,EACE,EAAM,SACN,EACA,EACA,EACA,EACA,EACA,CACF,CAEJ,CACF,CAoCA,SAAS,EAAY,EAAkC,CACrD,IAAM,EAAQH,EAAAA,kBAAkB,EAIhC,OAFA,EAAA,oBAAoB,EAAO,CAAM,EAE1B,CACT,CASA,SAAS,EACP,EACA,EACA,EACmB,CACnB,GAAI,EAAe,SAAW,EAC5B,MAAO,CAAC,GAAG,EAAa,GAAG,CAAK,EAGlC,GAAM,CAAC,EAAM,GAAG,GAAQ,EAExB,OAAO,EAAY,IAAK,GAAQ,CAC9B,GAAI,EAAI,OAAS,EACf,OAAO,EAGT,IAAM,EAAW,EAAI,UAAY,CAAC,EAElC,MAAO,CACL,GAAG,EACH,SACE,EAAK,SAAW,EACZ,CAAC,GAAG,EAAU,GAAG,CAAK,EACtB,EAAuB,EAAU,EAAO,CAAI,CACpD,CACF,CAAC,CACH,CAGA,SAAS,EACP,EACA,EACA,EACM,CACN,IAAK,IAAM,KAAS,EAAQ,CAC1B,IAAM,EAAW,EAAa,GAAG,EAAW,GAAG,EAAM,OAAS,EAAM,KAEpE,EAAO,CAAQ,EAEX,EAAM,UACR,EAAe,EAAM,SAAU,EAAU,CAAM,CAEnD,CACF,CAaA,SAAgB,EAGd,EACA,EACA,EACM,CACN,IAAM,EAAO,IAAI,IAEjB,EAAe,EAAQ,EAAa,GAAa,CAC/C,GAAI,EAAK,IAAI,CAAQ,EACnB,MAAU,MACR,WAAW,EAAW,qBAAqB,EAAS,WACtD,EAGF,EAAK,IAAI,CAAQ,CACnB,CAAC,CACH,CAgBA,SAAgB,EACd,EACA,EACM,CACN,GAAI,EAAK,WAAW,IAAqB,EACvC,MAAU,MACR,WAAW,EAAW,gBAAgB,EAAK,qHAC7C,CAEJ,CAQA,SAAgB,EAEd,EAAwC,EAA0B,CAClE,IAAK,IAAM,KAAS,EAClB,EAA0B,EAAM,KAAM,CAAU,EAE5C,EAAM,UACR,EAA6B,EAAM,SAAU,CAAU,CAG7D,CAaA,SAAgB,EAGd,EACA,EACA,EACM,CACN,IAAM,EAAe,IAAI,IAEnB,GACJ,EACA,IACS,CACT,IAAK,IAAM,KAAS,EAAU,CAC5B,IAAM,EAAQ,EAAa,IAAI,CAAM,EAErC,GAAI,GAAO,IAAI,EAAM,IAAI,EACvB,MAAU,MACR,WAAW,EAAW,UAAU,EAAM,KAAK,qBAC7C,EAGE,EACF,EAAM,IAAI,EAAM,IAAI,EAEpB,EAAa,IAAI,EAAQ,IAAI,IAAI,CAAC,EAAM,IAAI,CAAC,CAAC,EAG5C,EAAM,UACR,EAAK,EAAM,SAAU,EAAS,GAAG,EAAO,GAAG,EAAM,OAAS,EAAM,IAAI,CAExE,CACF,EAEA,EAAK,EAAQ,CAAU,CACzB,CAUA,SAAgB,EACd,EACA,EACA,EACM,CAGN,GAFA,EAA6B,EAAQ,UAAU,EAE3C,IAAe,IAAA,IAAa,CAAC,EAAM,QAAQ,SAAS,CAAU,EAChE,MAAU,MACR,mCAAmC,EAAW,iBAChD,EAGF,EAAe,EAAQ,GAAc,GAAK,GAAa,CACrD,GAAI,EAAM,QAAQ,SAAS,CAAQ,EACjC,MAAU,MAAM,4BAA4B,EAAS,iBAAiB,CAE1E,CAAC,EAED,EAA8B,EAAQ,GAAc,GAAI,UAAU,EAClE,EAA8B,EAAQ,GAAc,GAAI,UAAU,CACpE,CAaA,SAAS,EAAyD,CAChE,cACA,oBACA,SACA,oBACA,oBACA,WACA,iBACA,UAU+B,CAC/B,IAAM,EAAqB,IAAI,IACzB,EAAuB,IAAI,IAEjC,EACE,EACA,EACA,EACA,EACA,EACA,EACA,CACF,EAEA,IAAM,EAAqB,EAAkB,CAAM,EAC7C,CAAE,OAAM,WAAY,EAAY,EAAa,EAAU,CAAc,EAE3E,MAAO,CACL,SACA,oBACA,qBACA,uBACA,OACA,UACA,oBACF,CACF,CAGA,SAAgB,EACd,EACA,EACA,EACA,EAC8B,CAO9B,OAAO,EAAe,CACpB,YAPkB,EAClB,EAAM,YACN,EAAO,IAAK,GAAUI,EAAAA,cAAc,CAAK,CAAC,EAC1C,IAAe,IAAA,GAAY,CAAC,EAAI,EAAW,MAAM,GAAG,CAI1C,EACV,kBAAmB,EACnB,OAAQ,EAAY,EAAM,MAAM,EAChC,kBAAmB,OAAO,OACxB,OAAO,OAAO,IAAI,EAClB,EAAM,iBACR,EACA,kBAAmB,GAAc,GACjC,SAAU,EAAM,SAChB,eAAgB,EAAM,eACtB,QACF,CAAC,CACH,CAGA,SAAgB,EACd,EACA,EACA,EACA,EAC8B,CAC9B,OAAO,EAAe,CACpB,YAAa,EAAO,IAAK,GAAUA,EAAAA,cAAc,CAAK,CAAC,EACvD,kBAAmB,EACnB,OAAQJ,EAAAA,kBAAkB,EAC1B,kBAAmB,OAAO,OAAO,IAAI,EAIrC,kBAAmB,GACnB,WACA,iBACA,QACF,CAAC,CACH,CAUA,SAAS,EACP,EACA,EAIA,EACmD,CACnD,IAAM,EAA8D,CAAC,EAErE,IAAK,GAAM,CAAC,EAAM,KAAY,EAC5B,EAAS,KAAK,CAAC,EAAM,EAAS,EAAQ,EAAS,CAAU,CAAC,CAAC,EAG7D,OAAO,CACT,CAoBA,SAAgB,EACd,EACA,EACsC,CACtC,MAAO,CACL,SAAU,EACR,EAAU,mBACV,EAAK,aACL,aACF,EACA,WAAY,EACV,EAAU,qBACV,EAAK,aACL,eACF,CACF,CACF,CAuBA,SAAgB,EACd,EACA,EACA,EACM,CAEN,IAAM,EAAO,EAAM,UAMb,CAAE,SAAU,EAAkB,WAAY,GAC9C,GAAe,EAAsB,EAAW,CAAI,EAItD,OAAO,OAAO,EAAM,OAAQ,EAAU,MAAM,EAC5C,EAAM,kBAAoB,EAAU,kBACpC,EAAM,KAAO,EAAU,KACvB,EAAM,QAAU,EAAU,QAC1B,EAAM,eAAe,MAAM,EAC3B,EAAM,iBAAiB,MAAM,EAC7B,EAAkB,EAAO,EAAU,kBAAkB,EAGrD,IAAK,GAAM,CAAC,EAAM,EAAS,KAAO,EAChC,EAAK,iBAAiB,EAAM,EAAS,CAAE,EAGzC,IAAK,GAAM,CAAC,EAAM,EAAS,KAAO,EAChC,EAAK,mBAAmB,EAAM,EAAS,CAAE,CAE7C,CAkBA,SAAgB,EACd,EACA,EACA,EACA,EAOA,CACA,GAAM,CACJ,YACA,gBACA,gBACA,eACA,eACA,cACA,iBACE,EAUA,GAAiD,MACnD,EAAA,qBACE,cACA,EACA,EACAK,EAAAA,cAAc,EAAO,CAAI,EACzB,+BACA,4BACF,EAGF,IAAM,EACJ,IAAc,IAAA,GACV,IAAA,GACA,EAAiB,EAAM,EAAW,EAAM,MAAM,EAE9C,EAAmB,EAAoB,EAAO,EAAM,CAAO,EAO3D,EACJ,GAA6C,KACzC,IAAA,GACA,EAAU,oBAAoB,EAAa,aAAa,EACxD,EACJ,GAAiD,KAC7C,IAAA,GACA,EAAU,oBAAoB,EAAe,eAAe,EA2ClE,OArCA,EAAU,sBACR,IAAe,IAAA,GAAY,CAAC,EAAI,CAAC,CAAI,EACrC,IAAiB,IAAA,GAAY,CAAC,EAAI,CAAC,CAAI,EACvC,EACF,EAOI,IAAqB,IAAA,KACnB,OAAO,KAAK,CAAgB,CAAC,CAAC,OAAS,EACzC,EAAM,kBAAkB,GAAQ,EAEhC,OAAO,EAAM,kBAAkB,IAI/B,IAAkB,IAAA,KACpB,EAAM,OAAO,WAAa,EAAc,WACxC,EAAM,OAAO,aAAe,EAAc,aAC1C,EAAkB,EAAO,EAAc,QAAQ,GAGjD,EAAmB,EAAO,EAAM,CAC9B,gBACA,gBACA,eACA,cACF,CAAC,EAID,EAAkB,EAAW,WAAY,EAAM,EAAa,CAAU,EACtE,EAAkB,EAAW,aAAc,EAAM,EAAe,CAAY,EAErE,CACL,YACA,gBACA,gBACA,eACA,cACF,CACF,CAWA,SAAS,EAGP,EACA,EACA,EAKA,CAMA,EAAwB,EAAW,CAAI,EAEvC,IAAM,EAAa,OAAO,OACxB,OAAO,OAAO,IAAI,EAClB,EAAO,UACT,EACM,EAAe,OAAO,OAC1B,OAAO,OAAO,IAAI,EAClB,EAAO,YACT,EAeA,OAbI,IAAc,MAChB,OAAO,EAAW,GAClB,OAAO,EAAa,IACX,OAAO,GAAc,UAC9B,OAAO,EAAa,GACpB,EAAW,GAAQ,IAEnB,OAAO,EAAW,GAClB,EAAa,GAAQ,GAKhB,CAAE,aAAY,eAAc,SAFlB,EAAkB,CAAE,GAAG,EAAQ,YAAW,CAEjB,CAAE,CAC9C,CAwBA,SAAS,EAGP,EACA,EACA,EACqC,CACrC,IAAI,EAQJ,IAAK,IAAM,KAAO,OAAO,KAAK,CAAO,EAAG,CACtC,GAAIF,EAAAA,oBAAoB,IAAI,CAAG,EAC7B,SAGF,IAAM,EAAS,EAAoC,GAG/C,IAAU,IAAA,KAKd,IAAS,CAAE,GAAG,EAAM,kBAAkB,EAAM,EAExC,IAAU,KACZ,OAAO,EAAK,GAEZ,EAAK,GAAO,EAEhB,CAEA,OAAO,CACT,CAMA,SAAS,EACP,EACA,EACA,EACM,CACF,IAAU,IAAA,KAIV,IAAU,KACZ,OAAO,EAAI,GAEX,EAAI,GAAQ,EAEhB,CAUA,SAAS,EAGP,EACA,EACA,EAQM,CAIN,GAHA,EAAkB,EAAM,OAAO,cAAe,EAAM,EAAQ,aAAa,EACzE,EAAkB,EAAM,OAAO,cAAe,EAAM,EAAQ,aAAa,EAErE,EAAQ,eAAiB,IAAA,GAC3B,GAAI,EAAQ,eAAiB,KAC3B,OAAO,EAAM,OAAO,SAAS,OACxB,CACL,IAAM,EAAU,EAAQ,aAExB,EAAM,OAAO,SAAS,GAAS,GAE7B,EAAQ,CAAQ,GAAK,CACzB,CAGF,GAAI,EAAQ,eAAiB,IAAA,GAC3B,GAAI,EAAQ,eAAiB,KAC3B,OAAO,EAAM,OAAO,SAAS,OACxB,CACL,IAAM,EAAU,EAAQ,aAExB,EAAM,OAAO,SAAS,GAAS,GAE7B,EAAQ,CAAQ,GAAK,CACzB,CAEJ,CASA,SAAS,EACP,EACA,EACA,EACA,EACA,EACM,CACF,IAAU,IAAA,KAIV,IAAS,WACP,IAAU,KACZ,EAAU,iBAAiB,EAAM,YAAY,EAE7C,EAAU,eAAe,EAAM,EAAO,GAAM,CAAa,EAElD,IAAU,KACnB,EAAU,mBAAmB,EAAM,YAAY,EAE/C,EAAU,iBAAiB,EAAM,EAAO,GAAM,CAAa,EAE/D,CAMA,SAAgB,EAGd,EACA,EACA,EAC2B,CAe3B,EAA6B,EAAQ,UAAU,EAC/C,EAA8B,EAAQ,GAAI,UAAU,EAEpD,IAAM,EAAY,EAAsB,EAAQ,GAAI,EAAgB,CAAM,EAEpE,EAAmC,CAEvC,IAAI,aAAc,CAChB,OAAOG,EAAAA,uBAAuB,EAAM,IAAI,CAC1C,EACA,OAAQ,EAAU,OAClB,KAAM,EAAU,KAChB,QAAS,EAAU,QACnB,eAAgB,IAAI,IACpB,iBAAkB,IAAI,IACtB,mBAAoB,EAAU,mBAC9B,cAAeL,EAAAA,qBAAqB,EAAU,MAAM,EACpD,kBAAmB,EAAU,kBAC7B,SAAU,GACV,iBACA,UAAW,IAAA,GACX,mBAAoB,IAAA,GACpB,mBAAoB,EAAU,mBAC9B,qBAAsB,EAAU,oBAClC,EAOA,OAFA,EAAA,8BAA8B,EAAM,QAAS,EAAM,OAAQ,UAAU,EAE9D,CACT"}
@@ -1,2 +1,2 @@
1
- const e=require("../../constants.js"),t=require("../../helpers.js"),n=require("../../pipeline/canonicalize.js"),r=require("../../pipeline/buildURL.js"),i=require("../../pipeline/materialize.js");var a=class{#e=void 0;#t=void 0;#n;get(){return this.#e}set(e){this.#t=this.#e,this.#e=e?t.freezeStateInPlace(e):void 0}getPrevious(){return this.#t}reset(){this.#e=void 0,this.#t=void 0}setDependencies(e){this.#n=e}makeState(t,a,o,s){let c=this.#n.port(),l=n.canonicalize(c,t,a??e.EMPTY_PARAMS,o,{resolveForward:!1});return i.materialize(l,{path:s??r.buildURL(l,c)})}areStatesEqual(e,n,r=!0){if(!e||!n)return!!e==!!n;if(e.name!==n.name)return!1;if(r){let r=this.#n.getUrlParams(e.name);for(let i of r)if(!t.areParamValuesEqual(e.params[i],n.params[i]))return!1;return!0}return o(e.params,n.params)&&o(e.search,n.search)}};function o(e,n){let r=Object.keys(e);if(r.length!==Object.keys(n).length)return!1;for(let i of r)if(!(i in n)||!t.areParamValuesEqual(e[i],n[i]))return!1;return!0}exports.StateNamespace=a;
1
+ const e=require("../../constants.js"),t=require("../../helpers.js"),n=require("../../pipeline/canonicalize.js"),r=require("../../pipeline/buildURL.js"),i=require("../../pipeline/materialize.js");var a=class{#e=void 0;#t=void 0;#n;get(){return this.#e}set(e){this.#t=this.#e,this.#e=e?t.freezeStateShell(e):void 0}getPrevious(){return this.#t}reset(){this.#e=void 0,this.#t=void 0}setDependencies(e){this.#n=e}makeState(t,a,o,s){let c=this.#n.port(),l=n.canonicalize(c,t,a??e.EMPTY_PARAMS,o,{resolveForward:!1});return i.materialize(l,{path:s??r.buildURL(l,c)})}areStatesEqual(e,n,r=!0){if(!e||!n)return!!e==!!n;if(e.name!==n.name)return!1;if(r){let r=this.#n.getUrlParams(e.name);for(let i of r)if(!t.areParamValuesEqual(e.params[i],n.params[i]))return!1;return!0}return o(e.params,n.params)&&o(e.search,n.search)}};function o(e,n){let r=Object.keys(e);if(r.length!==Object.keys(n).length)return!1;for(let i of r)if(!(i in n)||!t.areParamValuesEqual(e[i],n[i]))return!1;return!0}exports.StateNamespace=a;
2
2
  //# sourceMappingURL=StateNamespace.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"StateNamespace.js","names":["#frozenState","#previousState","freezeStateInPlace","#deps","canonicalize","EMPTY_PARAMS","materialize","buildURL","areParamValuesEqual"],"sources":["../../../../src/namespaces/StateNamespace/StateNamespace.ts"],"sourcesContent":["// packages/core/src/namespaces/StateNamespace/StateNamespace.ts\n\nimport { EMPTY_PARAMS } from \"../../constants\";\nimport { areParamValuesEqual, freezeStateInPlace } from \"../../helpers\";\nimport { buildURL, canonicalize, materialize } from \"../../pipeline\";\n\nimport type { StateNamespaceDependencies } from \"./types\";\nimport type { Params, SearchParams, State } from \"../../types\";\n\n/**\n * Independent namespace for managing router state storage and creation.\n *\n * Static methods handle validation (called by facade).\n * Instance methods handle state storage, freezing, and creation.\n */\nexport class StateNamespace {\n /**\n * Cached frozen state - avoids structuredClone on every getState() call.\n */\n #frozenState: State | undefined = undefined;\n\n /**\n * Previous state before the last setState call.\n */\n #previousState: State | undefined = undefined;\n\n /**\n * Dependencies injected from Router.\n */\n #deps!: StateNamespaceDependencies;\n\n // =========================================================================\n // Instance methods (trust input - already validated by facade)\n // =========================================================================\n\n /**\n * Returns the current router state.\n *\n * The returned state is deeply frozen (immutable) for safety.\n * Returns `undefined` if the router has not been started or has been stopped.\n */\n get<P extends Params = Params>(): State<P> | undefined {\n return this.#frozenState as State<P> | undefined; // NOSONAR -- generic narrowing needed for public API\n }\n\n /**\n * Sets the current router state.\n *\n * The state is deeply frozen before storage to ensure immutability.\n * The previous state is preserved and accessible via `getPrevious()`.\n *\n * @param state - Already validated by facade, or undefined to clear\n */\n set(state: State | undefined): void {\n // Preserve current state as previous before updating\n this.#previousState = this.#frozenState;\n\n // If state is already frozen (from makeState()), use it directly.\n // For external states, freeze in place without cloning.\n this.#frozenState = state ? freezeStateInPlace(state) : undefined;\n }\n\n /**\n * Returns the previous router state (before the last navigation).\n */\n getPrevious(): State | undefined {\n return this.#previousState;\n }\n\n reset(): void {\n this.#frozenState = undefined;\n this.#previousState = undefined;\n }\n\n // =========================================================================\n // Dependency Injection\n // =========================================================================\n\n /**\n * Sets dependencies for state creation methods.\n * Must be called before using makeState, areStatesEqual, etc.\n */\n setDependencies(deps: StateNamespaceDependencies): void {\n this.#deps = deps;\n }\n\n // =========================================================================\n // State Creation Methods\n // =========================================================================\n\n /**\n * Creates a state object for a route.\n *\n * `params` is frozen at creation so it is always immutable, even when\n * `skipFreeze=true` is passed to defer the outer `Object.freeze(state)` call.\n * This keeps params-freezing invariants independent of transition-pipeline\n * mutation (e.g. `completeTransition` attaching `state.transition`).\n *\n * **The LITERAL form of the pipeline** (nav-pipeline Phase 4). This method\n * used to carry its own copy of stage ③ (merge each channel's route default\n * UNDER the caller's value) and of the mode gate — a second, parallel\n * canonicalisation living outside `src/pipeline`. Two terminals for one rule\n * is not a style problem: #1584's existence precondition landed on the\n * pipeline's terminal and NOT on this one, because it was found by sweeping\n * `canonicalize`'s PORT consumers, and this method read its own dependency\n * bag. It now IS `canonicalize(…, { resolveForward: false })` — the same form\n * `buildPath` and `isActiveRoute`'s literal arm take, which is exactly this\n * method's documented contract: `forwardTo` is not resolved (`makeState(\"src\")`\n * stays on `\"src\"`) but the NAMED route's defaults are applied (forwardState\n * invariants #7/#8).\n *\n * ⚠ The literal form also applies `withholdFilledSlots` (a query default is\n * declined for a `?`-declared slot the caller filled in the PARAMS bag). That\n * is unreachable here rather than new behaviour: the only door to this method\n * is `PluginApi.makeState`, which runs the P1 channel guard first\n * (`getPluginApi.ts` → `throwOnMisChanneledKey`) on the SAME predicate — own\n * key, defined value, `?`-declared — so the bag that would trigger the\n * withholding is refused before it arrives. Verified by a 71-cell before/after\n * snapshot, not by reading.\n *\n * `context` is initialized as a fresh empty object — intentionally NOT frozen\n * so plugins can publish data via `claim.write(state, value)` after creation.\n */\n makeState<P extends Params = Params, S extends SearchParams = SearchParams>(\n name: string,\n params?: P,\n search?: S,\n path?: string,\n ): State<P, S> {\n // Stages ③ + the mode gate, from the ONE implementation (`canonicalize`) —\n // this method no longer carries its own. `resolveForward: false` is the\n // whole difference from `navigate`'s form, and it is this method's contract:\n // the route NAMED is the route answered about.\n const port = this.#deps.port();\n const canonical = canonicalize(port, name, params ?? EMPTY_PARAMS, search, {\n resolveForward: false,\n });\n\n // ⑤a only when the caller did not supply the URL. `buildURL` prints through\n // `port.buildPath` — the interceptable `ctx.buildPath` this method already\n // used — so the interceptor zone is unchanged, and the URL is built from the\n // SAME canonical intent the state is materialised from, which is what keeps\n // `state.path` in step with `state.search` for a caller that passes no path\n // (`canNavigateTo`, `isActiveRoute`).\n //\n // ⚠ No `skipFreeze` arm: the parameter died when Phase 2 moved the two\n // callers that used it (`canNavigateTo`, `isActiveRoute`) onto\n // `materialize({ skipFreeze: true })` directly, and the old body hid the\n // death because it forwarded `undefined` into a slot that needs no branch.\n // The public `PluginApi.makeState` type has four parameters and both call\n // sites pass four; unfreezing a state is the transition pipeline's business,\n // reached through `materialize`, not through this primitive. Coverage is\n // what surfaced it — the same way it caught `deps.makeState` and\n // `paramsMatchExcluding` when Phase 2 migrated their last consumers.\n return materialize<P, S>(canonical, {\n path: path ?? buildURL(canonical, port),\n });\n }\n\n // =========================================================================\n // State Comparison Methods\n // =========================================================================\n\n /**\n * Compares two states for equality.\n * By default, ignores query params (only compares URL params).\n */\n areStatesEqual(\n state1: State | undefined,\n state2: State | undefined,\n ignoreQueryParams = true,\n ): boolean {\n if (!state1 || !state2) {\n return !!state1 === !!state2;\n }\n\n if (state1.name !== state2.name) {\n return false;\n }\n\n if (ignoreQueryParams) {\n // URL (path) param names are cached at the routes layer and invalidated\n // on every tree mutation, so this stays correct after replace() (#723).\n const urlParams = this.#deps.getUrlParams(state1.name);\n\n for (const urlParam of urlParams) {\n if (\n !areParamValuesEqual(state1.params[urlParam], state2.params[urlParam])\n ) {\n return false;\n }\n }\n\n return true;\n }\n\n // Compare BOTH channels — path params and query (search). Query moved out\n // of `params` into `search` in M2 (#1548), so a full comparison must check\n // both. `search` is always present (makeState fills EMPTY_SEARCH).\n return (\n recordsShallowEqual(state1.params, state2.params) &&\n recordsShallowEqual(state1.search, state2.search)\n );\n }\n}\n\n/**\n * Shallow key/value equality of two param-like records (path params or query),\n * using {@link areParamValuesEqual} per key so array values compare by content.\n */\nfunction recordsShallowEqual(\n left: Readonly<Record<string, unknown>>,\n right: Readonly<Record<string, unknown>>,\n): boolean {\n const leftKeys = Object.keys(left);\n\n if (leftKeys.length !== Object.keys(right).length) {\n return false;\n }\n\n for (const key of leftKeys) {\n if (!(key in right) || !areParamValuesEqual(left[key], right[key])) {\n return false;\n }\n }\n\n return true;\n}\n"],"mappings":"mMAeA,IAAa,EAAb,KAA4B,CAI1B,GAAkC,IAAA,GAKlC,GAAoC,IAAA,GAKpC,GAYA,KAAuD,CACrD,OAAO,KAAKA,EACd,CAUA,IAAI,EAAgC,CAElC,KAAKC,GAAiB,KAAKD,GAI3B,KAAKA,GAAe,EAAQE,EAAAA,mBAAmB,CAAK,EAAI,IAAA,EAC1D,CAKA,aAAiC,CAC/B,OAAO,KAAKD,EACd,CAEA,OAAc,CACZ,KAAKD,GAAe,IAAA,GACpB,KAAKC,GAAiB,IAAA,EACxB,CAUA,gBAAgB,EAAwC,CACtD,KAAKE,GAAQ,CACf,CAuCA,UACE,EACA,EACA,EACA,EACa,CAKb,IAAM,EAAO,KAAKA,GAAM,KAAK,EACvB,EAAYC,EAAAA,aAAa,EAAM,EAAM,GAAUC,EAAAA,aAAc,EAAQ,CACzE,eAAgB,EAClB,CAAC,EAkBD,OAAOC,EAAAA,YAAkB,EAAW,CAClC,KAAM,GAAQC,EAAAA,SAAS,EAAW,CAAI,CACxC,CAAC,CACH,CAUA,eACE,EACA,EACA,EAAoB,GACX,CACT,GAAI,CAAC,GAAU,CAAC,EACd,MAAO,CAAC,CAAC,GAAW,CAAC,CAAC,EAGxB,GAAI,EAAO,OAAS,EAAO,KACzB,MAAO,GAGT,GAAI,EAAmB,CAGrB,IAAM,EAAY,KAAKJ,GAAM,aAAa,EAAO,IAAI,EAErD,IAAK,IAAM,KAAY,EACrB,GACE,CAACK,EAAAA,oBAAoB,EAAO,OAAO,GAAW,EAAO,OAAO,EAAS,EAErE,MAAO,GAIX,MAAO,EACT,CAKA,OACE,EAAoB,EAAO,OAAQ,EAAO,MAAM,GAChD,EAAoB,EAAO,OAAQ,EAAO,MAAM,CAEpD,CACF,EAMA,SAAS,EACP,EACA,EACS,CACT,IAAM,EAAW,OAAO,KAAK,CAAI,EAEjC,GAAI,EAAS,SAAW,OAAO,KAAK,CAAK,CAAC,CAAC,OACzC,MAAO,GAGT,IAAK,IAAM,KAAO,EAChB,GAAI,EAAE,KAAO,IAAU,CAACA,EAAAA,oBAAoB,EAAK,GAAM,EAAM,EAAI,EAC/D,MAAO,GAIX,MAAO,EACT"}
1
+ {"version":3,"file":"StateNamespace.js","names":["#frozenState","#previousState","freezeStateShell","#deps","canonicalize","EMPTY_PARAMS","materialize","buildURL","areParamValuesEqual"],"sources":["../../../../src/namespaces/StateNamespace/StateNamespace.ts"],"sourcesContent":["// packages/core/src/namespaces/StateNamespace/StateNamespace.ts\n\nimport { EMPTY_PARAMS } from \"../../constants\";\nimport { areParamValuesEqual, freezeStateShell } from \"../../helpers\";\nimport { buildURL, canonicalize, materialize } from \"../../pipeline\";\n\nimport type { StateNamespaceDependencies } from \"./types\";\nimport type { Params, SearchParams, State } from \"../../types\";\n\n/**\n * Independent namespace for managing router state storage and creation.\n *\n * Static methods handle validation (called by facade).\n * Instance methods handle state storage, freezing, and creation.\n */\nexport class StateNamespace {\n /**\n * Cached frozen state - avoids structuredClone on every getState() call.\n */\n #frozenState: State | undefined = undefined;\n\n /**\n * Previous state before the last setState call.\n */\n #previousState: State | undefined = undefined;\n\n /**\n * Dependencies injected from Router.\n */\n #deps!: StateNamespaceDependencies;\n\n // =========================================================================\n // Instance methods (trust input - already validated by facade)\n // =========================================================================\n\n /**\n * Returns the current router state.\n *\n * The returned state is deeply frozen (immutable) for safety.\n * Returns `undefined` if the router has not been started or has been stopped.\n */\n get<P extends Params = Params>(): State<P> | undefined {\n return this.#frozenState as State<P> | undefined; // NOSONAR -- generic narrowing needed for public API\n }\n\n /**\n * Sets the current router state.\n *\n * The state is deeply frozen before storage to ensure immutability.\n * The previous state is preserved and accessible via `getPrevious()`.\n *\n * @param state - Already validated by facade, or undefined to clear\n */\n set(state: State | undefined): void {\n // Preserve current state as previous before updating\n this.#previousState = this.#frozenState;\n\n // If state is already frozen (from makeState()), use it directly.\n // For external states, freeze in place without cloning.\n this.#frozenState = state ? freezeStateShell(state) : undefined;\n }\n\n /**\n * Returns the previous router state (before the last navigation).\n */\n getPrevious(): State | undefined {\n return this.#previousState;\n }\n\n reset(): void {\n this.#frozenState = undefined;\n this.#previousState = undefined;\n }\n\n // =========================================================================\n // Dependency Injection\n // =========================================================================\n\n /**\n * Sets dependencies for state creation methods.\n * Must be called before using makeState, areStatesEqual, etc.\n */\n setDependencies(deps: StateNamespaceDependencies): void {\n this.#deps = deps;\n }\n\n // =========================================================================\n // State Creation Methods\n // =========================================================================\n\n /**\n * Creates a state object for a route.\n *\n * `params` is frozen at creation so it is always immutable, even when\n * `skipFreeze=true` is passed to defer the outer `Object.freeze(state)` call.\n * This keeps params-freezing invariants independent of transition-pipeline\n * mutation (e.g. `completeTransition` attaching `state.transition`).\n *\n * **The LITERAL form of the pipeline** (nav-pipeline Phase 4). This method\n * used to carry its own copy of stage ③ (merge each channel's route default\n * UNDER the caller's value) and of the mode gate — a second, parallel\n * canonicalisation living outside `src/pipeline`. Two terminals for one rule\n * is not a style problem: #1584's existence precondition landed on the\n * pipeline's terminal and NOT on this one, because it was found by sweeping\n * `canonicalize`'s PORT consumers, and this method read its own dependency\n * bag. It now IS `canonicalize(…, { resolveForward: false })` — the same form\n * `buildPath` and `isActiveRoute`'s literal arm take, which is exactly this\n * method's documented contract: `forwardTo` is not resolved (`makeState(\"src\")`\n * stays on `\"src\"`) but the NAMED route's defaults are applied (forwardState\n * invariants #7/#8).\n *\n * ⚠ The literal form also applies `withholdFilledSlots` (a query default is\n * declined for a `?`-declared slot the caller filled in the PARAMS bag). That\n * is unreachable here rather than new behaviour: the only door to this method\n * is `PluginApi.makeState`, which runs the P1 channel guard first\n * (`getPluginApi.ts` → `throwOnMisChanneledKey`) on the SAME predicate — own\n * key, defined value, `?`-declared — so the bag that would trigger the\n * withholding is refused before it arrives. Verified by a 71-cell before/after\n * snapshot, not by reading.\n *\n * `context` is initialized as a fresh empty object — intentionally NOT frozen\n * so plugins can publish data via `claim.write(state, value)` after creation.\n */\n makeState<P extends Params = Params, S extends SearchParams = SearchParams>(\n name: string,\n params?: P,\n search?: S,\n path?: string,\n ): State<P, S> {\n // Stages ③ + the mode gate, from the ONE implementation (`canonicalize`) —\n // this method no longer carries its own. `resolveForward: false` is the\n // whole difference from `navigate`'s form, and it is this method's contract:\n // the route NAMED is the route answered about.\n const port = this.#deps.port();\n const canonical = canonicalize(port, name, params ?? EMPTY_PARAMS, search, {\n resolveForward: false,\n });\n\n // ⑤a only when the caller did not supply the URL. `buildURL` prints through\n // `port.buildPath` — the interceptable `ctx.buildPath` this method already\n // used — so the interceptor zone is unchanged, and the URL is built from the\n // SAME canonical intent the state is materialised from, which is what keeps\n // `state.path` in step with `state.search` for a caller that passes no path\n // (`canNavigateTo`, `isActiveRoute`).\n //\n // ⚠ No `skipFreeze` arm: the parameter died when Phase 2 moved the two\n // callers that used it (`canNavigateTo`, `isActiveRoute`) onto\n // `materialize({ skipFreeze: true })` directly, and the old body hid the\n // death because it forwarded `undefined` into a slot that needs no branch.\n // The public `PluginApi.makeState` type has four parameters and both call\n // sites pass four; unfreezing a state is the transition pipeline's business,\n // reached through `materialize`, not through this primitive. Coverage is\n // what surfaced it — the same way it caught `deps.makeState` and\n // `paramsMatchExcluding` when Phase 2 migrated their last consumers.\n return materialize<P, S>(canonical, {\n path: path ?? buildURL(canonical, port),\n });\n }\n\n // =========================================================================\n // State Comparison Methods\n // =========================================================================\n\n /**\n * Compares two states for equality.\n * By default, ignores query params (only compares URL params).\n */\n areStatesEqual(\n state1: State | undefined,\n state2: State | undefined,\n ignoreQueryParams = true,\n ): boolean {\n if (!state1 || !state2) {\n return !!state1 === !!state2;\n }\n\n if (state1.name !== state2.name) {\n return false;\n }\n\n if (ignoreQueryParams) {\n // URL (path) param names are cached at the routes layer and invalidated\n // on every tree mutation, so this stays correct after replace() (#723).\n const urlParams = this.#deps.getUrlParams(state1.name);\n\n for (const urlParam of urlParams) {\n if (\n !areParamValuesEqual(state1.params[urlParam], state2.params[urlParam])\n ) {\n return false;\n }\n }\n\n return true;\n }\n\n // Compare BOTH channels — path params and query (search). Query moved out\n // of `params` into `search` in M2 (#1548), so a full comparison must check\n // both. `search` is always present (makeState fills EMPTY_SEARCH).\n return (\n recordsShallowEqual(state1.params, state2.params) &&\n recordsShallowEqual(state1.search, state2.search)\n );\n }\n}\n\n/**\n * Shallow key/value equality of two param-like records (path params or query),\n * using {@link areParamValuesEqual} per key so array values compare by content.\n */\nfunction recordsShallowEqual(\n left: Readonly<Record<string, unknown>>,\n right: Readonly<Record<string, unknown>>,\n): boolean {\n const leftKeys = Object.keys(left);\n\n if (leftKeys.length !== Object.keys(right).length) {\n return false;\n }\n\n for (const key of leftKeys) {\n if (!(key in right) || !areParamValuesEqual(left[key], right[key])) {\n return false;\n }\n }\n\n return true;\n}\n"],"mappings":"mMAeA,IAAa,EAAb,KAA4B,CAI1B,GAAkC,IAAA,GAKlC,GAAoC,IAAA,GAKpC,GAYA,KAAuD,CACrD,OAAO,KAAKA,EACd,CAUA,IAAI,EAAgC,CAElC,KAAKC,GAAiB,KAAKD,GAI3B,KAAKA,GAAe,EAAQE,EAAAA,iBAAiB,CAAK,EAAI,IAAA,EACxD,CAKA,aAAiC,CAC/B,OAAO,KAAKD,EACd,CAEA,OAAc,CACZ,KAAKD,GAAe,IAAA,GACpB,KAAKC,GAAiB,IAAA,EACxB,CAUA,gBAAgB,EAAwC,CACtD,KAAKE,GAAQ,CACf,CAuCA,UACE,EACA,EACA,EACA,EACa,CAKb,IAAM,EAAO,KAAKA,GAAM,KAAK,EACvB,EAAYC,EAAAA,aAAa,EAAM,EAAM,GAAUC,EAAAA,aAAc,EAAQ,CACzE,eAAgB,EAClB,CAAC,EAkBD,OAAOC,EAAAA,YAAkB,EAAW,CAClC,KAAM,GAAQC,EAAAA,SAAS,EAAW,CAAI,CACxC,CAAC,CACH,CAUA,eACE,EACA,EACA,EAAoB,GACX,CACT,GAAI,CAAC,GAAU,CAAC,EACd,MAAO,CAAC,CAAC,GAAW,CAAC,CAAC,EAGxB,GAAI,EAAO,OAAS,EAAO,KACzB,MAAO,GAGT,GAAI,EAAmB,CAGrB,IAAM,EAAY,KAAKJ,GAAM,aAAa,EAAO,IAAI,EAErD,IAAK,IAAM,KAAY,EACrB,GACE,CAACK,EAAAA,oBAAoB,EAAO,OAAO,GAAW,EAAO,OAAO,EAAS,EAErE,MAAO,GAIX,MAAO,EACT,CAKA,OACE,EAAoB,EAAO,OAAQ,EAAO,MAAM,GAChD,EAAoB,EAAO,OAAQ,EAAO,MAAM,CAEpD,CACF,EAMA,SAAS,EACP,EACA,EACS,CACT,IAAM,EAAW,OAAO,KAAK,CAAI,EAEjC,GAAI,EAAS,SAAW,OAAO,KAAK,CAAK,CAAC,CAAC,OACzC,MAAO,GAGT,IAAK,IAAM,KAAO,EAChB,GAAI,EAAE,KAAO,IAAU,CAACA,EAAAA,oBAAoB,EAAK,GAAM,EAAM,EAAI,EAC/D,MAAO,GAIX,MAAO,EACT"}
@@ -1,2 +1,2 @@
1
- const e=require("../channels/defaults.js"),t=require("../constants.js"),n=require("../channels/modeGate.js"),r=require("../helpers.js");function i(e,t,n,r,i){let a=e.pathNames(t);if(a!==void 0)for(let e of Object.keys(n))!r.includes(e)&&!a.includes(e)&&i(t,e)}function a(a,o,s,c,l){let u=l?.resolveForward===!1?{name:o,params:s,search:c}:a.resolveForward(o,s,c),d=u.name,f=r.normalizeParams(u.params),p=a.queryNames(d),m=l?.diagnoseUndeclared===!0?a.reportUndeclaredParamKey:void 0;m&&i(a,d,f,p,m);let h=a.defaultParams(d),g=a.defaultSearch(d);if(h===void 0&&g===void 0&&p.length===0&&(u.search===void 0||u.search===t.EMPTY_SEARCH))return{name:d,path:Object.freeze(f),query:t.EMPTY_SEARCH};let _=a.reportDroppedQueryKey,v=r.mergeWithDefault(l?.resolveForward===!1?e.withholdFilledSlots(g,f,p):g,u.search,t.EMPTY_SEARCH);return{name:d,path:r.mergeWithDefault(h,f,t.EMPTY_PARAMS,!0),query:a.admitsUndeclaredQuery()?v:n.admittedSearch(v,p,e=>{_!==void 0&&a.pathNames(d)!==void 0&&_(d,e)})}}exports.canonicalize=a;
1
+ const e=require("../channels/defaults.js"),t=require("../constants.js"),n=require("../channels/modeGate.js"),r=require("../helpers.js");function i(e,t,n,r,i){let a=e.pathNames(t);if(a!==void 0)for(let e of Object.keys(n))!r.includes(e)&&!a.includes(e)&&i(t,e)}function a(a,o,s,c,l){let u=l?.resolveForward===!1?{name:o,params:s,search:c}:a.resolveForward(o,s,c),d=u.name,f=r.normalizeParams(u.params),p=l?.diagnoseUndeclared===!0?a.reportUndeclaredParamKey:void 0;p&&i(a,d,f,a.queryNames(d),p);let m=a.defaultParams(d),h=a.defaultSearch(d);if((u.search===void 0||u.search===t.EMPTY_SEARCH)&&m===void 0&&h===void 0)return{name:d,path:f,query:t.EMPTY_SEARCH};let g=a.queryNames(d),_=a.reportDroppedQueryKey,v=r.mergeWithDefault(l?.resolveForward===!1?e.withholdFilledSlots(h,f,g):h,u.search,t.EMPTY_SEARCH);return{name:d,path:r.mergeWithDefault(m,f,t.EMPTY_PARAMS,!0),query:a.admitsUndeclaredQuery()?v:n.admittedSearch(v,g,e=>{_!==void 0&&a.pathNames(d)!==void 0&&_(d,e)})}}exports.canonicalize=a;
2
2
  //# sourceMappingURL=canonicalize.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"canonicalize.js","names":["normalizeParams","EMPTY_SEARCH","mergeWithDefault","withholdFilledSlots","EMPTY_PARAMS","admittedSearch"],"sources":["../../../src/pipeline/canonicalize.ts"],"sourcesContent":["// packages/core/src/pipeline/canonicalize.ts\n\nimport { admittedSearch, withholdFilledSlots } from \"../channels\";\nimport { EMPTY_PARAMS, EMPTY_SEARCH } from \"../constants\";\nimport { mergeWithDefault, normalizeParams } from \"../helpers\";\n\nimport type { RouteResolver } from \"./port\";\nimport type { Canonical } from \"./types\";\nimport type { Params, SearchParams } from \"../types\";\n\n/**\n * Options for {@link canonicalize}. Both flags are opt-in, and both are read as\n * a ROLE rather than inferred from the shape of the call — the reasons differ\n * per flag and are recorded on each.\n */\nexport interface CanonicalizeOptions {\n /**\n * Run stage ① (`forwardTo` resolution through the interceptor seam)?\n *\n * Defaults to `true`. `false` is the LITERAL form: the intent canonicalises\n * against the route the caller NAMED, without following its `forwardTo` chain\n * and without entering the seam. The entry points that ask a question about a\n * literal route rather than producing a destination take it — `buildPath`\n * (A.5: `buildPath(\"src\")` stays `/src`, deliberately asymmetric with\n * `navigate`), `isActiveRoute`'s literal arm, and `makeState`.\n *\n * ⚠ The literal form also skips the seam's channel CHECK. The seam does not\n * SEPARATE channels — stage ② was deleted (`ba0f6b18b`), so the resolving form\n * REFUSES a mis-channelled bag while the literal form simply does not look.\n * Either way nothing is moved: a caller who rides a declared query key in the\n * `params` bag keeps it there, and the URL build prints from the query channel\n * alone. That is what makes channel-correctness the producer's contract.\n */\n resolveForward?: boolean;\n\n /**\n * Run the opt-in undeclared-key diagnostic (#1579)? Defaults to `false`.\n *\n * Explicit rather than inferred from `resolveForward`, and the difference is\n * not cosmetic: `canNavigateTo` DOES resolve `forwardTo` (so it shares the\n * form with `navigate`) yet is a PREDICATE that runs on every `<Link>` render.\n * Keying the diagnostic on the form warned from it — measured, not reasoned —\n * which is exactly the per-render flood the channel guard avoids by not\n * instrumenting predicates at all (RFC rev. 29 §5). Only the points that\n * COMMIT or hand back a state a developer will keep opt in.\n */\n diagnoseUndeclared?: boolean;\n}\n\n/**\n * The undeclared-key diagnostic (#1579 — the params half of #1553), lifted out of\n * {@link canonicalize} so the fast path (#1589) fits the cognitive-complexity\n * budget beside it.\n *\n * BOTH diagnostics presuppose that the route EXISTS — they answer \"does route X\n * declare this key?\", and for a route that is not a route the honest answer is\n * that the question does not apply (#1584). `queryNames` / `pathNames` answer\n * `[]` for a real route with no declarations, so `[]` cannot say \"no such route\";\n * `pathNames` carries the `undefined` arm that can. Reporting a nonexistent route\n * blamed the params for a typo in the ROUTE name — the most misleading direction\n * available — and burnt a de-dup slot per key, silencing the genuine warning if\n * that name later became real. The committing producers still refuse the\n * navigation on their own (`undefined` from `buildNavigationState`,\n * `ROUTE_NOT_FOUND` from `navigate`); only the diagnostic was wrong.\n *\n * Called only when the sink is present, so bare core never reaches the\n * `pathNames` lookup or the bag walk.\n */\nfunction diagnoseUndeclaredKeys(\n port: RouteResolver,\n resolvedName: string,\n pathBag: Params,\n declaredQuery: readonly string[],\n report: (routeName: string, key: string) => void,\n): void {\n const declaredPath = port.pathNames(resolvedName);\n\n if (declaredPath === undefined) {\n return;\n }\n\n for (const key of Object.keys(pathBag)) {\n if (!declaredQuery.includes(key) && !declaredPath.includes(key)) {\n report(resolvedName, key);\n }\n }\n}\n\n/**\n * THE single producer of {@link Canonical}: one pass over stage ① (resolve the\n * `forwardTo` chain) and stage ③ (merge each channel's route default UNDER the\n * caller's value). There is no separating stage ② — channels arrive correct by\n * the producer contract, and the port's `resolveForward` is wired to the seam\n * that REFUSES a mis-channelled bag rather than repairing one.\n *\n * Ordering is forced by the data, not by discipline: ③ needs the RESOLVED name\n * (target defaults cannot be read before `forwardTo` resolves), so ① always\n * precedes it.\n *\n * `undefined` is absence on both sides of the merge (`mergeWithDefault`,\n * #1550/#1551) — an explicitly-`undefined` caller value leaves the default in\n * place, and a default carrying `undefined` behaves like no entry.\n *\n * Channels are frozen here, at merge time — NOT in `materialize`. The two\n * freezes are different things: `materialize`'s `skipFreeze` governs the state\n * object (the navigate path defers it so `completeTransition` can attach\n * `transition`), while `params` / `search` must be immutable the moment a guard\n * can see them. `mergeWithDefault` also copies before freezing, so the caller's\n * own bag is never frozen out from under it.\n *\n * ⚠ The option bags at the call sites are INLINE LITERALS on purpose (#1589).\n * Hoisting them to shared frozen module constants was tried and measured worse:\n * `buildPath` and `isActiveRoute` slowed 6.6–10.5 % while sites that pass no\n * options moved 2 %. The literal in a small hot function is not an allocation at\n * all — V8 inlines the function, escape analysis removes the object, and the flag\n * folds to a constant. A shared frozen object replaces that with a property read\n * off the heap. Do not \"optimise\" these back.\n */\nexport function canonicalize(\n port: RouteResolver,\n name: string,\n params: Params,\n search?: SearchParams,\n opts?: CanonicalizeOptions,\n): Canonical {\n // ① — forwardTo resolution + source-route default layering, through the\n // interceptor seam (plugins inject here). The literal form skips it entirely:\n // no chain, no seam, no channel check — the caller's bags stand as given.\n const forwarded =\n opts?.resolveForward === false\n ? { name, params, search }\n : port.resolveForward(name, params, search);\n const resolvedName = forwarded.name;\n\n // Path-channel entry guard: drops `undefined`-valued keys and collapses an\n // empty bag onto the EMPTY_PARAMS singleton (#1027), so the zero-params hot\n // path allocates nothing downstream.\n const pathBag = normalizeParams(forwarded.params);\n\n // Read ONCE. Three consumers below need the route's `?`-declared names — the\n // diagnostic, the default split, and the mode gate — and `getQueryParams` is a\n // cached lookup, not a free one; a local also makes it impossible for the three\n // to disagree about which registry decided the channel (#1556).\n const declaredQuery = port.queryNames(resolvedName);\n\n // The undeclared-key diagnostic (#1579 — the params half of #1553). A key the\n // route declares NOWHERE stays in `state.params` as app-level data, which is\n // correct and documented — but it never reaches the URL, so the state does not\n // round-trip through its own `state.path`. Core does not change that; it only\n // offers to SAY it.\n //\n // Three things keep this from costing anything it should not:\n // - gated on the sink being present, so bare core pays one `undefined` check\n // and never walks the bag (the port member is a GETTER that returns\n // `undefined` until `validation-plugin` installs the validator — a plain\n // closure would always be truthy and the gate would be decorative);\n // - opted in EXPLICITLY by the committing producers, so every predicate stays\n // silent — including `canNavigateTo`, which resolves `forwardTo` and would\n // therefore be caught by a form-based test while still running on every\n // `<Link>` render (measured: it warned before the flag was made explicit);\n // - read from the CALLER's bag, before route defaults are merged in, so a\n // deliberate arbitrary `defaultParams` entry is not reported as a mistake.\n const reportUndeclared =\n opts?.diagnoseUndeclared === true\n ? port.reportUndeclaredParamKey\n : undefined;\n\n if (reportUndeclared) {\n diagnoseUndeclaredKeys(\n port,\n resolvedName,\n pathBag,\n declaredQuery,\n reportUndeclared,\n );\n }\n\n // The route's OWN defaults. Each slot IS its channel — no split (#1549 routed\n // them by the route's declaration for one release; `ba0f6b18b` retired that\n // along with the rest of stage ②). `defaultParams` is the path channel,\n // `defaultSearch` the query channel, and the router never moves a key between\n // them: the two meet only when the URL is printed. A `defaultParams` naming a\n // `?`-declared key is refused at REGISTRATION (`assertRouteDefaultChannels`),\n // so nothing mis-channelled can reach this merge and there is nothing here to\n // repair. Splitting here used to be what made a config the router itself had\n // accepted survive its own always-on channel guard.\n // Two locals, not an object literal: this used to allocate a `{ params, search }`\n // bag on EVERY call to read two fields twice (#1589).\n const defaultPath = port.defaultParams(resolvedName);\n const defaultQuery = port.defaultSearch(resolvedName);\n\n // FAST PATH (#1589): nothing to merge and nothing to gate. A route with no\n // defaults on either slot and no `?`-declaration cannot have a default applied,\n // cannot have a slot withheld, and cannot have a key dropped by the mode gate —\n // whatever the mode, `admittedSearch` would keep every key of a query channel\n // that is empty anyway. So the whole tail below is provably identity, and this\n // returns without it.\n //\n // This is the `buildPath/warm-static` case, and it was the most diagnostic\n // number in the regression: a static route — no params, no query, no defaults —\n // paid the full pass and came out 2.6x slower than before the pipeline.\n //\n // ⚠ The channels are still FROZEN here (canonicalize invariant #4): `pathBag`\n // is `normalizeParams`' own fresh object, so it is frozen in place, and\n // `EMPTY_SEARCH` is the shared frozen singleton.\n //\n // ⚠ The query test accepts the EMPTY_SEARCH singleton as well as `undefined`,\n // and that is not cosmetic: `isActiveRoute` and the `forwardState` seam both\n // hand down the singleton rather than nothing, so a test for `undefined` alone\n // left the two render-path predicates — the whole point of the exercise — on\n // the slow path. A fresh `{}` is deliberately NOT accepted: telling an empty\n // literal from a non-empty one costs a key walk, and the two call sites that\n // used to mint one now pass the singleton instead.\n if (\n defaultPath === undefined &&\n defaultQuery === undefined &&\n declaredQuery.length === 0 &&\n (forwarded.search === undefined || forwarded.search === EMPTY_SEARCH)\n ) {\n // Annotated rather than asserted: the literal's inferred `query` type is the\n // empty singleton's `Record<string, never>`, too narrow for `Canonical` to\n // overlap, and an inline `as SearchParams` is redundant to the receiver.\n const fastPath: { name: string; path: Params; query: SearchParams } = {\n name: resolvedName,\n path: Object.freeze(pathBag),\n query: EMPTY_SEARCH,\n };\n\n return fastPath as Canonical;\n }\n\n // ③ — route defaults UNDER the routed value, each channel independent. Read\n // per channel (not as one `{ params, search }` bag from a combined `defaults()`\n // accessor) so the merge itself allocates nothing on the zero-defaults hot\n // path — the `Canonical` literal below is this function's only allocation.\n // (The pipeline's second one is `materialize`'s options bag, at the call site:\n // two object literals per navigation over the pre-pipeline form.)\n // In the LITERAL form no seam runs, so nothing has enforced #1570's rule that\n // a default is never applied to a slot the caller already filled — in EITHER\n // bag. Apply it here: the query default and a caller's params-twin land in\n // DIFFERENT channels, where no merge ranks them, and the default would win by\n // construction. `buildPath(\"x\", { page: \"9\" })` on `defaultSearch { page: \"5\" }`\n // would print `?page=5` — the caller's value silently replaced by the default,\n // which is the §1.1 inversion this whole split exists to remove. Nothing is\n // rerouted: the caller's key stays in the bag they chose (and, being in the\n // path channel, is simply not printed — that IS the single-bag retirement),\n // only the default is withheld.\n // ⚠ Scoped to `declaredQuery`, and the scope is load-bearing: only a DECLARED\n // query name can HAVE a params-bag twin. Withholding on a key the route\n // declares nowhere (`/u` + `defaultSearch { theme }`), or on one that owns a\n // path slot beside its query twin (`/items/:id?id`, the #843/#1549 carve-out),\n // takes a default no caller was competing for — and left `buildPath` the only\n // producer out of agreement, printing an href this very route's `matchPath`\n // rewrote on the spot. That is the #1552/#1578 class, re-opened.\n // The mode gate's sink, resolved ONCE — the read IS the gate. The router\n // implements this member as a GETTER returning `undefined` while no validator\n // is installed (`wiring/wireNamespaces.ts`), so in bare core `dropSink` is\n // genuinely absent and the drop path below skips the `pathNames` existence\n // lookup entirely. Hoisting also keeps the getter from being re-invoked per\n // dropped key. (It used to be wired as a plain closure — always truthy — so\n // the check read as taken and bare core paid that lookup with no sink behind\n // it; both sinks report their absence honestly now.)\n //\n // Read BELOW the fast path (#1589): a route with nothing to gate cannot drop a\n // key, so it has no use for the sink and should not pay the getter.\n const dropSink = port.reportDroppedQueryKey;\n\n const queryDefaults =\n opts?.resolveForward === false\n ? withholdFilledSlots(defaultQuery, pathBag, declaredQuery)\n : defaultQuery;\n\n const query = mergeWithDefault(queryDefaults, forwarded.search, EMPTY_SEARCH);\n\n return {\n name: resolvedName,\n // `valueIsOwned` (#1589): `pathBag` is `normalizeParams`' own fresh object —\n // never its input — so the merge freezes it in place instead of copying a bag\n // that was already copied one line above. Only the PATH channel may say this;\n // `forwarded.search` above comes from the caller or the seam.\n path: mergeWithDefault(defaultPath, pathBag, EMPTY_PARAMS, true),\n // The mode gate (#1575), applied AFTER the default merge so a `defaultSearch`\n // for an undeclared key is dropped with it — under `default`/`strict` that\n // config is dead by the same rule, not a back door around it. Runs on the\n // merged bag rather than the caller's, because that is the bag ⑤a prints\n // from, and the invariant is about those two agreeing.\n query: port.admitsUndeclaredQuery()\n ? query\n : admittedSearch(query as SearchParams, declaredQuery, (key) => {\n // Same existence precondition as the params-bag diagnostic above\n // (#1584): the DROP is always-on and correct either way, but saying\n // \"key `q` is not declared on route `nope`\" about a route that does\n // not exist blames the query for a route-name typo. Found by sweeping\n // this file's port consumers after fixing the sibling — the two\n // diagnostics read the same `[]`-means-nothing answer.\n // The sink is checked FIRST: it is the cheap half, and it is the one\n // that is absent in bare core, so the `pathNames` lookup stays off the\n // path of a router with no validator installed.\n if (\n dropSink !== undefined &&\n port.pathNames(resolvedName) !== undefined\n ) {\n dropSink(resolvedName, key);\n }\n }),\n // The one and only cast to the brand in the codebase — reviewed once, here.\n } as Canonical;\n}\n"],"mappings":"wIAoEA,SAAS,EACP,EACA,EACA,EACA,EACA,EACM,CACN,IAAM,EAAe,EAAK,UAAU,CAAY,EAE5C,OAAiB,IAAA,GAIrB,IAAK,IAAM,KAAO,OAAO,KAAK,CAAO,EAC/B,CAAC,EAAc,SAAS,CAAG,GAAK,CAAC,EAAa,SAAS,CAAG,GAC5D,EAAO,EAAc,CAAG,CAG9B,CAgCA,SAAgB,EACd,EACA,EACA,EACA,EACA,EACW,CAIX,IAAM,EACJ,GAAM,iBAAmB,GACrB,CAAE,OAAM,SAAQ,QAAO,EACvB,EAAK,eAAe,EAAM,EAAQ,CAAM,EACxC,EAAe,EAAU,KAKzB,EAAUA,EAAAA,gBAAgB,EAAU,MAAM,EAM1C,EAAgB,EAAK,WAAW,CAAY,EAmB5C,EACJ,GAAM,qBAAuB,GACzB,EAAK,yBACL,IAAA,GAEF,GACF,EACE,EACA,EACA,EACA,EACA,CACF,EAcF,IAAM,EAAc,EAAK,cAAc,CAAY,EAC7C,EAAe,EAAK,cAAc,CAAY,EAwBpD,GACE,IAAgB,IAAA,IAChB,IAAiB,IAAA,IACjB,EAAc,SAAW,IACxB,EAAU,SAAW,IAAA,IAAa,EAAU,SAAWC,EAAAA,cAWxD,MAAO,CALL,KAAM,EACN,KAAM,OAAO,OAAO,CAAO,EAC3B,MAAOA,EAAAA,YAGK,EAqChB,IAAM,EAAW,EAAK,sBAOhB,EAAQC,EAAAA,iBAJZ,GAAM,iBAAmB,GACrBC,EAAAA,oBAAoB,EAAc,EAAS,CAAa,EACxD,EAEwC,EAAU,OAAQF,EAAAA,YAAY,EAE5E,MAAO,CACL,KAAM,EAKN,KAAMC,EAAAA,iBAAiB,EAAa,EAASE,EAAAA,aAAc,EAAI,EAM/D,MAAO,EAAK,sBAAsB,EAC9B,EACAC,EAAAA,eAAe,EAAuB,EAAgB,GAAQ,CAW1D,IAAa,IAAA,IACb,EAAK,UAAU,CAAY,IAAM,IAAA,IAEjC,EAAS,EAAc,CAAG,CAE9B,CAAC,CAEP,CACF"}
1
+ {"version":3,"file":"canonicalize.js","names":["normalizeParams","EMPTY_SEARCH","mergeWithDefault","withholdFilledSlots","EMPTY_PARAMS","admittedSearch"],"sources":["../../../src/pipeline/canonicalize.ts"],"sourcesContent":["// packages/core/src/pipeline/canonicalize.ts\n\nimport { admittedSearch, withholdFilledSlots } from \"../channels\";\nimport { EMPTY_PARAMS, EMPTY_SEARCH } from \"../constants\";\nimport { mergeWithDefault, normalizeParams } from \"../helpers\";\n\nimport type { RouteResolver } from \"./port\";\nimport type { Canonical } from \"./types\";\nimport type { Params, SearchParams } from \"../types\";\n\n/**\n * Options for {@link canonicalize}. Both flags are opt-in, and both are read as\n * a ROLE rather than inferred from the shape of the call — the reasons differ\n * per flag and are recorded on each.\n */\nexport interface CanonicalizeOptions {\n /**\n * Run stage ① (`forwardTo` resolution through the interceptor seam)?\n *\n * Defaults to `true`. `false` is the LITERAL form: the intent canonicalises\n * against the route the caller NAMED, without following its `forwardTo` chain\n * and without entering the seam. The entry points that ask a question about a\n * literal route rather than producing a destination take it — `buildPath`\n * (A.5: `buildPath(\"src\")` stays `/src`, deliberately asymmetric with\n * `navigate`), `isActiveRoute`'s literal arm, and `makeState`.\n *\n * ⚠ The literal form also skips the seam's channel CHECK. The seam does not\n * SEPARATE channels — stage ② was deleted (`ba0f6b18b`), so the resolving form\n * REFUSES a mis-channelled bag while the literal form simply does not look.\n * Either way nothing is moved: a caller who rides a declared query key in the\n * `params` bag keeps it there, and the URL build prints from the query channel\n * alone. That is what makes channel-correctness the producer's contract.\n */\n resolveForward?: boolean;\n\n /**\n * Run the opt-in undeclared-key diagnostic (#1579)? Defaults to `false`.\n *\n * Explicit rather than inferred from `resolveForward`, and the difference is\n * not cosmetic: `canNavigateTo` DOES resolve `forwardTo` (so it shares the\n * form with `navigate`) yet is a PREDICATE that runs on every `<Link>` render.\n * Keying the diagnostic on the form warned from it — measured, not reasoned —\n * which is exactly the per-render flood the channel guard avoids by not\n * instrumenting predicates at all (RFC rev. 29 §5). Only the points that\n * COMMIT or hand back a state a developer will keep opt in.\n */\n diagnoseUndeclared?: boolean;\n}\n\n/**\n * The undeclared-key diagnostic (#1579 — the params half of #1553), lifted out of\n * {@link canonicalize} so the fast path (#1589) fits the cognitive-complexity\n * budget beside it.\n *\n * BOTH diagnostics presuppose that the route EXISTS — they answer \"does route X\n * declare this key?\", and for a route that is not a route the honest answer is\n * that the question does not apply (#1584). `queryNames` / `pathNames` answer\n * `[]` for a real route with no declarations, so `[]` cannot say \"no such route\";\n * `pathNames` carries the `undefined` arm that can. Reporting a nonexistent route\n * blamed the params for a typo in the ROUTE name — the most misleading direction\n * available — and burnt a de-dup slot per key, silencing the genuine warning if\n * that name later became real. The committing producers still refuse the\n * navigation on their own (`undefined` from `buildNavigationState`,\n * `ROUTE_NOT_FOUND` from `navigate`); only the diagnostic was wrong.\n *\n * Called only when the sink is present, so bare core never reaches the\n * `pathNames` lookup or the bag walk.\n */\nfunction diagnoseUndeclaredKeys(\n port: RouteResolver,\n resolvedName: string,\n pathBag: Params,\n declaredQuery: readonly string[],\n report: (routeName: string, key: string) => void,\n): void {\n const declaredPath = port.pathNames(resolvedName);\n\n if (declaredPath === undefined) {\n return;\n }\n\n for (const key of Object.keys(pathBag)) {\n if (!declaredQuery.includes(key) && !declaredPath.includes(key)) {\n report(resolvedName, key);\n }\n }\n}\n\n/**\n * THE single producer of {@link Canonical}: one pass over stage ① (resolve the\n * `forwardTo` chain) and stage ③ (merge each channel's route default UNDER the\n * caller's value). There is no separating stage ② — channels arrive correct by\n * the producer contract, and the port's `resolveForward` is wired to the seam\n * that REFUSES a mis-channelled bag rather than repairing one.\n *\n * Ordering is forced by the data, not by discipline: ③ needs the RESOLVED name\n * (target defaults cannot be read before `forwardTo` resolves), so ① always\n * precedes it.\n *\n * `undefined` is absence on both sides of the merge (`mergeWithDefault`,\n * #1550/#1551) — an explicitly-`undefined` caller value leaves the default in\n * place, and a default carrying `undefined` behaves like no entry.\n *\n * Channels are frozen here, at merge time — NOT in `materialize`. The two\n * freezes are different things: `materialize`'s `skipFreeze` governs the state\n * object (the navigate path defers it so `completeTransition` can attach\n * `transition`), while `params` / `search` must be immutable the moment a guard\n * can see them. `mergeWithDefault` also copies before freezing, so the caller's\n * own bag is never frozen out from under it.\n *\n * ⚠ The option bags at the call sites are INLINE LITERALS on purpose (#1589).\n * Hoisting them to shared frozen module constants was tried and measured worse:\n * `buildPath` and `isActiveRoute` slowed 6.6–10.5 % while sites that pass no\n * options moved 2 %. The literal in a small hot function is not an allocation at\n * all — V8 inlines the function, escape analysis removes the object, and the flag\n * folds to a constant. A shared frozen object replaces that with a property read\n * off the heap. Do not \"optimise\" these back.\n */\nexport function canonicalize(\n port: RouteResolver,\n name: string,\n params: Params,\n search?: SearchParams,\n opts?: CanonicalizeOptions,\n): Canonical {\n // ① — forwardTo resolution + source-route default layering, through the\n // interceptor seam (plugins inject here). The literal form skips it entirely:\n // no chain, no seam, no channel check — the caller's bags stand as given.\n const forwarded =\n opts?.resolveForward === false\n ? { name, params, search }\n : port.resolveForward(name, params, search);\n const resolvedName = forwarded.name;\n\n // Path-channel entry guard: drops `undefined`-valued keys and collapses an\n // empty bag onto the EMPTY_PARAMS singleton (#1027), so the zero-params hot\n // path allocates nothing downstream.\n const pathBag = normalizeParams(forwarded.params);\n\n // The undeclared-key diagnostic (#1579 — the params half of #1553). A key the\n // route declares NOWHERE stays in `state.params` as app-level data, which is\n // correct and documented — but it never reaches the URL, so the state does not\n // round-trip through its own `state.path`. Core does not change that; it only\n // offers to SAY it.\n //\n // Three things keep this from costing anything it should not:\n // - gated on the sink being present, so bare core pays one `undefined` check\n // and never walks the bag (the port member is a GETTER that returns\n // `undefined` until `validation-plugin` installs the validator — a plain\n // closure would always be truthy and the gate would be decorative);\n // - opted in EXPLICITLY by the committing producers, so every predicate stays\n // silent — including `canNavigateTo`, which resolves `forwardTo` and would\n // therefore be caught by a form-based test while still running on every\n // `<Link>` render (measured: it warned before the flag was made explicit);\n // - read from the CALLER's bag, before route defaults are merged in, so a\n // deliberate arbitrary `defaultParams` entry is not reported as a mistake.\n const reportUndeclared =\n opts?.diagnoseUndeclared === true\n ? port.reportUndeclaredParamKey\n : undefined;\n\n if (reportUndeclared) {\n // Read HERE rather than once at the top (#1589): the fast path below does not\n // use the `?`-declared names, only the fact that there are none, and hoisting\n // the read made every predicate pay a port hop for an array it discarded. The\n // diagnostic is opt-in and off on every predicate, so this read is now paid\n // only by the committing producers that asked for it.\n diagnoseUndeclaredKeys(\n port,\n resolvedName,\n pathBag,\n port.queryNames(resolvedName),\n reportUndeclared,\n );\n }\n\n // The route's OWN defaults. Each slot IS its channel — no split (#1549 routed\n // them by the route's declaration for one release; `ba0f6b18b` retired that\n // along with the rest of stage ②). `defaultParams` is the path channel,\n // `defaultSearch` the query channel, and the router never moves a key between\n // them: the two meet only when the URL is printed. A `defaultParams` naming a\n // `?`-declared key is refused at REGISTRATION (`assertRouteDefaultChannels`),\n // so nothing mis-channelled can reach this merge and there is nothing here to\n // repair. Splitting here used to be what made a config the router itself had\n // accepted survive its own always-on channel guard.\n // FAST PATH (#1589): nothing to merge and nothing to gate. A route with no\n // defaults on either slot, called without a query bag, cannot have a default\n // applied, cannot have a slot withheld, and cannot have a key dropped by the\n // mode gate — whatever the mode, `admittedSearch` would keep every key of a\n // query channel that is empty anyway. So the whole tail below is provably\n // identity, and this returns without it. Note what is NOT in that list: how\n // many names the route declares with `?`. See the gate's own comment.\n //\n // This is the `buildPath/warm-static` case, and it was the most diagnostic\n // number in the regression: a static route — no params, no query, no defaults —\n // paid the full pass and came out 2.6x slower than before the pipeline.\n //\n // ⚠ The channels are still FROZEN here (canonicalize invariant #4): `pathBag`\n // is `normalizeParams`' own fresh object, so it is frozen in place, and\n // `EMPTY_SEARCH` is the shared frozen singleton.\n //\n // ⚠ The query test accepts the EMPTY_SEARCH singleton as well as `undefined`,\n // and that is not cosmetic: `isActiveRoute` and the `forwardState` seam both\n // hand down the singleton rather than nothing, so a test for `undefined` alone\n // left the two render-path predicates — the whole point of the exercise — on\n // the slow path. A fresh `{}` is deliberately NOT accepted: telling an empty\n // literal from a non-empty one costs a key walk, and the two call sites that\n // used to mint one now pass the singleton instead.\n // ⚠ TWO facts, one from each side, and between them stage ③ and the mode gate\n // are provably identity (#1589):\n //\n // 1. the CALLER brought no query bag, so the mode gate has nothing to filter\n // and the query merge has nothing on its left;\n // 2. the ROUTE carries no default on either slot, so neither merge has\n // anything on its right.\n //\n // The merged query bag has exactly those two sources, so both being empty is\n // the whole condition. What is NOT in it: how many names the route declares\n // with `?`. That term WAS in the condition until #1589 — and it was redundant\n // against fact 1, because an empty bag has nothing to drop however many names\n // are declared. Established, not argued: the term survives all 3808 tests, and\n // a 33-probe × 3-mode matrix over a `?`-declaring route with no defaults is\n // byte-identical without it. Dropping it costs one port hop less per call\n // (`queryNames` is ~12 ns on its own — `getQueryParams` is a four-frame chain to\n // a cached Map, not a Map read) and widens the fast path to routes that declare\n // query params but carry no defaults.\n //\n // Which leaves the two defaults, and they are read ABOVE the gate on purpose:\n // they are the gate's own route half AND the slow path's first input, so the\n // fast path pays two hops and the slow path pays nothing extra. The alternative\n // — one `port.mergesNothing()` predicate here, defaults re-read below — buys the\n // fast path one more hop (measured: `isActiveRoute-exact` 101 vs 111 ns) at the\n // cost of a FOURTH hop on the defaults path, which measured +6.5 % there. Both\n // were built and measured; the symmetric one wins because it regresses nothing.\n const defaultPath = port.defaultParams(resolvedName);\n const defaultQuery = port.defaultSearch(resolvedName);\n\n if (\n (forwarded.search === undefined || forwarded.search === EMPTY_SEARCH) &&\n defaultPath === undefined &&\n defaultQuery === undefined\n ) {\n // Annotated rather than asserted: the literal's inferred `query` type is the\n // empty singleton's `Record<string, never>`, too narrow for `Canonical` to\n // overlap, and an inline `as SearchParams` is redundant to the receiver.\n const fastPath: { name: string; path: Params; query: SearchParams } = {\n name: resolvedName,\n path: pathBag,\n query: EMPTY_SEARCH,\n };\n\n return fastPath as Canonical;\n }\n\n // Below the gate: the SLOW path is the only consumer of the declared names, so\n // the read moved here from the top of the function (#1589) — hoisting it made\n // every predicate pay a port hop for an array it discarded. The two consumers\n // left on this side — the default split and the mode gate — share this one\n // local, so they still cannot disagree about which registry decided the channel\n // (#1556); the diagnostic further up reads its own, through the same accessor,\n // so the one-registry invariant is unchanged.\n const declaredQuery = port.queryNames(resolvedName);\n\n // ③ — route defaults UNDER the routed value, each channel independent. Read\n // per channel (not as one `{ params, search }` bag from a combined `defaults()`\n // accessor) so the merge itself allocates nothing on the zero-defaults hot\n // path — the `Canonical` literal below is this function's only allocation.\n // (The pipeline's second one is `materialize`'s options bag, at the call site:\n // two object literals per navigation over the pre-pipeline form.)\n // In the LITERAL form no seam runs, so nothing has enforced #1570's rule that\n // a default is never applied to a slot the caller already filled — in EITHER\n // bag. Apply it here: the query default and a caller's params-twin land in\n // DIFFERENT channels, where no merge ranks them, and the default would win by\n // construction. `buildPath(\"x\", { page: \"9\" })` on `defaultSearch { page: \"5\" }`\n // would print `?page=5` — the caller's value silently replaced by the default,\n // which is the §1.1 inversion this whole split exists to remove. Nothing is\n // rerouted: the caller's key stays in the bag they chose (and, being in the\n // path channel, is simply not printed — that IS the single-bag retirement),\n // only the default is withheld.\n // ⚠ Scoped to `declaredQuery`, and the scope is load-bearing: only a DECLARED\n // query name can HAVE a params-bag twin. Withholding on a key the route\n // declares nowhere (`/u` + `defaultSearch { theme }`), or on one that owns a\n // path slot beside its query twin (`/items/:id?id`, the #843/#1549 carve-out),\n // takes a default no caller was competing for — and left `buildPath` the only\n // producer out of agreement, printing an href this very route's `matchPath`\n // rewrote on the spot. That is the #1552/#1578 class, re-opened.\n // The mode gate's sink, resolved ONCE — the read IS the gate. The router\n // implements this member as a GETTER returning `undefined` while no validator\n // is installed (`wiring/wireNamespaces.ts`), so in bare core `dropSink` is\n // genuinely absent and the drop path below skips the `pathNames` existence\n // lookup entirely. Hoisting also keeps the getter from being re-invoked per\n // dropped key. (It used to be wired as a plain closure — always truthy — so\n // the check read as taken and bare core paid that lookup with no sink behind\n // it; both sinks report their absence honestly now.)\n //\n // Read BELOW the fast path (#1589): a route with nothing to gate cannot drop a\n // key, so it has no use for the sink and should not pay the getter.\n const dropSink = port.reportDroppedQueryKey;\n\n const queryDefaults =\n opts?.resolveForward === false\n ? withholdFilledSlots(defaultQuery, pathBag, declaredQuery)\n : defaultQuery;\n\n const query = mergeWithDefault(queryDefaults, forwarded.search, EMPTY_SEARCH);\n\n return {\n name: resolvedName,\n // `valueIsOwned` (#1589): `pathBag` is `normalizeParams`' own fresh object —\n // never its input — so the merge freezes it in place instead of copying a bag\n // that was already copied one line above. Only the PATH channel may say this;\n // `forwarded.search` above comes from the caller or the seam.\n path: mergeWithDefault(defaultPath, pathBag, EMPTY_PARAMS, true),\n // The mode gate (#1575), applied AFTER the default merge so a `defaultSearch`\n // for an undeclared key is dropped with it — under `default`/`strict` that\n // config is dead by the same rule, not a back door around it. Runs on the\n // merged bag rather than the caller's, because that is the bag ⑤a prints\n // from, and the invariant is about those two agreeing.\n query: port.admitsUndeclaredQuery()\n ? query\n : admittedSearch(query as SearchParams, declaredQuery, (key) => {\n // Same existence precondition as the params-bag diagnostic above\n // (#1584): the DROP is always-on and correct either way, but saying\n // \"key `q` is not declared on route `nope`\" about a route that does\n // not exist blames the query for a route-name typo. Found by sweeping\n // this file's port consumers after fixing the sibling — the two\n // diagnostics read the same `[]`-means-nothing answer.\n // The sink is checked FIRST: it is the cheap half, and it is the one\n // that is absent in bare core, so the `pathNames` lookup stays off the\n // path of a router with no validator installed.\n if (\n dropSink !== undefined &&\n port.pathNames(resolvedName) !== undefined\n ) {\n dropSink(resolvedName, key);\n }\n }),\n // The one and only cast to the brand in the codebase — reviewed once, here.\n } as Canonical;\n}\n"],"mappings":"wIAoEA,SAAS,EACP,EACA,EACA,EACA,EACA,EACM,CACN,IAAM,EAAe,EAAK,UAAU,CAAY,EAE5C,OAAiB,IAAA,GAIrB,IAAK,IAAM,KAAO,OAAO,KAAK,CAAO,EAC/B,CAAC,EAAc,SAAS,CAAG,GAAK,CAAC,EAAa,SAAS,CAAG,GAC5D,EAAO,EAAc,CAAG,CAG9B,CAgCA,SAAgB,EACd,EACA,EACA,EACA,EACA,EACW,CAIX,IAAM,EACJ,GAAM,iBAAmB,GACrB,CAAE,OAAM,SAAQ,QAAO,EACvB,EAAK,eAAe,EAAM,EAAQ,CAAM,EACxC,EAAe,EAAU,KAKzB,EAAUA,EAAAA,gBAAgB,EAAU,MAAM,EAmB1C,EACJ,GAAM,qBAAuB,GACzB,EAAK,yBACL,IAAA,GAEF,GAMF,EACE,EACA,EACA,EACA,EAAK,WAAW,CAAY,EAC5B,CACF,EA6DF,IAAM,EAAc,EAAK,cAAc,CAAY,EAC7C,EAAe,EAAK,cAAc,CAAY,EAEpD,IACG,EAAU,SAAW,IAAA,IAAa,EAAU,SAAWC,EAAAA,eACxD,IAAgB,IAAA,IAChB,IAAiB,IAAA,GAWjB,MAAO,CALL,KAAM,EACN,KAAM,EACN,MAAOA,EAAAA,YAGK,EAUhB,IAAM,EAAgB,EAAK,WAAW,CAAY,EAoC5C,EAAW,EAAK,sBAOhB,EAAQC,EAAAA,iBAJZ,GAAM,iBAAmB,GACrBC,EAAAA,oBAAoB,EAAc,EAAS,CAAa,EACxD,EAEwC,EAAU,OAAQF,EAAAA,YAAY,EAE5E,MAAO,CACL,KAAM,EAKN,KAAMC,EAAAA,iBAAiB,EAAa,EAASE,EAAAA,aAAc,EAAI,EAM/D,MAAO,EAAK,sBAAsB,EAC9B,EACAC,EAAAA,eAAe,EAAuB,EAAgB,GAAQ,CAW1D,IAAa,IAAA,IACb,EAAK,UAAU,CAAY,IAAM,IAAA,IAEjC,EAAS,EAAc,CAAG,CAE9B,CAAC,CAEP,CACF"}
@@ -1,2 +1,2 @@
1
- const e=require("../constants.js"),t=require("../helpers.js");function n(n,r){let i={name:n.name,params:n.path,search:n.query,path:r.path,context:{},...!r.skipFreeze&&{transition:e.DEFAULT_TRANSITION}};return r.skipFreeze?i:t.freezeStateInPlace(i)}exports.materialize=n;
1
+ const e=require("../constants.js"),t=require("../helpers.js");function n(n,r){let i={name:n.name,params:n.path,search:n.query,path:r.path,context:{},...!r.skipFreeze&&{transition:e.DEFAULT_TRANSITION}};return Object.freeze(i.params),r.skipFreeze?i:t.freezeStateShell(i)}exports.materialize=n;
2
2
  //# sourceMappingURL=materialize.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"materialize.js","names":["DEFAULT_TRANSITION","freezeStateInPlace"],"sources":["../../../src/pipeline/materialize.ts"],"sourcesContent":["// packages/core/src/pipeline/materialize.ts\n\nimport { DEFAULT_TRANSITION } from \"../constants\";\nimport { freezeStateInPlace } from \"../helpers\";\n\nimport type { Canonical } from \"./types\";\nimport type { Params, SearchParams, State } from \"../types\";\n\nexport interface MaterializeOptions {\n /**\n * The already-built URL (stage ⑤a). Required: the entry points that could\n * want a lazily-built path (`canNavigateTo`, `isActiveRoute`) settled the\n * question in Phase 2 by calling `buildURL` themselves, so `materialize`\n * never grew the port argument the milestone-1 docs left open.\n */\n path: string;\n /**\n * Defer `Object.freeze` of the STATE OBJECT — the navigate path passes `true`\n * so `completeTransition` can attach `transition`. It does NOT affect the\n * channels: `params` / `search` are frozen at merge time in `canonicalize`.\n */\n skipFreeze?: boolean;\n}\n\n/**\n * Stage ⑤b — the State of a canonical intent, and THE shape of a router State.\n * Accepts nothing but a {@link Canonical}.\n *\n * The shape used to live in `helpers.createStateObject`, shared with\n * `StateNamespace.makeState`; Phase 4 folded that producer onto the pipeline and\n * left the helper with a single caller and a docstring whose whole justification\n * (\"one constructor for both producers\") had expired. It is inlined here — ⑤b IS\n * \"make the state object\", so the shape belongs to the primitive that owns the\n * stage rather than to a helper one import away.\n *\n * `context` is a fresh empty object, intentionally NOT frozen — plugins publish\n * into it via `claim.write(state, value)` after creation.\n *\n * Deliberately does NOT call `makeState`: that would re-run stage ③ (idempotent\n * but a wasted pass) and rebuild the path itself, defeating ⑤a. Since Phase 4 it\n * could not, anyway — `makeState` is `canonicalize`'s literal form and would\n * recurse.\n */\nexport function materialize<\n P extends Params = Params,\n S extends SearchParams = SearchParams,\n>(canonical: Canonical, opts: MaterializeOptions): State<P, S> {\n // `Canonical` is deliberately NOT generic: it is one opaque intent shape, and\n // parameterising it would push the caller's `P` through the port and the merge\n // helpers for no gain. The parameter belongs to the FUNCTION, exactly as on\n // `makeState<P, S>` — a public entry point (`matchPath<P>`) instantiates it and\n // hands the result straight back to the consumer, so the chain\n // `matchPath<P>` → `materialize<P>` → `State<P>` has to carry the caller's type\n // (measured: without it `materialize` collapses the chain to `State<Params>`\n // and a consumer's `State<MyParams>` assignment fails TS2322).\n const state = {\n name: canonical.name,\n params: canonical.path as P,\n search: canonical.query as S,\n path: opts.path,\n context: {},\n ...(!opts.skipFreeze && { transition: DEFAULT_TRANSITION }),\n } as State<P, S>;\n\n return opts.skipFreeze ? state : freezeStateInPlace(state);\n}\n"],"mappings":"8DA2CA,SAAgB,EAGd,EAAsB,EAAuC,CAS7D,IAAM,EAAQ,CACZ,KAAM,EAAU,KAChB,OAAQ,EAAU,KAClB,OAAQ,EAAU,MAClB,KAAM,EAAK,KACX,QAAS,CAAC,EACV,GAAI,CAAC,EAAK,YAAc,CAAE,WAAYA,EAAAA,kBAAmB,CAC3D,EAEA,OAAO,EAAK,WAAa,EAAQC,EAAAA,mBAAmB,CAAK,CAC3D"}
1
+ {"version":3,"file":"materialize.js","names":["DEFAULT_TRANSITION","freezeStateShell"],"sources":["../../../src/pipeline/materialize.ts"],"sourcesContent":["// packages/core/src/pipeline/materialize.ts\n\nimport { DEFAULT_TRANSITION } from \"../constants\";\nimport { freezeStateShell } from \"../helpers\";\n\nimport type { Canonical } from \"./types\";\nimport type { Params, SearchParams, State } from \"../types\";\n\nexport interface MaterializeOptions {\n /**\n * The already-built URL (stage ⑤a). Required: the entry points that could\n * want a lazily-built path (`canNavigateTo`, `isActiveRoute`) settled the\n * question in Phase 2 by calling `buildURL` themselves, so `materialize`\n * never grew the port argument the milestone-1 docs left open.\n */\n path: string;\n /**\n * Defer `Object.freeze` of the STATE OBJECT — the navigate path passes `true`\n * so `completeTransition` can attach `transition`. It does NOT affect the\n * channels: `params` / `search` are frozen at merge time in `canonicalize`.\n */\n skipFreeze?: boolean;\n}\n\n/**\n * Stage ⑤b — the State of a canonical intent, and THE shape of a router State.\n * Accepts nothing but a {@link Canonical}.\n *\n * The shape used to live in `helpers.createStateObject`, shared with\n * `StateNamespace.makeState`; Phase 4 folded that producer onto the pipeline and\n * left the helper with a single caller and a docstring whose whole justification\n * (\"one constructor for both producers\") had expired. It is inlined here — ⑤b IS\n * \"make the state object\", so the shape belongs to the primitive that owns the\n * stage rather than to a helper one import away.\n *\n * `context` is a fresh empty object, intentionally NOT frozen — plugins publish\n * into it via `claim.write(state, value)` after creation.\n *\n * Deliberately does NOT call `makeState`: that would re-run stage ③ (idempotent\n * but a wasted pass) and rebuild the path itself, defeating ⑤a. Since Phase 4 it\n * could not, anyway — `makeState` is `canonicalize`'s literal form and would\n * recurse.\n */\nexport function materialize<\n P extends Params = Params,\n S extends SearchParams = SearchParams,\n>(canonical: Canonical, opts: MaterializeOptions): State<P, S> {\n // `Canonical` is deliberately NOT generic: it is one opaque intent shape, and\n // parameterising it would push the caller's `P` through the port and the merge\n // helpers for no gain. The parameter belongs to the FUNCTION, exactly as on\n // `makeState<P, S>` — a public entry point (`matchPath<P>`) instantiates it and\n // hands the result straight back to the consumer, so the chain\n // `matchPath<P>` → `materialize<P>` → `State<P>` has to carry the caller's type\n // (measured: without it `materialize` collapses the chain to `State<Params>`\n // and a consumer's `State<MyParams>` assignment fails TS2322).\n const state = {\n name: canonical.name,\n params: canonical.path as P,\n search: canonical.query as S,\n path: opts.path,\n context: {},\n ...(!opts.skipFreeze && { transition: DEFAULT_TRANSITION }),\n } as State<P, S>;\n\n // The path channel is frozen HERE, at the publication boundary — `materialize`\n // is the one place a `Canonical` becomes something user code can hold (#1598).\n // BEFORE the `skipFreeze` branch on purpose: that flag defers the state SHELL\n // (the navigate path attaches `transition` and lets plugins write `context`\n // after the fact), never the channels, so guards see frozen bags either way.\n //\n // `params` ONLY, and that asymmetry is measured rather than stylistic:\n // `canonical.query` is already frozen on every path — the fast path hands over\n // the `EMPTY_SEARCH` singleton, the slow one gets it back frozen from\n // `admittedSearch` — and re-freezing a frozen object is not free (~8 ns), so\n // freezing both regressed `isActiveRoute-exact` by 9.8 % while freezing one\n // wins 5-12 % on every producer that never publishes.\n Object.freeze(state.params);\n\n return opts.skipFreeze ? state : freezeStateShell(state);\n}\n"],"mappings":"8DA2CA,SAAgB,EAGd,EAAsB,EAAuC,CAS7D,IAAM,EAAQ,CACZ,KAAM,EAAU,KAChB,OAAQ,EAAU,KAClB,OAAQ,EAAU,MAClB,KAAM,EAAK,KACX,QAAS,CAAC,EACV,GAAI,CAAC,EAAK,YAAc,CAAE,WAAYA,EAAAA,kBAAmB,CAC3D,EAgBA,OAFA,OAAO,OAAO,EAAM,MAAM,EAEnB,EAAK,WAAa,EAAQC,EAAAA,iBAAiB,CAAK,CACzD"}
@@ -60,6 +60,14 @@ interface RouteResolver {
60
60
  * was measured on also carried a per-call `getInternals`, and the harness's
61
61
  * own A/A spread is wider than the delta, so no percentage is attributable to
62
62
  * this accessor alone.
63
+ *
64
+ * Also the ROUTE half of the fast-path gate (#1589), which is why these two are
65
+ * the only reads `canonicalize` still makes before it. A dedicated
66
+ * `mergesNothing()` predicate answering the same question in ONE hop was built
67
+ * and measured: it buys the fast path a further ~10 ns but makes the DEFAULTS
68
+ * path re-read both values below the gate — a fourth hop, +6.5 % there. Reading
69
+ * the values themselves regresses nothing, because the slow path was going to
70
+ * need them anyway.
63
71
  */
64
72
  defaultParams: (name: string) => Params | undefined;
65
73
  defaultSearch: (name: string) => SearchParams | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"port.d.ts","names":[],"sources":["../../../src/pipeline/port.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAwCiB;;;;;;;;EAQf,iBACE,cACA,QAAQ,QACR,SAAS;IAET;IACA,QAAQ;IAIR,SAAS;;;;;;;;;;;;;;EAeX,gBAAgB,iBAAiB;EACjC,gBAAgB,iBAAiB;;;;;;;EAQjC,YAAY,cAAc,QAAQ,QAAQ,QAAQ;;;;;;;EAQlD,aAAa;;;;;;;EAQb;;;;;;;;;;;;;;;;;;EAmBA,0BACI,mBAAmB;;;;;;;;;;;;;;;;;;;;EAqBvB,YAAY;;;;;;;;;;;;;;;;;;;EAoBZ,6BACI,mBAAmB"}
1
+ {"version":3,"file":"port.d.ts","names":[],"sources":["../../../src/pipeline/port.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAwCiB;;;;;;;;EAQf,iBACE,cACA,QAAQ,QACR,SAAS;IAET;IACA,QAAQ;IAIR,SAAS;;;;;;;;;;;;;;;;;;;;;;EAuBX,gBAAgB,iBAAiB;EACjC,gBAAgB,iBAAiB;;;;;;;EAQjC,YAAY,cAAc,QAAQ,QAAQ,QAAQ;;;;;;;EAQlD,aAAa;;;;;;;EAQb;;;;;;;;;;;;;;;;;;EAmBA,0BACI,mBAAmB;;;;;;;;;;;;;;;;;;;;EAqBvB,YAAY;;;;;;;;;;;;;;;;;;;EAoBZ,6BACI,mBAAmB"}
@@ -1,2 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./buildParamMeta-0m11OIJS.js");function t(e,t){return TypeError(`[router.${e}] ${t}`)}function n(e,n,r,i){let a=new Set;for(let o of e){if(a.has(o))throw t(r,`Invalid path for route "${n}": duplicate parameter name ':${o}' in "${i}" (a param name must be unique within a route — the second binding would overwrite the first)`);a.add(o)}}function r(n,r,i,a){for(let o of n)if(e.n.test(o))throw t(i,`Invalid path for route "${r}": invalid query-param name "${o}" in "${a}" (a query-param name cannot contain '<' or '>' — it would never round-trip; rename the query param)`)}function i(e){let t=!0,n=!1;for(let r of e){if(r===`/`){t=!0;continue}if(t&&=(n=r===`:`||r===`*`,!1),!n&&r.codePointAt(0)>=128)return!0}return!1}function a(e,t,n){switch(e){case`name-less`:case`trailing-marker`:return`Invalid path for route "${t}": parameter marker (':' or '*') without a name in "${n}"`;case`fused-marker`:return`Invalid path for route "${t}": parameter marker (':' or '*') must begin a segment, but "${n}" fuses one to a static prefix (use a boundary marker like "/a/:b")`}}function o(e,t){return e.code===`optional-removed`?`Invalid path for route "${t}": optional params are not supported — "${e.segment}". Declare two sibling routes instead: "${e.withoutSegment}" and "${e.requiredForm}" (the route hierarchy already expresses optionality)`:`Invalid path for route "${t}": regex constraints are not supported — '<' and '>' are reserved in path segments ("${e.segment}"). Match the segment as a plain string and validate the value in a guard (canActivate) or app code`}function s(s,c,l,u){if(typeof s!=`string`){let e;throw e=s===null?`null`:Array.isArray(s)?`array`:typeof s,t(l,`Route path must be a string, got ${e}`)}if(s===``)return;if(/\s/.test(s))throw t(l,`Invalid path for route "${c}": whitespace not allowed in "${s}"`);if(!/^([/?~]|[^/]+$)/.test(s))throw t(l,`Route "${c}" has invalid path format: "${s}". Path should start with '/', '~', '?' or be a relative segment.`);if(s.includes(`//`))throw t(l,`Invalid path for route "${c}": double slashes not allowed in "${s}"`);let{pathPattern:d,urlParams:f,queryParams:p}=e.r(s);n(f,c,l,s),r(p,c,l,s);let m=e.i(d);if(m!==void 0)throw t(l,o(m,c));let h=e.a(d);if(h!==void 0)throw t(l,a(h,c,s));if(i(d))throw t(l,`Invalid path for route "${c}": non-ASCII static segment in "${s}" — match compares static segments raw and rejects non-ASCII input, so this route would never match. Percent-encode it (e.g. '/caf%C3%A9') or use a param`);let g=u&&u.paramMeta.urlParams.length>0;if(s.startsWith(`~`)&&g)throw t(l,`Absolute path "${s}" cannot be used under parent route with URL parameters`)}const c=/^[A-Z_a-z][\w-]*$/,l=/\S/,u=1e4;function d(e){if(e===null)return`null`;if(typeof e==`object`){try{let t=e.constructor;if(typeof t==`function`&&t.name!==`Object`)return t.name||`object`}catch{}return`object`}return typeof e}function f(e){for(let t of Object.keys(e)){let n=Object.getOwnPropertyDescriptor(e,t);if(n&&(n.get||n.set))return!0}return!1}function p(e,t){if(!e||typeof e!=`object`)throw TypeError(`[router.${t}] Route must be an object, got ${d(e)}`);let n=Object.getPrototypeOf(e);if(n!==Object.prototype&&n!==null)throw TypeError(`[router.${t}] Route must be a plain object, got ${d(e)}`);if(f(e))throw TypeError(`[router.${t}] Route must not have getters or setters`)}function m(e,t){if(e.encodeParams!==void 0&&typeof e.encodeParams!=`function`)throw TypeError(`[router.${t}] Route "${String(e.name)}" encodeParams must be a function`)}function h(e,t){if(e.decodeParams!==void 0&&typeof e.decodeParams!=`function`)throw TypeError(`[router.${t}] Route "${String(e.name)}" decodeParams must be a function`)}function g(e,t){if(typeof e.name!=`string`)throw TypeError(`[router.${t}] Route name must be a string, got ${d(e.name)}`);let n=e.name;if(n===``)throw TypeError(`[router.${t}] Route name cannot be empty`);if(!l.test(n))throw TypeError(`[router.${t}] Route name cannot contain only whitespace`);if(n.length>u)throw TypeError(`[router.${t}] Route name exceeds maximum length of ${u} characters`);if(!n.startsWith(`@@`)){if(n.includes(`.`))throw TypeError(`[router.${t}] Route name "${n}" cannot contain dots. Use children array or { parent } option in addRoute() instead.`);if(!c.test(n))throw TypeError(`[router.${t}] Invalid route name "${n}". Name must start with a letter or underscore, followed by letters, numbers, underscores, or hyphens.`)}}function _(e,t){if(!t.includes(`.`))return e.children.get(t);let n=e;for(let e of t.split(`.`))if(n=n.children.get(e),!n)return;return n}function v(e,t,n){if(_(e,t))throw Error(`[router.${n}] Route "${t}" already exists`)}function y(e,t,n){if(e.has(t))throw Error(`[router.${n}] Duplicate route "${t}" in batch`);e.add(t)}function b(e,t,n,r){let i=t===``?e:_(e,t);if(i){for(let e of i.children.values())if(e.path===n)throw Error(`[router.${r}] Path "${n}" is already defined`)}}function x(e,t,n,r){let i=e.get(t);if(i?.has(n))throw Error(`[router.${r}] Path "${n}" is already defined`);i?i.add(n):e.set(t,new Set([n]))}function S(e,t,n,r=``,i,a){p(e,t);let o=e;g(o,t),s(o.path,o.name,t,n),m(o,t),h(o,t);let c=o.name,l=r?`${r}.${c}`:c;n&&l&&v(n,l,t),i&&y(i,l,t);let u=o.path,f=r;if(n&&b(n,f,u,t),a&&x(a,f,u,t),o.children!==void 0){if(!Array.isArray(o.children))throw TypeError(`[router.${t}] Route "${c}" children must be an array, got ${d(o.children)}`);for(let e of o.children)S(e,t,n,l,i,a)}}exports.getInternals=e.u,exports.validateRoute=S;
2
- //# sourceMappingURL=validation.js.map
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./internals.js"),t=require("./engine/validation/route-batch.js");exports.getInternals=e.getInternals,exports.validateRoute=t.validateRoute;