UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

334 lines 15.6 kB
import { ObservableAlreadyDisposedError } from '@furystack/utils'; import { match } from 'path-to-regexp'; 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 { maybeViewTransition } from '../view-transition.js'; /** * 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, currentUrl) => { 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, deserializedSearch, currentHash) => { const hasAnyDeclaration = chain.some((entry) => entry.route.query || entry.route.hash); if (!hasAnyDeclaration) return chain; return chain.map((entry) => { const validator = entry.route.query; const query = validator ? validator(deserializedSearch) : null; const declaredHash = entry.route.hash; 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, newChain) => { 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, b) => { 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[key], b[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, newChain) => { 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; }; /** * 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, currentUrl) => { let outlet; const chainElements = 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, 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, newChain) => { 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: createComponent("div", null), chainElements: [], }); const locationService = injector.get(LocationService); const updateUrl = async (currentUrl) => { 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; 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 || createComponent("div", null), 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; }, }); //# sourceMappingURL=nested-router.js.map