import type { Injector } from '@furystack/inject' import { ObservableAlreadyDisposedError } from '@furystack/utils' import type { MatchOptions, MatchResult } from 'path-to-regexp' import { match } from 'path-to-regexp' import type { RenderOptions } from '../models/render-options.js' import { LocationService } from '../services/location-service.js' import { RouteMatchService } from '../services/route-match-service.js' import { createComponent, setRenderMode } from '../shade-component.js' import { Shade } from '../shade.js' import type { ViewTransitionConfig } from '../view-transition.js' import { maybeViewTransition } from '../view-transition.js' import type { HashLiterals, QueryValidator } from './nested-route-types.js' /** * Options passed to a dynamic title resolver function. * @typeParam TMatchResult - The type of matched URL parameters */ export type TitleResolverOptions = { match: MatchResult injector: Injector } /** * Metadata associated with a route entry. * Used by consumers (breadcrumbs, document title, navigation trees) to * derive display information from the route hierarchy. * * This is an `interface` so that applications can augment it with custom fields * via declaration merging: * * @example * ```typescript * declare module '@furystack/shades' { * interface NestedRouteMeta { * icon?: IconDefinition * hidden?: boolean * } * } * ``` * * @typeParam TMatchResult - The type of matched URL parameters */ export interface NestedRouteMeta { title?: string | ((options: TitleResolverOptions) => string | Promise) } /** * A single route entry in a NestedRouter configuration. * Unlike flat `Route`, the URL is the Record key (not a field), and the * `component` receives an `outlet` for rendering matched child content. * * Routes may additionally declare: * - `query`: a validator that parses the deserialized query string into a * typed shape; `component` receives the parsed value (or `null` when * validation fails). The route still matches on path alone — an invalid * query never prevents navigation. * - `hash`: a readonly tuple of allowed URL hash literals; `component` * receives the current hash when it matches one of the listed literals, * or `undefined` otherwise. * * @typeParam TMatchResult - The type of matched URL parameters * @typeParam TQuery - The typed query shape parsed from the URL search string (defaults to `never`) * @typeParam THash - The readonly tuple of allowed hash literals (defaults to `never`) */ export type NestedRoute = { meta?: NestedRouteMeta component: (options: { currentUrl: string match: MatchResult query: TQuery | null hash: THash[number] | undefined outlet?: JSX.Element }) => JSX.Element routingOptions?: MatchOptions /** * Called after the route's DOM has been mounted. When view transitions are enabled, * this runs after the transition's update callback has completed and the new DOM is in place. * Use for imperative side effects like data fetching or focus management — not for visual * animations, which are handled by the View Transition API when `viewTransition` is enabled. */ onVisit?: (options: RenderOptions & { element: JSX.Element }) => Promise /** * Called before the route's DOM is removed (and before the view transition starts, if enabled). * Use for cleanup or teardown logic — not for exit animations, which are handled by the * View Transition API when `viewTransition` is enabled. */ onLeave?: (options: RenderOptions & { element: JSX.Element }) => Promise children?: Record> viewTransition?: boolean | ViewTransitionConfig /** * Optional validator that narrows the deserialized URL query string into a * typed shape. Return `null` when the URL's query does not satisfy the route's * contract — the route still matches on path, but `component` receives `null`. */ query?: QueryValidator /** * Optional readonly tuple of URL hash literals the route understands. Declare * with `as const` to preserve literal types, e.g. `hash: ['tab1', 'tab2'] as const`. * The router forwards the current hash to `component` only when it matches one * of the listed literals; otherwise `component.hash` is `undefined`. */ hash?: THash } /** * Props for the NestedRouter component. * Routes are defined as a Record where keys are URL patterns. */ export type NestedRouterProps = { routes: Record> notFound?: JSX.Element viewTransition?: boolean | ViewTransitionConfig } /** * A single entry in a match chain, pairing a matched route with its match * result and the typed `query` / `hash` values derived from the URL for that * route's declared schema. */ export type MatchChainEntry = { route: NestedRoute match: MatchResult query: unknown hash: string | undefined } /** * Internal state for the NestedRouter component. * `matchChain` is `null` when a notFound fallback has been rendered, * distinguishing it from the initial empty array (not yet processed). */ export type NestedRouterState = { matchChain: MatchChainEntry[] | null jsx: JSX.Element chainElements: JSX.Element[] } /** * Recursively builds a match chain from outermost to innermost matched route. * * For routes with children, a prefix match (`end: false`) is attempted first. * If a child matches the remaining URL, the parent and child chain are combined. * If no child matches, an exact match on the parent alone is attempted. * * For leaf routes (no children), only exact matching is used. * * The returned entries contain placeholder `query: null` / `hash: undefined` * values; callers are expected to populate them via {@link enrichMatchChain}. * * @param routes - The route definitions to match against * @param currentUrl - The URL path to match * @returns An array of matched chain entries from outermost to innermost, or null if no match */ export const buildMatchChain = ( routes: Record>, currentUrl: string, ): MatchChainEntry[] | null => { for (const [pattern, route] of Object.entries(routes)) { if (route.children) { const prefixMatchFn = match(pattern, { ...route.routingOptions, end: false }) let prefixResult = prefixMatchFn(currentUrl) // In path-to-regexp v8, match('/', { end: false }) only matches exact '/'. // For the root pattern, any URL is logically under '/', so force a prefix match. if (!prefixResult && pattern === '/') { prefixResult = { path: '/', params: {} } } if (prefixResult) { let remainingUrl = currentUrl.slice(prefixResult.path.length) if (!remainingUrl.startsWith('/')) { remainingUrl = `/${remainingUrl}` } const childChain = buildMatchChain(route.children, remainingUrl) if (childChain) { return [{ route, match: prefixResult, query: null, hash: undefined }, ...childChain] } } const exactMatchFn = match(pattern, route.routingOptions) const exactResult = exactMatchFn(currentUrl) if (exactResult) { return [{ route, match: exactResult, query: null, hash: undefined }] } } else { const matchFn = match(pattern, route.routingOptions) const matchResult = matchFn(currentUrl) if (matchResult) { return [{ route, match: matchResult, query: null, hash: undefined }] } } } return null } /** * Populates each chain entry's `query` and `hash` fields by running the route's * declared validator against the URL's deserialized query string, and matching * the current URL hash against the route's declared literal tuple. * * Entries whose route declares neither `query` nor `hash` are returned with * `query: null` / `hash: undefined`. * * When no entry in the chain declares either `query` or `hash`, the input * array is returned unchanged to avoid a per-navigation allocation on the * common path-only case. * * @param chain - The chain produced by {@link buildMatchChain} * @param deserializedSearch - The deserialized URL query string * @param currentHash - The current URL hash (without the leading `#`) */ export const enrichMatchChain = ( chain: MatchChainEntry[], deserializedSearch: Record, currentHash: string, ): MatchChainEntry[] => { const hasAnyDeclaration = chain.some((entry) => entry.route.query || entry.route.hash) if (!hasAnyDeclaration) return chain return chain.map((entry) => { const validator = entry.route.query as QueryValidator | undefined const query: unknown = validator ? validator(deserializedSearch) : null const declaredHash = entry.route.hash as readonly string[] | undefined const hash = declaredHash?.includes(currentHash) ? currentHash : undefined return { ...entry, query, hash } }) } /** * Finds the first index where two match chains diverge, considering route * identity and matched path parameters only. Used to scope lifecycle hooks * (`onLeave` / `onVisit`) so that a query string or hash change does not * fire spurious mount / unmount callbacks. * * Returns the length of the shorter chain if one is a prefix of the other. */ export const findDivergenceIndex = (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]): number => { const minLength = Math.min(oldChain.length, newChain.length) for (let i = 0; i < minLength; i++) { if ( oldChain[i].route !== newChain[i].route || JSON.stringify(oldChain[i].match.params) !== JSON.stringify(newChain[i].match.params) ) { return i } } return minLength } /** * Shallow structural equality for query values. Handles the shapes produced by * route-declared query validators: primitives, plain objects (by own enumerable * string keys, recursively shallow-compared), and arrays (by index). * * Nested objects / arrays are compared shallowly one level deep, then fall back * to `Object.is` — sufficient for the typed-query shapes the router surfaces, * and order-independent unlike `JSON.stringify`. */ const isShallowEqual = (a: unknown, b: unknown): boolean => { if (Object.is(a, b)) return true if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false for (let i = 0; i < a.length; i++) { if (!Object.is(a[i], b[i])) return false } return true } if (Array.isArray(b)) return false const aKeys = Object.keys(a) const bKeys = Object.keys(b) if (aKeys.length !== bKeys.length) return false for (const key of aKeys) { if (!Object.prototype.hasOwnProperty.call(b, key)) return false if (!Object.is((a as Record)[key], (b as Record)[key])) return false } return true } /** * Returns true when any chain entry differs in its `query` value or `hash` * segment, ignoring path-level fields (route identity and params). Used to * force a re-render when the URL's query string or hash changes without the * matched route chain itself changing. * * Query values are compared with a key-order-independent shallow equality — * sufficient for the typed shapes a route's `query` validator surfaces. */ export const hasQueryOrHashChanged = (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]): boolean => { const minLength = Math.min(oldChain.length, newChain.length) for (let i = 0; i < minLength; i++) { if (oldChain[i].hash !== newChain[i].hash) return true if (!isShallowEqual(oldChain[i].query, newChain[i].query)) return true } return false } /** * The result of rendering a match chain, containing both the fully composed * JSX tree and per-entry elements for scoped lifecycle animations. */ export type RenderMatchChainResult = { jsx: JSX.Element chainElements: JSX.Element[] } /** * Renders a match chain inside-out: starts with the innermost (leaf) route * rendered with `outlet: undefined`, then passes its JSX as `outlet` to * each successive parent up the chain. * * Returns per-entry elements so that lifecycle hooks (`onLeave`/`onVisit`) * receive only the element for their own route level, not the full tree. * * @param chain - The match chain from outermost to innermost * @param currentUrl - The current URL path * @returns The fully composed JSX element and per-entry rendered elements */ export const renderMatchChain = (chain: MatchChainEntry[], currentUrl: string): RenderMatchChainResult => { let outlet: JSX.Element | undefined const chainElements: JSX.Element[] = new Array(chain.length) for (let i = chain.length - 1; i >= 0; i--) { const entry = chain[i] outlet = entry.route.component({ currentUrl, match: entry.match, query: entry.query, hash: entry.hash, outlet, }) chainElements[i] = outlet } return { jsx: outlet as JSX.Element, chainElements } } /** * Resolves the effective view transition config for a navigation by merging * the router-level default with the innermost (leaf) route's override. * A per-route `false` disables transitions even when the router default is on. */ export const resolveViewTransition = ( routerConfig: boolean | ViewTransitionConfig | undefined, newChain: MatchChainEntry[], ): ViewTransitionConfig | false => { if (!routerConfig && routerConfig !== undefined) return false const leafRoute = newChain[newChain.length - 1]?.route const routeConfig = leafRoute?.viewTransition if (routeConfig === false) return false if (!routerConfig && !routeConfig) return false const baseTypes = typeof routerConfig === 'object' ? routerConfig.types : undefined const routeTypes = typeof routeConfig === 'object' ? routeConfig.types : undefined return { types: routeTypes ?? baseTypes } } /** * A nested router component that supports hierarchical route definitions * with parent/child relationships. Parent routes receive an `outlet` prop * containing the rendered child route, enabling layout composition. * * Routes are defined as a Record where keys are URL patterns (following the * RestApi pattern). The matching algorithm builds a chain from outermost to * innermost route, then renders inside-out so each parent wraps its child. * * The router subscribes to path, query string and hash changes; path-level * changes drive `onLeave` / `onVisit` lifecycle hooks while query / hash * changes re-render the chain without firing lifecycle callbacks. */ export const NestedRouter = Shade({ customElementName: 'shade-nested-router', render: (options) => { const { useState, useObservable, injector } = options const [versionRef] = useState('navVersion', { current: 0 }) const [state, setState] = useState('routerState', { matchChain: [], jsx:
, chainElements: [], }) const locationService = injector.get(LocationService) const updateUrl = async (currentUrl: string) => { const [lastState] = useState('routerState', state) const { matchChain: lastChain, chainElements: lastChainElements } = lastState try { const rawChain = buildMatchChain(options.props.routes, currentUrl) if (rawChain) { const deserializedSearch = locationService.onDeserializedLocationSearchChanged.getValue() const currentHash = locationService.onLocationHashChanged.getValue() const newChain = enrichMatchChain(rawChain, deserializedSearch, currentHash) const lastChainEntries = lastChain ?? [] const divergeIndex = findDivergenceIndex(lastChainEntries, newChain) const hasPathChanged = divergeIndex < lastChainEntries.length || divergeIndex < newChain.length || lastChainEntries.length !== newChain.length const hasChanged = hasPathChanged || hasQueryOrHashChanged(lastChainEntries, newChain) if (hasChanged) { const version = ++versionRef.current for (let i = lastChainEntries.length - 1; i >= divergeIndex; i--) { await lastChainEntries[i].route.onLeave?.({ ...options, element: lastChainElements[i] }) if (version !== versionRef.current) return } let newResult: RenderMatchChainResult setRenderMode(true) try { newResult = renderMatchChain(newChain, currentUrl) } finally { setRenderMode(false) } if (version !== versionRef.current) return const applyUpdate = () => { setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements }) injector.get(RouteMatchService).currentMatchChain.setValue(newChain) } const vtConfig = resolveViewTransition(options.props.viewTransition, newChain) await maybeViewTransition(vtConfig === false ? undefined : vtConfig, applyUpdate) for (let i = divergeIndex; i < newChain.length; i++) { await newChain[i].route.onVisit?.({ ...options, element: newResult.chainElements[i] }) if (version !== versionRef.current) return } } } else if (lastChain !== null) { const version = ++versionRef.current for (let i = (lastChain?.length ?? 0) - 1; i >= 0; i--) { await lastChain[i].route.onLeave?.({ ...options, element: lastChainElements[i] }) if (version !== versionRef.current) return } const applyNotFound = () => { setState({ matchChain: null, jsx: options.props.notFound ||
, chainElements: [], }) injector.get(RouteMatchService).currentMatchChain.setValue([]) } await maybeViewTransition(options.props.viewTransition, applyNotFound) } } catch (e) { if (!(e instanceof ObservableAlreadyDisposedError)) { throw e } } } /** * A single `LocationService.navigate` call synchronously fires three * observables (path, search, hash). Without coalescing, each would kick * off its own `updateUrl` and rely on `versionRef` to cancel the two * that lose the race. * * We dedupe by the composed URL key: each observer fires after the * browser's location has already been updated, so the key read at fire * time is the final target URL. The first observer in the burst kicks * off `updateUrl`; the remaining two see the same key and short-circuit. */ const getUrlKey = () => `${locationService.onLocationPathChanged.getValue()}?${locationService.onLocationSearchChanged.getValue()}#${locationService.onLocationHashChanged.getValue()}` const [lastKeyRef] = useState('navLastKey', { current: getUrlKey() }) const scheduleUpdate = () => { const key = getUrlKey() if (key === lastKeyRef.current) return lastKeyRef.current = key void updateUrl(locationService.onLocationPathChanged.getValue()) } const [locationPath] = useObservable('locationPathChanged', locationService.onLocationPathChanged, { onChange: scheduleUpdate, }) useObservable('locationSearchChanged', locationService.onDeserializedLocationSearchChanged, { onChange: scheduleUpdate, }) useObservable('locationHashChanged', locationService.onLocationHashChanged, { onChange: scheduleUpdate, }) void updateUrl(locationPath) return state.jsx }, })