diff --git a/docs/404-not-found.md b/docs/404-not-found.md new file mode 100644 index 0000000..a66fb96 --- /dev/null +++ b/docs/404-not-found.md @@ -0,0 +1,649 @@ +# Handling 404 Not Found Pages + +When no route matches a URL or all matched routes return `null` or `undefined`, Universal Router +throws an error with `status: 404`. This recipe covers different strategies for handling +not-found scenarios gracefully. + +## Default Behavior + +By default, the router throws an error when no route matches: + +```js +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter([ + { path: '/', action: () => 'Home' }, + { path: '/about', action: () => 'About' }, +]) + +try { + await router.resolve('/nonexistent') +} catch (error) { + console.log(error.message) // 'Route not found' + console.log(error.status) // 404 +} +``` + +## Using errorHandler Option + +The `errorHandler` option provides a clean way to handle all routing errors, including 404s: + +```js +const router = new UniversalRouter( + [ + { path: '/', action: () => ({ content: 'Home' }) }, + { path: '/about', action: () => ({ content: 'About' }) }, + ], + { + errorHandler(error, context) { + if (error.status === 404) { + return { + content: `

Page Not Found

The page "${context.pathname}" does not exist.

`, + status: 404, + } + } + // Re-throw other errors + throw error + }, + }, +) + +const result = await router.resolve('/nonexistent') +// => { content: '

Page Not Found

...', status: 404 } +``` + +### TypeScript errorHandler + +```ts +import UniversalRouter, { RouteError, ResolveContext } from 'universal-router' + +interface PageResult { + content: string + status?: number +} + +const router = new UniversalRouter(routes, { + errorHandler(error: RouteError, context: ResolveContext): PageResult { + if (error.status === 404) { + return { + content: renderNotFoundPage(context.pathname), + status: 404, + } + } + return { + content: renderErrorPage(error), + status: error.status || 500, + } + }, +}) +``` + +## Catch-All Route Pattern + +Add a wildcard route at the end of your routes to catch unmatched paths: + +```js +const router = new UniversalRouter([ + { path: '/', action: () => ({ content: 'Home' }) }, + { path: '/about', action: () => ({ content: 'About' }) }, + { path: '/users/:id', action: (ctx, p) => ({ content: `User ${p.id}` }) }, + + // Catch-all route - must be last! + { + path: '/*path', + action: (context, params) => ({ + content: `

404 Not Found

Path: /${params.path.join('/')}

`, + status: 404, + }), + }, +]) +``` + +### Nested Catch-All Routes + +For nested route structures, add catch-all routes at each level: + +```js +const router = new UniversalRouter({ + path: '', + children: [ + { path: '/', action: () => ({ content: 'Home' }) }, + { + path: '/admin', + children: [ + { path: '', action: () => ({ content: 'Admin Home' }) }, + { path: '/users', action: () => ({ content: 'User Management' }) }, + // Catch-all for /admin/* + { + path: '/*rest', + action: () => ({ + content: '

Admin Page Not Found

', + status: 404, + }), + }, + ], + }, + // Global catch-all + { + path: '/*path', + action: () => ({ + content: '

Page Not Found

', + status: 404, + }), + }, + ], +}) +``` + +## Server-Side Status Codes + +When rendering on the server, set the HTTP status code based on the route result: + +### Node.js HTTP Server + +```js +import http from 'http' +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter(routes, { + errorHandler(error, context) { + return { + content: `

${error.status === 404 ? 'Not Found' : 'Error'}

`, + status: error.status || 500, + } + }, +}) + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`) + const result = await router.resolve(url.pathname) + + // Set status code from result + res.statusCode = result.status || 200 + + res.setHeader('Content-Type', 'text/html') + res.end(`${result.content}`) +}) + +server.listen(3000) +``` + +### Express + +```js +import express from 'express' +import UniversalRouter from 'universal-router' + +const app = express() +const router = new UniversalRouter(routes, { + errorHandler(error, context) { + return { + content: renderErrorPage(error), + status: error.status || 500, + } + }, +}) + +app.use(async (req, res) => { + const result = await router.resolve(req.path) + + res.status(result.status || 200) + res.send(result.content) +}) + +app.listen(3000) +``` + +## Custom 404 Pages + +### Static 404 Page + +```js +const notFoundPage = ` +
+

404

+

Page Not Found

+

Sorry, the page you're looking for doesn't exist.

+ Go Home +
+` + +const router = new UniversalRouter(routes, { + errorHandler(error, context) { + if (error.status === 404) { + return { content: notFoundPage, status: 404 } + } + throw error + }, +}) +``` + +### Dynamic 404 with Suggestions + +```js +import UniversalRouter from 'universal-router' + +// Simple fuzzy matching for suggestions +function findSimilarPaths(pathname, routes, maxSuggestions = 3) { + const suggestions = [] + + function collectPaths(route, prefix = '') { + const fullPath = prefix + (route.path || '') + if (route.action && !fullPath.includes(':') && !fullPath.includes('*')) { + suggestions.push(fullPath) + } + if (route.children) { + route.children.forEach((child) => collectPaths(child, fullPath)) + } + } + + routes.forEach((route) => collectPaths(route)) + + // Simple similarity scoring + return suggestions + .map((path) => ({ + path, + score: calculateSimilarity(pathname, path), + })) + .filter((s) => s.score > 0.3) + .sort((a, b) => b.score - a.score) + .slice(0, maxSuggestions) + .map((s) => s.path) +} + +function calculateSimilarity(a, b) { + const aSegments = a.split('/').filter(Boolean) + const bSegments = b.split('/').filter(Boolean) + let matches = 0 + aSegments.forEach((seg, i) => { + if (bSegments[i] === seg) matches++ + }) + return matches / Math.max(aSegments.length, bSegments.length) +} + +const routes = [ + { path: '/', action: () => ({ content: 'Home' }) }, + { path: '/about', action: () => ({ content: 'About' }) }, + { path: '/contact', action: () => ({ content: 'Contact' }) }, + { path: '/blog', action: () => ({ content: 'Blog' }) }, + { path: '/blog/:slug', action: () => ({ content: 'Post' }) }, +] + +const router = new UniversalRouter(routes, { + errorHandler(error, context) { + if (error.status === 404) { + const suggestions = findSimilarPaths(context.pathname, routes) + + let suggestionsHtml = '' + if (suggestions.length > 0) { + suggestionsHtml = ` +

Did you mean:

+ + ` + } + + return { + content: ` +

Page Not Found

+

The page "${context.pathname}" doesn't exist.

+ ${suggestionsHtml} +

Go to homepage

+ `, + status: 404, + } + } + throw error + }, +}) +``` + +## Handling Different Error Types + +The errorHandler receives all errors, not just 404s. Handle different cases appropriately: + +```js +const router = new UniversalRouter(routes, { + errorHandler(error, context) { + console.error('Route error:', error.message, 'Path:', context.pathname) + + switch (error.status) { + case 404: + return { + content: render404Page(context), + status: 404, + } + + case 403: + return { + content: render403Page(context), + status: 403, + } + + case 500: + default: + // Log server errors + console.error('Server error:', error.stack) + return { + content: render500Page(error), + status: error.status || 500, + } + } + }, +}) +``` + +### Custom Error Status in Routes + +Throw custom errors from route actions: + +```js +const router = new UniversalRouter([ + { + path: '/admin', + action: (context) => { + if (!context.user) { + const error = new Error('Authentication required') + error.status = 401 + throw error + } + if (context.user.role !== 'admin') { + const error = new Error('Admin access required') + error.status = 403 + throw error + } + return { content: 'Admin Dashboard' } + }, + }, +]) +``` + +## Async 404 Handling + +Handle 404s that require async operations (e.g., checking database): + +```js +const router = new UniversalRouter([ + { + path: '/users/:username', + async action(context, params) { + const user = await db.findUser(params.username) + if (!user) { + // Return null to continue to next route + return null + } + return { content: renderUserProfile(user) } + }, + }, + { + path: '/users/:username', + action: (context, params) => ({ + content: `

User Not Found

No user named "${params.username}"

`, + status: 404, + }), + }, +]) +``` + +Or use errorHandler with async: + +```js +const router = new UniversalRouter(routes, { + async errorHandler(error, context) { + if (error.status === 404) { + // Log 404s for analysis + await analytics.log404(context.pathname) + + return { + content: render404Page(context), + status: 404, + } + } + throw error + }, +}) +``` + +## React Component Example + +```jsx +import UniversalRouter from 'universal-router' + +const NotFoundPage = ({ pathname, suggestions }) => ( +
+

404

+

Page Not Found

+

+ The page {pathname} could not be found. +

+ {suggestions.length > 0 && ( + <> +

You might be looking for:

+ + + )} + + Return Home + +
+) + +const router = new UniversalRouter(routes, { + errorHandler(error, context) { + if (error.status === 404) { + const suggestions = findSimilarPaths(context.pathname, routes) + return { + component: NotFoundPage, + props: { pathname: context.pathname, suggestions }, + status: 404, + } + } + throw error + }, +}) + +// Usage +async function render(pathname) { + const result = await router.resolve(pathname) + const Component = result.component + return +} +``` + +## Complete Example: Full Error Handling + +```ts +import UniversalRouter, { RouteError, ResolveContext } from 'universal-router' + +interface AppResult { + content: string + status: number + title: string +} + +interface AppContext extends ResolveContext { + user?: { id: string; role: string } +} + +const routes = [ + { + path: '/', + action: (): AppResult => ({ + content: '

Home

', + status: 200, + title: 'Home', + }), + }, + { + path: '/dashboard', + action: (context: AppContext): AppResult => { + if (!context.user) { + const error = new Error('Please log in') as RouteError + error.status = 401 + throw error + } + return { + content: '

Dashboard

', + status: 200, + title: 'Dashboard', + } + }, + }, + { + path: '/admin', + action: (context: AppContext): AppResult => { + if (!context.user) { + const error = new Error('Please log in') as RouteError + error.status = 401 + throw error + } + if (context.user.role !== 'admin') { + const error = new Error('Admin access required') as RouteError + error.status = 403 + throw error + } + return { + content: '

Admin Panel

', + status: 200, + title: 'Admin', + } + }, + }, +] + +function createErrorPage( + status: number, + title: string, + message: string, +): AppResult { + return { + content: ` +
+

${status}

+

${title}

+

${message}

+ Go Home +
+ `, + status, + title, + } +} + +const router = new UniversalRouter(routes, { + errorHandler(error: RouteError, context: ResolveContext): AppResult { + switch (error.status) { + case 401: + return createErrorPage( + 401, + 'Unauthorized', + 'Please log in to access this page.', + ) + + case 403: + return createErrorPage( + 403, + 'Forbidden', + 'You do not have permission to access this page.', + ) + + case 404: + return createErrorPage( + 404, + 'Not Found', + `The page "${context.pathname}" does not exist.`, + ) + + default: + console.error('Unexpected error:', error) + return createErrorPage( + 500, + 'Server Error', + 'Something went wrong. Please try again later.', + ) + } + }, +}) + +// Server usage +async function handleRequest( + pathname: string, + user?: { id: string; role: string }, +) { + const result = await router.resolve({ pathname, user }) + + return { + statusCode: result.status, + body: ` + + + ${result.title} + ${result.content} + + `, + } +} +``` + +## Common Pitfalls + +### 1. Catch-All Route Not Last + +The catch-all route must be the last route to avoid catching valid paths: + +```js +// Wrong - catch-all before specific routes +const routes = [ + { path: '/*path', action: () => '404' }, + { path: '/about', action: () => 'About' }, // Never reached! +] + +// Correct +const routes = [ + { path: '/about', action: () => 'About' }, + { path: '/*path', action: () => '404' }, // Last +] +``` + +### 2. Forgetting to Re-throw Non-404 Errors + +```js +// Wrong - swallows all errors +errorHandler(error, context) { + return { content: 'Error', status: error.status } +} + +// Correct - only handle expected errors +errorHandler(error, context) { + if (error.status === 404) { + return { content: 'Not Found', status: 404 } + } + throw error // Re-throw unexpected errors +} +``` + +### 3. Not Setting HTTP Status Code + +Always include the status code in your result for proper HTTP responses: + +```js +// Wrong - status lost +return { content: 'Not Found' } + +// Correct +return { content: 'Not Found', status: 404 } +``` + +## See Also + +- [Route Matching Order and Priorities](./route-priorities.md) - Understanding route order +- [Authorization and Protected Routes](./authorization.md) - Handling 401/403 errors +- [Isomorphic Routing](./isomorphic-routing.md) - Server-side status codes +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/README.md b/docs/README.md index 163f2b5..f423e5e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,93 @@ # Documentation -**General** +Welcome to the Universal Router documentation! This guide covers everything from basic setup to advanced patterns. -- [Getting Started](https://github.com/kriasoft/universal-router/blob/master/docs/getting-started.md) -- [Universal Router API](https://github.com/kriasoft/universal-router/blob/master/docs/api.md) +## Getting Started -**Recipes** +- **[Getting Started](./getting-started.md)** - Installation and basic usage +- **[API Reference](./api.md)** - Complete API documentation -- [Redirects](https://github.com/kriasoft/universal-router/blob/master/docs/redirects.md) -- [Request a recipe](https://github.com/kriasoft/universal-router/issues/new) +## Core Concepts + +Essential concepts for understanding how Universal Router works. + +- **[Nested Routes](./nested-routes.md)** - Building route hierarchies with shared layouts +- **[Route Matching Order](./route-priorities.md)** - Understanding which route matches first +- **[URL Parameters](./query-params-hash.md)** - Path params, query strings, and hash fragments + +## Navigation Patterns + +Different approaches to implementing navigation in your application. + +- **[Single Page Navigation](./spa-navigation.md)** - Client-side routing with History API +- **[Isomorphic Routing](./isomorphic-routing.md)** - Universal routing (server + client) +- **[Declarative Routing](./declarative-routing.md)** - Custom resolvers for declarative definitions + +## Authentication & Authorization + +Protecting routes and managing user access. + +- **[Authorization](./authorization.md)** - Protected routes and role-based access control +- **[Redirects](./redirects.md)** - Handling redirects + +## Navigation UI + +Building navigation components for your application. + +- **[URL Generation](./url-generation.md)** - Named routes and the `generateUrls` helper +- **[Active Links](./active-links.md)** - Highlighting the current route in navigation +- **[Breadcrumbs](./breadcrumbs.md)** - Building navigation breadcrumbs from route hierarchy +- **[Scroll Behavior](./scroll-behavior.md)** - Managing scroll position on navigation + +## Error Handling + +Dealing with errors and edge cases. + +- **[404 Not Found](./404-not-found.md)** - Error handling and catch-all routes + +## User Experience + +Enhancing the navigation experience. + +- **[Page Transitions](./page-transitions.md)** - Animated transitions between routes + +## Performance + +Optimizing your routing setup for better performance. + +- **[Code Splitting](./code-splitting.md)** - Lazy loading routes with dynamic imports +- **[Synchronous Mode](./synchronous-routing.md)** - Using `UniversalRouterSync` for sync resolution + +## Framework Integration + +Integrating Universal Router with popular frameworks and tools. + +- **[React & Redux](./react-redux.md)** - Redux-first routing patterns +- **[Server Methods](./server-methods.md)** - Handling GET/POST/PUT/DELETE on the server +- **[Hot Module Replacement](./hmr.md)** - Development setup with Vite and Webpack + +## Advanced Topics + +Advanced patterns and specialized use cases. + +- **[TypeScript](./typescript.md)** - Type-safe routes, parameters, and URL generation +- **[Internationalization](./i18n.md)** - Localized URLs and multilingual applications + +--- + +## Quick Reference + +| Recipe | Use Case | +| --------------------------------------------- | ------------------------------------------ | +| [SPA Navigation](./spa-navigation.md) | Client-side routing in the browser | +| [Isomorphic Routing](./isomorphic-routing.md) | Server-side rendering with React | +| [Code Splitting](./code-splitting.md) | Lazy loading for better performance | +| [Authorization](./authorization.md) | Protecting routes from unauthorized access | +| [TypeScript](./typescript.md) | Type-safe routing with full IntelliSense | +| [URL Generation](./url-generation.md) | Building URLs from route names | + +--- + +## Request a Recipe + +Don't see what you need? [Request a recipe](https://github.com/kriasoft/universal-router/issues/new) and we'll add it to the documentation. diff --git a/docs/active-links.md b/docs/active-links.md new file mode 100644 index 0000000..104db38 --- /dev/null +++ b/docs/active-links.md @@ -0,0 +1,762 @@ +# Highlighting Active Links + +Active link highlighting helps users understand where they are in your application. +This guide shows how to track the current route and style navigation links to +indicate the active page. + +## The Challenge + +Universal Router resolves routes but doesn't provide built-in link components or +active state tracking. You need to: + +1. Track the current pathname +2. Compare it against link destinations +3. Apply appropriate styling + +## Basic Implementation + +### Tracking Current Route + +Create a simple state manager for the current route: + +```ts +// route-state.ts +type Listener = (pathname: string) => void + +let currentPathname = window.location.pathname +const listeners = new Set() + +export function getCurrentPathname() { + return currentPathname +} + +export function setCurrentPathname(pathname: string) { + currentPathname = pathname + listeners.forEach((listener) => listener(pathname)) +} + +export function subscribe(listener: Listener) { + listeners.add(listener) + return () => listeners.delete(listener) +} +``` + +### Checking Active State + +Function to check if a link is active, with trailing slash normalization: + +```ts +// Normalize paths to handle trailing slash inconsistencies +function normalizePath(path: string): string { + return path.replace(/\/+$/, '') || '/' +} + +function isActive(href: string, currentPath: string, exact = false): boolean { + const normalizedHref = normalizePath(href) + const normalizedPath = normalizePath(currentPath) + + if (exact) { + return normalizedHref === normalizedPath + } + // Partial match - href is prefix of current path + return ( + normalizedPath === normalizedHref || + normalizedPath.startsWith(normalizedHref + '/') + ) +} + +// Usage +isActive('/', '/users', true) // false (exact match) +isActive('/', '/users', false) // false (/ is not prefix of /users) +isActive('/users', '/users/123', false) // true (partial match) +isActive('/users', '/users/123', true) // false (exact match) +isActive('/users/', '/users', false) // true (trailing slash normalized) +isActive('/users', '/users/', true) // true (trailing slash normalized) +``` + +> **Why normalize?** URLs like `/users` and `/users/` are often treated as equivalent, +> but string comparison would consider them different. Normalizing ensures consistent +> behavior regardless of trailing slash presence. + +## Vanilla JavaScript Implementation + +### Navigation Component + +```ts +import { getCurrentPathname, subscribe } from './route-state' + +interface NavLink { + href: string + label: string + exact?: boolean +} + +function createNavigation(links: NavLink[], container: HTMLElement) { + function render() { + const currentPath = getCurrentPathname() + + container.innerHTML = links + .map((link) => { + const active = isActive(link.href, currentPath, link.exact) + return ` + + ${link.label} + + ` + }) + .join('') + } + + // Initial render + render() + + // Re-render on route changes + subscribe(render) +} + +// Usage +const nav = document.getElementById('nav')! +createNavigation( + [ + { href: '/', label: 'Home', exact: true }, + { href: '/about', label: 'About' }, + { href: '/users', label: 'Users' }, + { href: '/settings', label: 'Settings' }, + ], + nav, +) +``` + +### CSS Styling + +```css +.nav-link { + padding: 8px 16px; + text-decoration: none; + color: #333; + border-radius: 4px; + transition: + background-color 0.2s, + color 0.2s; +} + +.nav-link:hover { + background-color: #f0f0f0; +} + +.nav-link.active { + background-color: #007bff; + color: white; +} + +/* Alternative: underline style */ +.nav-link-underline { + padding: 8px 16px; + text-decoration: none; + color: #333; + position: relative; +} + +.nav-link-underline.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background-color: #007bff; +} +``` + +## React Implementation + +### NavLink Component + +Create a reusable NavLink component: + +```tsx +import { + useEffect, + useState, + useCallback, + createContext, + useContext, +} from 'react' + +// Context for current route +interface RouterContextValue { + pathname: string + navigate: (href: string) => void +} + +const RouterContext = createContext({ + pathname: '/', + navigate: () => {}, +}) + +export function useRouter() { + return useContext(RouterContext) +} + +// NavLink component +interface NavLinkProps extends React.AnchorHTMLAttributes { + href: string + exact?: boolean + activeClassName?: string + activeStyle?: React.CSSProperties + children: React.ReactNode +} + +export function NavLink({ + href, + exact = false, + activeClassName = 'active', + activeStyle, + className = '', + style, + children, + ...props +}: NavLinkProps) { + const { pathname, navigate } = useRouter() + const isLinkActive = isActive(href, pathname, exact) + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault() + navigate(href) + } + + return ( + + {children} + + ) +} + +// Normalize paths to handle trailing slash inconsistencies +function normalizePath(path: string): string { + return path.replace(/\/+$/, '') || '/' +} + +function isActive(href: string, currentPath: string, exact: boolean): boolean { + const normalizedHref = normalizePath(href) + const normalizedPath = normalizePath(currentPath) + + if (exact) { + return normalizedHref === normalizedPath + } + return ( + normalizedPath === normalizedHref || + normalizedPath.startsWith(normalizedHref + '/') + ) +} +``` + +### Router Provider + +```tsx +import UniversalRouter from 'universal-router' + +interface RouterProviderProps { + router: UniversalRouter + children: React.ReactNode +} + +export function RouterProvider({ router, children }: RouterProviderProps) { + const [pathname, setPathname] = useState(window.location.pathname) + const [content, setContent] = useState(null) + + const navigate = useCallback( + async (href: string) => { + history.pushState(null, '', href) + setPathname(href) + const result = await router.resolve(href) + setContent(result) + }, + [router], + ) + + useEffect(() => { + // Initial route resolution + router.resolve(pathname).then(setContent) + + // Handle browser back/forward + const handlePopState = () => { + const newPath = window.location.pathname + setPathname(newPath) + router.resolve(newPath).then(setContent) + } + + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, [router, pathname]) + + return ( + + {children} + {content} + + ) +} +``` + +### Usage + +```tsx +import { NavLink, RouterProvider } from './router' +import UniversalRouter from 'universal-router' + +const routes = [ + { path: '/', action: () => }, + { path: '/about', action: () => }, + { path: '/users', action: () => }, + { path: '/users/:id', action: (ctx, params) => }, +] + +const router = new UniversalRouter(routes) + +function Navigation() { + return ( + + ) +} + +function App() { + return ( + + + + ) +} +``` + +## Advanced Matching + +### Partial vs Exact Matching + +Different links need different matching strategies: + +```tsx + +``` + +### Matching with Query Parameters + +Include or ignore query parameters in matching: + +```tsx +function isActiveWithQuery( + href: string, + currentPath: string, + currentSearch: string, + options: { exact?: boolean; ignoreQuery?: boolean } = {}, +): boolean { + const url = new URL(href, window.location.origin) + const pathMatches = options.exact + ? url.pathname === currentPath + : currentPath.startsWith(url.pathname) + + if (!pathMatches) return false + + if (options.ignoreQuery || !url.search) { + return true + } + + // Check if query params match + return url.search === currentSearch +} + +// Usage +function NavLink({ href, exact, matchQuery, ...props }) { + const { pathname } = useRouter() + const search = window.location.search + + const isLinkActive = isActiveWithQuery(href, pathname, search, { + exact, + ignoreQuery: !matchQuery, + }) + + // ... +} + +// Links +React Search +All Searches +``` + +### Active Parent Routes + +Highlight parent routes when children are active: + +```tsx +const routes = [ + { + path: '/settings', + name: 'settings', + children: [ + { path: '', name: 'settings-general', action: () => }, + { + path: '/profile', + name: 'settings-profile', + action: () => , + }, + { + path: '/security', + name: 'settings-security', + action: () => , + }, + ], + }, +] + +// Navigation with nested active states +function SettingsNav() { + const { pathname } = useRouter() + + return ( + + ) +} +``` + +## Render Props Pattern + +For more control, use render props: + +```tsx +interface NavLinkRenderProps { + isActive: boolean + isExactActive: boolean +} + +interface NavLinkWithRenderProps { + href: string + children: (props: NavLinkRenderProps) => React.ReactNode +} + +function NavLink({ href, children }: NavLinkWithRenderProps) { + const { pathname, navigate } = useRouter() + + const isLinkActive = pathname.startsWith(href) + const isExactActive = pathname === href + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault() + navigate(href) + } + + return ( + + {children({ isActive: isLinkActive, isExactActive })} + + ) +} + +// Usage with custom rendering +; + {({ isActive, isExactActive }) => ( + + Users + {isActive && } + + )} + +``` + +## Multiple Navigation Areas + +Handle different navigation areas (header, sidebar, breadcrumbs): + +```tsx +// Shared hook for active state +function useIsActive(href: string, exact = false): boolean { + const { pathname } = useRouter() + return exact + ? pathname === href + : pathname === href || pathname.startsWith(href + '/') +} + +// Header navigation +function HeaderNav() { + return ( + + ) +} + +// Sidebar navigation with icons +function SidebarNav() { + const links = [ + { href: '/dashboard', icon: HomeIcon, label: 'Dashboard' }, + { href: '/users', icon: UsersIcon, label: 'Users' }, + { href: '/settings', icon: SettingsIcon, label: 'Settings' }, + ] + + return ( + + ) +} + +// Breadcrumb navigation +function Breadcrumbs() { + const { pathname } = useRouter() + const segments = pathname.split('/').filter(Boolean) + + return ( + + ) +} +``` + +## Accessibility + +Ensure active links are accessible: + +```tsx +function NavLink({ href, exact, children, ...props }: NavLinkProps) { + const { pathname, navigate } = useRouter() + const isLinkActive = isActive(href, pathname, exact) + + return ( + { + e.preventDefault() + navigate(href) + }} + // Indicate current page to screen readers + aria-current={isLinkActive ? 'page' : undefined} + // Don't rely solely on color for active state + className={isLinkActive ? 'nav-link active' : 'nav-link'} + {...props} + > + {children} + {/* Visual indicator that doesn't rely on color */} + {isLinkActive && (current page)} + + ) +} +``` + +```css +/* Accessible active styles */ +.nav-link.active { + /* Color change */ + color: #007bff; + + /* Plus visual indicator */ + font-weight: bold; + border-bottom: 2px solid currentColor; +} + +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +``` + +## CSS-Only Active States (Limited) + +If you control the page and can set classes on the body: + +```ts +// Update body class on navigation +function navigate(pathname: string) { + // Convert path to class name: /users/123 -> page-users + const pageClass = 'page-' + pathname.split('/')[1] || 'home' + + // Remove old page classes + document.body.className = document.body.className + .split(' ') + .filter((c) => !c.startsWith('page-')) + .join(' ') + + // Add new page class + document.body.classList.add(pageClass) + + // ... rest of navigation +} +``` + +```css +/* Style links based on body class */ +body.page-home .nav-link[href='/'] { + color: #007bff; + font-weight: bold; +} + +body.page-users .nav-link[href='/users'] { + color: #007bff; + font-weight: bold; +} + +body.page-settings .nav-link[href='/settings'] { + color: #007bff; + font-weight: bold; +} +``` + +This approach is limited but requires no JavaScript in the nav component. + +## Common Pitfalls + +### 1. Home Link Always Active + +The "/" path is a prefix of all paths: + +```tsx +// Wrong - "/" matches everything +Home // Always active! + +// Correct - use exact matching for home +Home +``` + +### 2. Trailing Slashes + +The `isActive` function shown earlier in this guide already handles trailing slash +normalization. If you're using an older version without normalization: + +```ts +// Without normalization - paths are treated as different! +'/users/' !== '/users' // true (different strings) + +// With normalization (recommended) - consistent behavior +normalizePath('/users/') === normalizePath('/users') // true +``` + +Always normalize paths before comparison to avoid inconsistent behavior. + +### 3. Not Updating on Navigation + +Ensure navigation triggers re-renders: + +```tsx +// Wrong - component doesn't re-render +function NavLink({ href, children }) { + // pathname is captured once, never updates + const pathname = window.location.pathname + // ... +} + +// Correct - subscribe to route changes +function NavLink({ href, children }) { + const { pathname } = useRouter() // Updates on navigation + // ... +} +``` + +### 4. Missing aria-current + +Don't forget accessibility: + +```tsx +// Wrong - no indication for screen readers +Link + +// Correct - includes aria-current + + Link + +``` + +## See Also + +- [SPA Navigation](./spa-navigation.md) - Client-side routing basics +- [Breadcrumbs](./breadcrumbs.md) - Building breadcrumb navigation +- [Scroll Behavior](./scroll-behavior.md) - Managing scroll position +- [Page Transitions](./page-transitions.md) - Animated route changes diff --git a/docs/authorization.md b/docs/authorization.md new file mode 100644 index 0000000..90a7731 --- /dev/null +++ b/docs/authorization.md @@ -0,0 +1,640 @@ +# Authorization and Protected Routes + +Protecting routes based on user authentication and roles is a common requirement. Universal Router's +middleware pattern makes it straightforward to implement authorization at any level of your +route hierarchy. + +## Basic Authentication Check + +The simplest authorization pattern checks if a user is logged in: + +```js +import UniversalRouter from 'universal-router' + +const routes = [ + { + path: '/login', + action() { + return { content: '

Login

' } + }, + }, + { + path: '/dashboard', + action(context) { + // Check if user is authenticated + if (!context.user) { + return { redirect: '/login' } + } + return { content: '

Dashboard

' } + }, + }, +] + +const router = new UniversalRouter(routes) + +// Resolve with user context +router.resolve({ pathname: '/dashboard', user: null }).then((page) => { + if (page.redirect) { + window.location.href = page.redirect + } else { + document.body.innerHTML = page.content + } +}) +``` + +## Middleware Pattern for Route Groups + +Protect multiple routes at once using the middleware pattern: + +```js +const adminRoutes = { + path: '/admin', + action(context) { + // This runs before any child route + if (!context.user) { + return { redirect: '/login' } + } + // Continue to child routes + return context.next() + }, + children: [ + { + path: '', // /admin + action: () => ({ content: '

Admin Dashboard

' }), + }, + { + path: '/users', // /admin/users + action: () => ({ content: '

Manage Users

' }), + }, + { + path: '/settings', // /admin/settings + action: () => ({ content: '

Settings

' }), + }, + ], +} +``` + +## Role-Based Access Control + +Implement different permission levels: + +```ts +import UniversalRouter, { RouteContext } from 'universal-router' + +// Define user roles +type Role = 'guest' | 'user' | 'moderator' | 'admin' + +interface User { + id: string + name: string + role: Role +} + +interface AppContext { + pathname: string + user: User | null +} + +interface PageResult { + content?: string + redirect?: string +} + +// Role hierarchy - higher roles include lower role permissions +const roleHierarchy: Record = { + guest: 0, + user: 1, + moderator: 2, + admin: 3, +} + +function hasPermission(user: User | null, requiredRole: Role): boolean { + if (!user) return requiredRole === 'guest' + return roleHierarchy[user.role] >= roleHierarchy[requiredRole] +} + +// Create a guard factory +function requireRole(role: Role) { + return (context: RouteContext) => { + if (!hasPermission(context.user, role)) { + if (!context.user) { + // Not logged in - redirect to login + return { redirect: `/login?returnTo=${context.pathname}` } + } + // Logged in but insufficient permissions + return { content: '

Access Denied

' } + } + return context.next() + } +} + +const routes = [ + { path: '/', action: () => ({ content: '

Home

' }) }, + { path: '/login', action: () => ({ content: '

Login

' }) }, + + // User area - requires logged in user + { + path: '/profile', + action: requireRole('user'), + children: [ + { path: '', action: () => ({ content: '

My Profile

' }) }, + { path: '/settings', action: () => ({ content: '

Settings

' }) }, + ], + }, + + // Moderator area + { + path: '/moderate', + action: requireRole('moderator'), + children: [ + { path: '', action: () => ({ content: '

Moderation Queue

' }) }, + { path: '/reports', action: () => ({ content: '

Reports

' }) }, + ], + }, + + // Admin area + { + path: '/admin', + action: requireRole('admin'), + children: [ + { path: '', action: () => ({ content: '

Admin Dashboard

' }) }, + { path: '/users', action: () => ({ content: '

Manage Users

' }) }, + ], + }, +] + +const router = new UniversalRouter(routes) +``` + +## Declarative Authorization + +Use a custom `resolveRoute` for declarative authorization: + +```ts +interface ProtectedRoute { + path?: string + requireAuth?: boolean + roles?: Role[] + content?: string + children?: ProtectedRoute[] +} + +const routes: ProtectedRoute[] = [ + { path: '/', content: '

Home

' }, + { path: '/login', content: '

Login

' }, + { path: '/profile', requireAuth: true, content: '

Profile

' }, + { + path: '/admin', + roles: ['admin'], + children: [ + { path: '', content: '

Admin Home

' }, + { path: '/users', content: '

Users

' }, + ], + }, +] + +const router = new UniversalRouter(routes, { + resolveRoute(context, params) { + const { route, user } = context + + // Check authentication + if (route.requireAuth && !user) { + return { redirect: '/login' } + } + + // Check roles + if (route.roles && route.roles.length > 0) { + if (!user || !route.roles.includes(user.role)) { + return user ? { content: '

Forbidden

' } : { redirect: '/login' } + } + } + + // Return content if present + if (route.content) { + return { content: route.content } + } + + // No content means continue to children + return undefined + }, +}) +``` + +## Preserving Return URL + +Redirect users back to their original destination after login: + +```ts +const routes = [ + { + path: '/login', + action(context) { + const returnTo = context.query?.returnTo || '/' + return { + content: ` +

Login

+
+ + +
+ `, + } + }, + }, + { + path: '/protected', + action(context) { + if (!context.user) { + // Encode the current path for redirect + const returnTo = encodeURIComponent(context.pathname) + return { redirect: `/login?returnTo=${returnTo}` } + } + return { content: '

Protected Content

' } + }, + }, +] +``` + +## Permission-Based Authorization + +For fine-grained control, check specific permissions: + +```ts +interface User { + id: string + permissions: string[] +} + +function hasPermission(user: User | null, permission: string): boolean { + return user?.permissions.includes(permission) ?? false +} + +function requirePermission(permission: string) { + return (context: RouteContext) => { + if (!hasPermission(context.user, permission)) { + return { error: 'Forbidden', status: 403 } + } + return context.next() + } +} + +const routes = [ + { + path: '/posts', + children: [ + { + path: '', + action: requirePermission('posts:read'), + children: [{ path: '', action: () => ({ content: 'Post List' }) }], + }, + { + path: '/create', + action: requirePermission('posts:create'), + children: [{ path: '', action: () => ({ content: 'Create Post' }) }], + }, + { + path: '/:id/edit', + action: requirePermission('posts:update'), + children: [{ path: '', action: () => ({ content: 'Edit Post' }) }], + }, + { + path: '/:id/delete', + action: requirePermission('posts:delete'), + children: [{ path: '', action: () => ({ content: 'Delete Post' }) }], + }, + ], + }, +] +``` + +## Resource-Based Authorization + +Check ownership or specific resource permissions: + +```ts +const routes = [ + { + path: '/posts/:id', + async action(context) { + const postId = context.params.id + + // Fetch the resource + const post = await fetchPost(postId) + + if (!post) { + return { error: 'Not Found', status: 404 } + } + + // Check if user can access this specific post + const canAccess = + post.isPublic || + context.user?.id === post.authorId || + context.user?.role === 'admin' + + if (!canAccess) { + return { error: 'Forbidden', status: 403 } + } + + // Pass the post to child routes + context.post = post + return context.next() + }, + children: [ + { + path: '', + action(context) { + return { content: `

${context.post.title}

` } + }, + }, + { + path: '/edit', + action(context) { + // Additional check for editing + if (context.user?.id !== context.post.authorId) { + return { error: 'Forbidden', status: 403 } + } + return { content: '

Edit Post

' } + }, + }, + ], + }, +] +``` + +## Server-Side Authorization + +Implement authorization on the server with Express: + +```ts +import express from 'express' +import UniversalRouter from 'universal-router' + +const app = express() + +// Authentication middleware +app.use((req, res, next) => { + // Get user from session, JWT, etc. + req.user = getUserFromSession(req) + next() +}) + +app.get('*', async (req, res) => { + const router = new UniversalRouter(routes) + + try { + const page = await router.resolve({ + pathname: req.path, + user: req.user, + query: req.query, + }) + + if (page.redirect) { + res.redirect(page.status || 302, page.redirect) + return + } + + if (page.status === 403) { + res.status(403).send('

Forbidden

') + return + } + + res.send(page.content) + } catch (error) { + if (error.status === 404) { + res.status(404).send('

Not Found

') + } else { + throw error + } + } +}) +``` + +## Client-Side Authorization with React + +Integrate authorization with React components: + +```tsx +import { createContext, useContext, ReactNode } from 'react' +import UniversalRouter from 'universal-router' + +// Auth context +interface AuthContextValue { + user: User | null + login: (credentials: Credentials) => Promise + logout: () => void +} + +const AuthContext = createContext(null) + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth must be used within AuthProvider') + return context +} + +// Protected component wrapper +interface ProtectedProps { + children: ReactNode + requiredRole?: Role + fallback?: ReactNode +} + +export function Protected({ + children, + requiredRole = 'user', + fallback, +}: ProtectedProps) { + const { user } = useAuth() + + if (!hasPermission(user, requiredRole)) { + return fallback ?? null + } + + return <>{children} +} + +// Usage in routes +const routes = [ + { + path: '/admin', + action(context) { + const { default: AdminLayout } = await import('./layouts/Admin') + return ( + }> + + + ) + }, + }, +] +``` + +## Handling Multiple Auth Strategies + +Support different authentication methods: + +```ts +interface AuthContext { + // JWT token auth + token?: string + // Session auth + session?: { userId: string } + // API key auth + apiKey?: string +} + +function getUser(context: AuthContext): User | null { + // Check JWT token + if (context.token) { + try { + return verifyJWT(context.token) + } catch { + return null + } + } + + // Check session + if (context.session) { + return getUserById(context.session.userId) + } + + // Check API key + if (context.apiKey) { + return getApiKeyUser(context.apiKey) + } + + return null +} + +const router = new UniversalRouter(routes, { + context: { + // Will be populated per request + }, + resolveRoute(context, params) { + // Resolve user from various auth methods + context.user = context.user ?? getUser(context) + return context.route.action?.(context, params) + }, +}) +``` + +## Common Pitfalls + +### 1. Client-Side Only Security + +Never rely solely on client-side authorization: + +```ts +// Wrong - easily bypassed by users +const routes = [ + { + path: '/admin', + action(context) { + if (!context.user?.isAdmin) { + return { redirect: '/login' } + } + // User can modify JS to skip this check + return { content: 'Admin secrets' } + }, + }, +] + +// Correct - always verify on server +app.get('/admin', authenticateMiddleware, authorizeAdmin, (req, res) => { + // Server-side authorization check +}) +``` + +### 2. Leaking Protected Content + +Ensure protected content is not sent to unauthorized users: + +```ts +// Wrong - content is in the response, just hidden +{ + path: '/admin', + action(context) { + const adminContent = '

Admin

Secret data
' + if (!context.user?.isAdmin) { + return { content: '

Access Denied

', hidden: adminContent } + } + return { content: adminContent } + } +} + +// Correct - only return what user should see +{ + path: '/admin', + action(context) { + if (!context.user?.isAdmin) { + return { content: '

Access Denied

' } + } + return { content: '

Admin

Secret data
' } + } +} +``` + +### 3. Not Handling All Child Routes + +Ensure middleware protects ALL children: + +```ts +// Wrong - action must call context.next() to protect children +{ + path: '/admin', + action(context) { + if (!context.user) { + return { redirect: '/login' } + } + // Missing context.next() - children aren't checked! + return undefined + }, + children: [...] +} + +// Correct +{ + path: '/admin', + action(context) { + if (!context.user) { + return { redirect: '/login' } + } + return context.next() // Continue to children + }, + children: [...] +} +``` + +### 4. Race Conditions in Auth State + +Handle auth state changes during navigation: + +```ts +// Problem: User logs out while page is loading +async function navigate(pathname: string) { + const page = await router.resolve({ pathname, user: currentUser }) + // User might have logged out during the await! + render(page) +} + +// Solution: Check auth state after resolve +async function navigate(pathname: string) { + const userAtStart = currentUser + const page = await router.resolve({ pathname, user: userAtStart }) + + // Verify user hasn't changed + if (currentUser !== userAtStart) { + // Re-resolve with new auth state + return navigate(pathname) + } + + render(page) +} +``` + +## See Also + +- [Redirects](./redirects.md) - Redirect patterns for auth flows +- [Isomorphic Routing](./isomorphic-routing.md) - Server-side auth +- [Usage with React and Redux](./react-redux.md) - Auth state management +- [Universal Router API](./api.md) - Middleware and context diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md new file mode 100644 index 0000000..760b833 --- /dev/null +++ b/docs/breadcrumbs.md @@ -0,0 +1,631 @@ +# Building Navigation Breadcrumbs + +Breadcrumbs provide users with a trail of links showing their current location within the +site hierarchy. Universal Router's nested route structure and parent references make it +easy to build dynamic breadcrumbs that update automatically as users navigate. + +## Understanding Route Hierarchy + +Universal Router automatically maintains parent-child relationships in routes. Each matched +route has a `parent` property that references its parent route, allowing you to traverse +the hierarchy. + +```js +// Route structure +{ + path: '/products', + name: 'products', + title: 'Products', + children: [ + { + path: '/:category', + name: 'category', + title: 'Category', + children: [ + { + path: '/:productId', + name: 'product', + title: 'Product Details', + }, + ], + }, + ], +} + +// When matching /products/electronics/phone-123: +// route.parent -> category route +// route.parent.parent -> products route +// route.parent.parent.parent -> root route +``` + +## Basic Breadcrumb Collection + +Collect breadcrumbs by traversing the route hierarchy from the matched route to the root: + +```js +import UniversalRouter from 'universal-router' + +const routes = { + path: '', + title: 'Home', + children: [ + { + path: '/products', + title: 'Products', + children: [ + { + path: '', + title: 'All Products', + action: () => ({ page: 'product-list' }), + }, + { + path: '/:category', + title: 'Category', + children: [ + { + path: '', + action: (ctx, params) => ({ + page: 'category', + category: params.category, + }), + }, + { + path: '/:productId', + title: 'Product', + action: (ctx, params) => ({ + page: 'product', + category: params.category, + productId: params.productId, + }), + }, + ], + }, + ], + }, + ], +} + +function collectBreadcrumbs(route, params) { + const breadcrumbs = [] + let current = route + + while (current) { + if (current.title) { + breadcrumbs.unshift({ + title: current.title, + path: current.path, + }) + } + current = current.parent + } + + return breadcrumbs +} + +const router = new UniversalRouter(routes) + +router.resolve('/products/electronics/phone-123').then((result) => { + // result.page === 'product' +}) +``` + +## Middleware Pattern for Breadcrumbs + +Use middleware to automatically collect breadcrumbs and include them in route results: + +```js +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +const routes = { + path: '', + name: 'home', + title: 'Home', + async action(context) { + const result = await context.next() + if (!result) return null + + // Collect breadcrumbs from route hierarchy + const breadcrumbs = [] + let route = context.route + + while (route) { + if (route.title && route.name) { + breadcrumbs.unshift({ + title: + typeof route.title === 'function' + ? route.title(context.params) + : route.title, + name: route.name, + path: route.path, + }) + } + route = route.parent + } + + return { + ...result, + breadcrumbs, + } + }, + children: [ + { + path: '', + name: 'home-index', + action: () => ({ content: 'Home Page' }), + }, + { + path: '/blog', + name: 'blog', + title: 'Blog', + children: [ + { + path: '', + name: 'blog-index', + action: () => ({ content: 'Blog Index' }), + }, + { + path: '/:slug', + name: 'blog-post', + title: (params) => `Post: ${params.slug}`, + action: (ctx, params) => ({ content: `Blog Post: ${params.slug}` }), + }, + ], + }, + ], +} + +const router = new UniversalRouter(routes) + +const result = await router.resolve('/blog/hello-world') +console.log(result.breadcrumbs) +// [ +// { title: 'Home', name: 'home', path: '' }, +// { title: 'Blog', name: 'blog', path: '/blog' }, +// { title: 'Post: hello-world', name: 'blog-post', path: '/:slug' }, +// ] +``` + +## Dynamic Breadcrumb Titles + +Breadcrumb titles often need to be dynamic, showing actual names instead of IDs. Fetch +data and populate titles: + +```js +const routes = { + path: '', + title: 'Home', + async action(context) { + const result = await context.next() + if (!result) return null + + // Build breadcrumbs with resolved titles + const breadcrumbs = await buildBreadcrumbs(context) + + return { ...result, breadcrumbs } + }, + children: [ + { + path: '/users', + title: 'Users', + children: [ + { + path: '/:userId', + // Dynamic title - will be resolved + title: async (params) => { + const user = await fetchUser(params.userId) + return user.name + }, + action: async (ctx, params) => { + const user = await fetchUser(params.userId) + return { content: user, type: 'user-profile' } + }, + }, + ], + }, + ], +} + +async function buildBreadcrumbs(context) { + const breadcrumbs = [] + let route = context.route + + while (route) { + if (route.title) { + const title = + typeof route.title === 'function' + ? await route.title(context.params) + : route.title + + breadcrumbs.unshift({ title, path: route.path }) + } + route = route.parent + } + + return breadcrumbs +} +``` + +### Caching Dynamic Titles + +Avoid duplicate fetches by caching data in context: + +```js +const routes = { + path: '/users/:userId', + title: (params, context) => context.userData?.name || 'User', + async action(context, params) { + // Fetch once and store in context + const userData = await fetchUser(params.userId) + context.userData = userData + + return { + content: userData, + breadcrumbs: await buildBreadcrumbs(context), + } + }, +} +``` + +## Generating Breadcrumb URLs + +Use `generateUrls` to create proper links for each breadcrumb: + +```js +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +const routes = [ + { + path: '', + name: 'root', + title: 'Home', + children: [ + { + path: '/categories', + name: 'categories', + title: 'Categories', + children: [ + { + path: '/:categoryId', + name: 'category', + title: (params) => `Category ${params.categoryId}`, + children: [ + { + path: '/products/:productId', + name: 'product', + title: (params) => `Product ${params.productId}`, + action: (ctx, params) => ({ params }), + }, + ], + }, + ], + }, + ], + }, +] + +const router = new UniversalRouter(routes) +const url = generateUrls(router) + +async function resolveBreadcrumbs(pathname) { + const result = await router.resolve(pathname) + const breadcrumbs = [] + let route = result.route + const params = result.params + + while (route) { + if (route.name && route.title) { + breadcrumbs.unshift({ + title: + typeof route.title === 'function' ? route.title(params) : route.title, + url: route.name === 'root' ? '/' : url(route.name, params), + }) + } + route = route.parent + } + + return breadcrumbs +} + +const breadcrumbs = await resolveBreadcrumbs( + '/categories/electronics/products/phone-1', +) +// [ +// { title: 'Home', url: '/' }, +// { title: 'Categories', url: '/categories' }, +// { title: 'Category electronics', url: '/categories/electronics' }, +// { title: 'Product phone-1', url: '/categories/electronics/products/phone-1' }, +// ] +``` + +## Rendering Breadcrumbs + +### HTML Rendering + +```js +function renderBreadcrumbs(breadcrumbs) { + const items = breadcrumbs.map((crumb, index) => { + const isLast = index === breadcrumbs.length - 1 + + if (isLast) { + return `${crumb.title}` + } + + return `${crumb.title}` + }) + + return ` + + ` +} +``` + +### React Component + +```jsx +function Breadcrumbs({ items }) { + return ( + + ) +} +``` + +## SEO Structured Data + +Add JSON-LD structured data for search engines to display breadcrumbs in search results: + +```js +function generateBreadcrumbSchema(breadcrumbs, baseUrl) { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: breadcrumbs.map((crumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: crumb.title, + item: `${baseUrl}${crumb.url}`, + })), + } +} + +function renderPageWithBreadcrumbs(result, baseUrl) { + const schema = generateBreadcrumbSchema(result.breadcrumbs, baseUrl) + + return ` + + + + + + + ${renderBreadcrumbs(result.breadcrumbs)} + ${result.content} + + + ` +} + +// Generated schema for /categories/electronics/products/phone-1: +// { +// "@context": "https://schema.org", +// "@type": "BreadcrumbList", +// "itemListElement": [ +// { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" }, +// { "@type": "ListItem", "position": 2, "name": "Categories", "item": "https://example.com/categories" }, +// { "@type": "ListItem", "position": 3, "name": "Electronics", "item": "https://example.com/categories/electronics" }, +// { "@type": "ListItem", "position": 4, "name": "Phone 1", "item": "https://example.com/categories/electronics/products/phone-1" } +// ] +// } +``` + +## Complete Example + +```ts +import UniversalRouter, { Route, RouteContext } from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +interface Breadcrumb { + title: string + url: string +} + +interface PageResult { + content: string + breadcrumbs: Breadcrumb[] +} + +interface AppRoute extends Route { + title?: + | string + | ((params: Record) => string | Promise) +} + +const routes: AppRoute = { + path: '', + name: 'root', + title: 'Home', + async action(context) { + const result = await context.next() + if (!result) return null + + const breadcrumbs = await collectBreadcrumbs(context, url) + return { ...result, breadcrumbs } + }, + children: [ + { + path: '', + name: 'home', + action: () => ({ content: '

Welcome

', breadcrumbs: [] }), + }, + { + path: '/shop', + name: 'shop', + title: 'Shop', + children: [ + { + path: '', + name: 'shop-index', + action: () => ({ content: '

Shop

', breadcrumbs: [] }), + }, + { + path: '/:category', + name: 'shop-category', + title: (params) => capitalizeFirst(params.category), + children: [ + { + path: '', + name: 'category-index', + action: (ctx, params) => ({ + content: `

${params.category}

`, + breadcrumbs: [], + }), + }, + { + path: '/:productId', + name: 'product', + title: async (params) => { + const product = await fetchProduct(params.productId) + return product.name + }, + action: async (ctx, params) => { + const product = await fetchProduct(params.productId) + return { + content: `

${product.name}

${product.description}

`, + breadcrumbs: [], + } + }, + }, + ], + }, + ], + }, + ], +} + +const router = new UniversalRouter(routes) +const url = generateUrls(router) + +async function collectBreadcrumbs( + context: RouteContext, + urlGenerator: ReturnType, +): Promise { + const breadcrumbs: Breadcrumb[] = [] + let route = context.route as AppRoute | null + + while (route) { + if (route.title && route.name) { + const title = + typeof route.title === 'function' + ? await route.title(context.params as Record) + : route.title + + const routeUrl = + route.name === 'root' ? '/' : urlGenerator(route.name, context.params) + + breadcrumbs.unshift({ title, url: routeUrl }) + } + route = route.parent as AppRoute | null + } + + return breadcrumbs +} + +function capitalizeFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +async function fetchProduct(id: string) { + // Simulated fetch + return { name: `Product ${id}`, description: 'A great product' } +} + +// Usage +const result = await router.resolve('/shop/electronics/laptop-pro') +console.log(result.breadcrumbs) +// [ +// { title: 'Home', url: '/' }, +// { title: 'Shop', url: '/shop' }, +// { title: 'Electronics', url: '/shop/electronics' }, +// { title: 'Product laptop-pro', url: '/shop/electronics/laptop-pro' }, +// ] +``` + +## Common Pitfalls + +### 1. Missing Route Names + +Without route names, URL generation will fail: + +```js +// Wrong - no name for URL generation +{ + path: '/products', + title: 'Products', + // Missing name! +} + +// Correct +{ + path: '/products', + name: 'products', + title: 'Products', +} +``` + +### 2. Inconsistent Parameter Names + +Breadcrumb URL generation needs all parameters from the hierarchy: + +```js +// Wrong - parent uses 'id', child uses different 'id' +{ + path: '/:id', + children: [{ path: '/items/:id' }] // Overwrites parent 'id' +} + +// Correct - unique names +{ + path: '/:categoryId', + children: [{ path: '/items/:itemId' }] +} +``` + +### 3. Forgetting to Handle Root Route + +The root route often needs special handling in URL generation: + +```js +// Handle root route specially +const routeUrl = route.name === 'root' ? '/' : url(route.name, params) +``` + +## See Also + +- [Nested Routes and Layouts](./nested-routes.md) - Understanding route hierarchy +- [URL Generation](./api.md#url-generation) - Generating URLs from route names +- [Route Matching Order](./route-priorities.md) - How routes are matched +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/code-splitting.md b/docs/code-splitting.md new file mode 100644 index 0000000..b833bae --- /dev/null +++ b/docs/code-splitting.md @@ -0,0 +1,520 @@ +# Code Splitting and Lazy Loading Routes + +Code splitting allows you to load route components on demand, reducing your initial bundle size +and improving application startup time. Universal Router works seamlessly with dynamic imports +and any bundler that supports them. + +## Why Code Split Routes? + +Without code splitting, your entire application is bundled into a single JavaScript file. +Users download everything upfront, even pages they may never visit: + +``` +Before: main.js (500KB) - includes all pages +After: main.js (50KB) + home.js (30KB) + about.js (20KB) + admin.js (400KB) +``` + +With route-based code splitting: + +- Initial load is faster (smaller bundle) +- Users only download code for pages they visit +- Admin-heavy features don't slow down regular users + +## Basic Dynamic Imports + +The simplest approach uses `import()` inside route actions: + +```js +import UniversalRouter from 'universal-router' + +const routes = [ + { + path: '/', + async action() { + const { default: Home } = await import('./pages/Home') + return Home + }, + }, + { + path: '/about', + async action() { + const { default: About } = await import('./pages/About') + return About + }, + }, + { + path: '/admin', + async action() { + // This 400KB admin bundle only loads when visiting /admin + const { default: Admin } = await import('./pages/Admin') + return Admin + }, + }, +] + +const router = new UniversalRouter(routes) + +router.resolve('/admin').then((Page) => { + // Admin module is now loaded + document.getElementById('app').innerHTML = Page() +}) +``` + +## Loading States + +Show a loading indicator while chunks are being fetched: + +```tsx +import UniversalRouter from 'universal-router' +import type { ReactNode } from 'react' + +interface PageResult { + component: ReactNode + loading?: ReactNode +} + +const routes = [ + { + path: '/dashboard', + async action(): Promise { + const { default: Dashboard } = await import('./pages/Dashboard') + return { + component: , + } + }, + }, +] + +const router = new UniversalRouter(routes) + +async function navigate(pathname: string) { + const container = document.getElementById('app')! + + // Show loading state immediately + container.innerHTML = '
Loading...
' + + try { + const page = await router.resolve(pathname) + // Replace with actual content + render(page.component, container) + } catch (error) { + container.innerHTML = '
Failed to load page
' + } +} +``` + +## React Suspense Integration + +For React applications, integrate with Suspense for declarative loading states: + +```tsx +import { lazy, Suspense, ReactNode } from 'react' +import UniversalRouter from 'universal-router' + +// Create lazy components +const Home = lazy(() => import('./pages/Home')) +const About = lazy(() => import('./pages/About')) +const Dashboard = lazy(() => import('./pages/Dashboard')) + +const routes = [ + { + path: '/', + action: () => , + }, + { + path: '/about', + action: () => , + }, + { + path: '/dashboard', + action: () => , + }, +] + +const router = new UniversalRouter(routes) + +// Wrap rendering in Suspense +function App() { + const [content, setContent] = useState(null) + + useEffect(() => { + router.resolve(window.location.pathname).then(setContent) + }, []) + + return Loading...}>{content} +} +``` + +## Route-Level Loading Components + +Define custom loading states per route: + +```tsx +interface RouteConfig { + path: string + load: () => Promise<{ default: React.ComponentType }> + loading?: React.ReactNode +} + +const routeConfigs: RouteConfig[] = [ + { + path: '/', + load: () => import('./pages/Home'), + loading: , + }, + { + path: '/dashboard', + load: () => import('./pages/Dashboard'), + loading: , + }, + { + path: '/settings', + load: () => import('./pages/Settings'), + loading: , + }, +] + +// Convert to Universal Router routes +const routes = routeConfigs.map((config) => ({ + path: config.path, + async action() { + const module = await config.load() + return { + Component: module.default, + loading: config.loading, + } + }, +})) +``` + +## Preloading Routes + +Preload routes before users navigate to improve perceived performance: + +```tsx +// Create a preload map +const preloadMap = new Map>() + +const routes = [ + { + path: '/dashboard', + // Store the import promise for preloading + load: () => import('./pages/Dashboard'), + async action() { + // Use preloaded module if available, otherwise load fresh + const existing = preloadMap.get('/dashboard') + const module = existing + ? await existing + : await import('./pages/Dashboard') + return + }, + }, +] + +// Preload function to call on hover or other triggers +function preload(path: string) { + const route = routes.find((r) => r.path === path && r.load) + if (route && !preloadMap.has(path)) { + preloadMap.set(path, route.load()) + } +} + +// Preload on link hover +document.addEventListener('mouseover', (event) => { + const link = (event.target as Element).closest('a') + if (link && link.hostname === window.location.hostname) { + preload(link.pathname) + } +}) + +// Preload on link focus (keyboard navigation) +document.addEventListener('focusin', (event) => { + const link = (event.target as Element).closest('a') + if (link && link.hostname === window.location.hostname) { + preload(link.pathname) + } +}) +``` + +## Webpack Configuration + +Webpack automatically code-splits on dynamic imports. Configure chunk names for better debugging: + +```js +// routes.js +const routes = [ + { + path: '/admin', + async action() { + // webpackChunkName creates a named chunk: admin.js + const { default: Admin } = await import( + /* webpackChunkName: "admin" */ './pages/Admin' + ) + return Admin + }, + }, + { + path: '/reports', + async action() { + // Group related chunks together + const { default: Reports } = await import( + /* webpackChunkName: "admin" */ './pages/Reports' + ) + return Reports + }, + }, +] +``` + +```js +// webpack.config.js +module.exports = { + output: { + filename: '[name].[contenthash].js', + chunkFilename: '[name].[contenthash].js', + }, + optimization: { + splitChunks: { + chunks: 'all', + // Create separate chunks for large dependencies + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + }, + }, + }, + }, +} +``` + +## Vite Configuration + +Vite handles code splitting automatically with dynamic imports: + +```ts +// routes.ts +const routes = [ + { + path: '/admin', + async action() { + const { default: Admin } = await import('./pages/Admin') + return Admin + }, + }, +] +``` + +```ts +// vite.config.ts +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + rollupOptions: { + output: { + // Customize chunk file names + chunkFileNames: 'assets/[name]-[hash].js', + // Manual chunk splitting + manualChunks: { + // Put large libraries in separate chunks + 'react-vendor': ['react', 'react-dom'], + }, + }, + }, + }, +}) +``` + +## Error Handling for Failed Chunks + +Handle network errors when loading chunks: + +```tsx +async function loadWithRetry( + load: () => Promise, + retries = 3, + delay = 1000, +): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await load() + } catch (error) { + if (attempt === retries) throw error + + // Wait before retrying (exponential backoff) + await new Promise((resolve) => setTimeout(resolve, delay * attempt)) + + // Clear module cache (for Vite/Webpack) + console.warn(`Retrying chunk load (attempt ${attempt + 1})`) + } + } + throw new Error('Failed to load chunk') +} + +const routes = [ + { + path: '/dashboard', + async action() { + try { + const module = await loadWithRetry(() => import('./pages/Dashboard')) + return + } catch (error) { + // Return error component or redirect + return navigate('/dashboard')} /> + } + }, + }, +] +``` + +## Prefetching with Intersection Observer + +Prefetch chunks when links become visible: + +```tsx +function usePrefetchOnVisible(paths: string[]) { + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const link = entry.target as HTMLAnchorElement + preload(link.pathname) + observer.unobserve(link) + } + }) + }, + { rootMargin: '100px' }, // Prefetch when 100px from viewport + ) + + paths.forEach((path) => { + const link = document.querySelector(`a[href="${path}"]`) + if (link) observer.observe(link) + }) + + return () => observer.disconnect() + }, [paths]) +} +``` + +## Server-Side Rendering Considerations + +When using SSR with code splitting, ensure chunks are preloaded on the client: + +```tsx +// server.tsx +import { renderToString } from 'react-dom/server' + +app.get('*', async (req, res) => { + const router = new UniversalRouter(routes) + const page = await router.resolve(req.path) + + const html = renderToString(page.component) + + // Track which chunks were used during rendering + const usedChunks = collectChunks() // Implementation depends on your setup + + res.send(` + + + + ${usedChunks + .map((chunk) => ``) + .join('\n')} + + +
${html}
+ + + + `) +}) +``` + +## Common Pitfalls + +### 1. Importing at Module Level + +Dynamic imports must be inside functions to enable code splitting: + +```js +// Wrong - imports at module level, no code splitting +import Admin from './pages/Admin' +const routes = [{ path: '/admin', action: () => }] + +// Correct - dynamic import in action +const routes = [ + { + path: '/admin', + async action() { + const { default: Admin } = await import('./pages/Admin') + return + }, + }, +] +``` + +### 2. Not Handling Loading States + +Users see a blank screen while chunks load: + +```js +// Wrong - no loading feedback +async function navigate(path) { + const page = await router.resolve(path) + render(page) +} + +// Correct - show loading state +async function navigate(path) { + showLoadingIndicator() + try { + const page = await router.resolve(path) + render(page) + } finally { + hideLoadingIndicator() + } +} +``` + +### 3. Over-Splitting + +Too many small chunks can hurt performance due to HTTP overhead: + +```js +// Probably too granular - each tiny component is its own chunk +const Button = lazy(() => import('./Button')) +const Icon = lazy(() => import('./Icon')) + +// Better - split at route level, not component level +const Dashboard = lazy(() => import('./pages/Dashboard')) +``` + +### 4. Duplicate Dependencies in Chunks + +Ensure shared dependencies are extracted: + +```js +// webpack.config.js +optimization: { + splitChunks: { + cacheGroups: { + // Extract shared dependencies into a common chunk + common: { + name: 'common', + minChunks: 2, // Used by at least 2 chunks + chunks: 'all', + priority: -10, + }, + }, + }, +} +``` + +## See Also + +- [SPA Navigation](./spa-navigation.md) - Client-side routing basics +- [Isomorphic Routing](./isomorphic-routing.md) - Server-side rendering +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/declarative-routing.md b/docs/declarative-routing.md new file mode 100644 index 0000000..86b51ba --- /dev/null +++ b/docs/declarative-routing.md @@ -0,0 +1,715 @@ +# Declarative Routing + +Universal Router supports a declarative approach where route configuration contains metadata +about what to render rather than imperative action functions. This pattern separates route +definitions from rendering logic, making routes more portable and easier to maintain. + +## Understanding Declarative vs. Imperative Routing + +**Imperative routing** embeds logic directly in routes: + +```js +const routes = [ + { + path: '/users/:id', + action: async (context) => { + const user = await fetchUser(context.params.id) + return renderTemplate('user', { user }) + }, + }, +] +``` + +**Declarative routing** separates what from how: + +```js +const routes = [ + { + path: '/users/:id', + page: './pages/user', + data: '/api/users/:id', + }, +] + +// Rendering logic is centralized elsewhere +``` + +## Basic Declarative Setup + +Define routes as pure data, then use `resolveRoute` to interpret them: + +```ts +import UniversalRouter, { RouteContext, RouteParams } from 'universal-router' + +// Declarative route definition +interface DeclarativeRoute { + path: string + page: string + data?: string + layout?: string + meta?: { + title?: string + description?: string + auth?: boolean + } +} + +const routes: DeclarativeRoute[] = [ + { + path: '/', + page: './pages/home', + meta: { title: 'Home' }, + }, + { + path: '/about', + page: './pages/about', + meta: { title: 'About Us' }, + }, + { + path: '/users/:id', + page: './pages/user', + data: '/api/users/:id', + meta: { title: 'User Profile', auth: true }, + }, +] + +// Result type returned from resolution +interface PageResult { + component: React.ComponentType + data: unknown + meta: DeclarativeRoute['meta'] +} + +const router = new UniversalRouter(routes, { + async resolveRoute( + context: RouteContext, + params: RouteParams, + ): Promise { + const route = context.route as unknown as DeclarativeRoute + + if (!route.page) { + return undefined // Continue to children or next route + } + + // Load page component dynamically + const module = await import(route.page) + const Component = module.default + + // Fetch data if specified + let data = null + if (route.data) { + const dataUrl = interpolatePath(route.data, params) + const response = await fetch(dataUrl) + data = await response.json() + } + + return { + component: Component, + data, + meta: route.meta, + } + }, +}) + +// Helper to replace :param with actual values +function interpolatePath(path: string, params: RouteParams): string { + return path.replace(/:(\w+)/g, (_, key) => String(params[key] || '')) +} +``` + +## Client-Side Implementation + +Full client-side implementation with code splitting and data fetching: + +```ts +import UniversalRouter, { RouteContext, RouteParams } from 'universal-router' + +interface DeclarativeRoute { + path: string + page?: string + data?: string | ((params: RouteParams) => Promise) + children?: DeclarativeRoute[] + meta?: { + title?: string + auth?: boolean + roles?: string[] + } +} + +interface ResolvedPage { + Component: React.ComponentType<{ data?: unknown }> + data: unknown + meta: DeclarativeRoute['meta'] +} + +const routes: DeclarativeRoute[] = [ + { + path: '/', + page: './pages/home', + meta: { title: 'Home' }, + }, + { + path: '/products', + children: [ + { + path: '', + page: './pages/products', + data: '/api/products', + meta: { title: 'Products' }, + }, + { + path: '/:productId', + page: './pages/product', + data: '/api/products/:productId', + meta: { title: 'Product Details' }, + }, + ], + }, + { + path: '/admin', + meta: { auth: true, roles: ['admin'] }, + children: [ + { + path: '', + page: './pages/admin/dashboard', + meta: { title: 'Admin Dashboard' }, + }, + { + path: '/users', + page: './pages/admin/users', + data: '/api/admin/users', + meta: { title: 'Manage Users' }, + }, + ], + }, +] + +const router = new UniversalRouter(routes, { + async resolveRoute(context, params) { + const route = context.route as unknown as DeclarativeRoute + + // Check authentication + if (route.meta?.auth) { + const user = await getCurrentUser() + if (!user) { + throw { status: 401, redirect: '/login' } + } + if (route.meta.roles && !route.meta.roles.includes(user.role)) { + throw { status: 403, message: 'Forbidden' } + } + } + + // If no page, continue to children + if (!route.page) { + return undefined + } + + // Load page and data in parallel + const [module, data] = await Promise.all([ + import(/* webpackChunkName: "[request]" */ route.page), + fetchRouteData(route.data, params), + ]) + + return { + Component: module.default, + data, + meta: route.meta, + } + }, +}) + +async function fetchRouteData( + dataConfig: DeclarativeRoute['data'], + params: RouteParams, +): Promise { + if (!dataConfig) return null + + if (typeof dataConfig === 'function') { + return dataConfig(params) + } + + const url = dataConfig.replace(/:(\w+)/g, (_, key) => + String(params[key] || ''), + ) + const response = await fetch(url) + return response.json() +} +``` + +## Server-Side Implementation + +On the server, load modules synchronously and render to HTML: + +```ts +import express from 'express' +import UniversalRouter from 'universal-router' +import { renderToString } from 'react-dom/server' + +interface DeclarativeRoute { + path: string + page?: string + data?: string + layout?: string + meta?: { title?: string; description?: string } +} + +const routes: DeclarativeRoute[] = [ + { path: '/', page: './pages/home', meta: { title: 'Home' } }, + { path: '/about', page: './pages/about', meta: { title: 'About' } }, + { + path: '/posts/:id', + page: './pages/post', + data: '/api/posts/:id', + meta: { title: 'Blog Post' }, + }, +] + +const router = new UniversalRouter(routes, { + async resolveRoute(context, params) { + const route = context.route as unknown as DeclarativeRoute + + if (!route.page) return undefined + + // Server-side: require instead of dynamic import + const Page = require(route.page).default + + // Fetch data + let data = null + if (route.data) { + const url = route.data.replace(/:(\w+)/g, (_, k) => params[k] as string) + data = await fetchServerSide(url) + } + + // Render to HTML + const html = renderToString() + const meta = route.meta || {} + + return ` + + + + ${meta.title || 'App'} + ${meta.description ? `` : ''} + + + +
${html}
+ + + + ` + }, +}) + +const app = express() + +app.get('*', async (req, res, next) => { + try { + const html = await router.resolve(req.path) + res.send(html) + } catch (error: any) { + if (error.status === 404) { + res.status(404).send('Not Found') + } else { + next(error) + } + } +}) + +app.listen(3000) +``` + +## Route Metadata Patterns + +### Page Components + +Define which component renders for each route: + +```ts +interface Route { + path: string + page: string // Path to component module + // OR + component: React.ComponentType // Direct component reference +} +``` + +### Data Dependencies + +Specify data requirements declaratively: + +```ts +interface Route { + path: string + page: string + + // URL pattern for API endpoint + data?: string + + // Or function for complex data fetching + loader?: (params: RouteParams) => Promise + + // Multiple data sources + loaders?: { + user: (params: RouteParams) => Promise + posts: (params: RouteParams) => Promise + } +} +``` + +### Layout Configuration + +Specify layouts per route: + +```ts +interface Route { + path: string + page: string + layout?: 'default' | 'fullWidth' | 'admin' | 'none' + // Or nested layouts + layouts?: string[] // ['root', 'admin', 'dashboard'] +} +``` + +### Authorization Rules + +Declare access requirements: + +```ts +interface Route { + path: string + page: string + meta?: { + auth?: boolean | 'guest' | 'user' | 'admin' + roles?: string[] + permissions?: string[] + } +} +``` + +## Complete Declarative Router + +Here is a comprehensive implementation combining all patterns: + +```ts +import UniversalRouter, { RouteContext, RouteParams } from 'universal-router' + +// Route definition types +interface RouteMeta { + title?: string + description?: string + canonical?: string + robots?: string +} + +interface RouteAuth { + required?: boolean + roles?: string[] + permissions?: string[] + redirectTo?: string +} + +interface DeclarativeRoute { + path: string + name?: string + page?: string + data?: string | DataLoader + layout?: string + meta?: RouteMeta + auth?: RouteAuth + children?: DeclarativeRoute[] +} + +type DataLoader = (params: RouteParams, context: AppContext) => Promise + +interface AppContext { + user: User | null + locale: string +} + +interface ResolvedPage { + Component: React.ComponentType<{ data?: unknown }> + Layout: React.ComponentType<{ children: React.ReactNode }> + data: unknown + meta: RouteMeta +} + +// Layout registry +const layouts: Record> = { + default: DefaultLayout, + admin: AdminLayout, + fullWidth: FullWidthLayout, + none: ({ children }) => <>{children}, +} + +// Route definitions +const routes: DeclarativeRoute[] = [ + { + path: '/', + page: './pages/home', + layout: 'default', + meta: { title: 'Welcome' }, + }, + { + path: '/dashboard', + layout: 'admin', + auth: { required: true }, + children: [ + { + path: '', + page: './pages/dashboard', + meta: { title: 'Dashboard' }, + }, + { + path: '/analytics', + page: './pages/analytics', + data: async (params, context) => { + return fetchAnalytics(context.user!.id) + }, + meta: { title: 'Analytics' }, + auth: { permissions: ['analytics:view'] }, + }, + ], + }, + { + path: '/posts/:id', + page: './pages/post', + data: '/api/posts/:id', + layout: 'default', + meta: { title: 'Blog Post' }, + }, +] + +// Create router +function createDeclarativeRouter(appContext: AppContext) { + return new UniversalRouter(routes, { + context: appContext, + + async resolveRoute(context, params) { + const route = context.route as unknown as DeclarativeRoute + + // Authorization check + if (route.auth?.required && !appContext.user) { + throw { + status: 401, + redirect: route.auth.redirectTo || '/login', + } + } + + if (route.auth?.roles?.length) { + const hasRole = route.auth.roles.some((r) => + appContext.user?.roles.includes(r) + ) + if (!hasRole) { + throw { status: 403, message: 'Forbidden' } + } + } + + if (route.auth?.permissions?.length) { + const hasPermission = route.auth.permissions.every((p) => + appContext.user?.permissions.includes(p) + ) + if (!hasPermission) { + throw { status: 403, message: 'Insufficient permissions' } + } + } + + // No page means this is a layout wrapper - continue to children + if (!route.page) { + return undefined + } + + // Load component and data in parallel + const [module, data] = await Promise.all([ + import(route.page), + loadRouteData(route.data, params, appContext), + ]) + + return { + Component: module.default, + Layout: layouts[route.layout || 'default'], + data, + meta: route.meta || {}, + } + }, + + errorHandler(error, context) { + if (error.redirect) { + return { + Component: () => null, + Layout: layouts.none, + data: null, + meta: {}, + redirect: error.redirect, + } as ResolvedPage & { redirect: string } + } + + return { + Component: ErrorPage, + Layout: layouts.default, + data: { error }, + meta: { title: 'Error' }, + } + }, + }) +} + +async function loadRouteData( + dataConfig: DeclarativeRoute['data'], + params: RouteParams, + context: AppContext +): Promise { + if (!dataConfig) return null + + if (typeof dataConfig === 'function') { + return dataConfig(params, context) + } + + // URL pattern - interpolate params + const url = dataConfig.replace(/:(\w+)/g, (_, key) => String(params[key] || '')) + const response = await fetch(url) + return response.json() +} + +// Usage +const router = createDeclarativeRouter({ + user: currentUser, + locale: 'en', +}) + +const result = await router.resolve('/dashboard/analytics') +``` + +## React Integration + +Render declarative routes in React: + +```tsx +import { useState, useEffect } from 'react' + +function App() { + const [page, setPage] = useState(null) + const [loading, setLoading] = useState(true) + + async function navigate(pathname: string) { + setLoading(true) + try { + const result = await router.resolve(pathname) + + // Handle redirects + if ('redirect' in result) { + window.history.replaceState(null, '', result.redirect) + return navigate(result.redirect) + } + + setPage(result) + + // Update document meta + if (result.meta.title) { + document.title = result.meta.title + } + } catch (error) { + console.error('Navigation error:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + navigate(window.location.pathname) + + const handlePopState = () => navigate(window.location.pathname) + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, []) + + if (loading || !page) { + return + } + + const { Component, Layout, data } = page + + return ( + + + + ) +} +``` + +## Benefits of Declarative Routing + +1. **Separation of concerns**: Route configuration is separate from rendering logic +2. **Portability**: Routes can be serialized, stored, or generated dynamically +3. **Testability**: Routes are pure data, easy to test +4. **Code splitting**: Dynamic imports based on route configuration +5. **Consistency**: Centralized handling of auth, data loading, layouts +6. **Documentation**: Routes serve as documentation for available pages + +## Common Pitfalls + +### 1. Not Returning Undefined for Parent Routes + +Parent routes without a page should return `undefined` to continue matching: + +```ts +// Wrong - returns value, stops matching +{ + resolveRoute(context) { + const route = context.route + if (!route.page) { + return null // Stops matching! + } + } +} + +// Correct - returns undefined to continue +{ + resolveRoute(context) { + const route = context.route + if (!route.page) { + return undefined // Continues to children + } + } +} +``` + +### 2. Blocking Data Fetches + +Load data in parallel with components: + +```ts +// Slow - sequential loading +const module = await import(route.page) +const data = await fetchData(route.data) + +// Fast - parallel loading +const [module, data] = await Promise.all([ + import(route.page), + fetchData(route.data), +]) +``` + +### 3. Missing Error Boundaries + +Always handle errors gracefully: + +```ts +const router = new UniversalRouter(routes, { + errorHandler(error, context) { + // Return error page instead of throwing + return { + Component: ErrorPage, + data: { error }, + meta: { title: 'Error' }, + } + }, +}) +``` + +## See Also + +- [Code Splitting](./code-splitting.md) - Dynamic imports for route components +- [Authorization](./authorization.md) - Route-based access control +- [Isomorphic Routing](./isomorphic-routing.md) - Server and client rendering +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/hmr.md b/docs/hmr.md new file mode 100644 index 0000000..6230789 --- /dev/null +++ b/docs/hmr.md @@ -0,0 +1,572 @@ +# Hot Module Replacement (HMR) + +Hot Module Replacement allows you to update code during development without losing +application state or requiring a full page reload. Universal Router works well with +HMR, but requires some setup to handle route updates properly. + +## How HMR Works with Universal Router + +When your route files change, HMR replaces the old modules with new ones. The challenge +is updating the router instance with the new routes. There are two main approaches: + +1. **Recreate the router instance** - Simple and reliable +2. **Replace the routes in place** - Preserves router state + +For most applications, recreating the router instance is recommended because it's +simpler and Universal Router instances are stateless (aside from route caching). + +## Vite HMR Setup (Recommended) + +Vite is the modern standard for frontend tooling. It uses native ES modules and +provides faster HMR than Webpack. Use `import.meta.hot` for HMR handling. + +### Basic Setup + +```ts +// routes.ts +const routes = [ + { path: '/', action: () => import('./pages/Home') }, + { path: '/about', action: () => import('./pages/About') }, +] + +export default routes +``` + +```ts +// router.ts +import UniversalRouter from 'universal-router' +import routes from './routes' + +let router = new UniversalRouter(routes) + +export function getRouter() { + return router +} + +export function updateRoutes(newRoutes: typeof routes) { + router = new UniversalRouter(newRoutes) +} + +// Vite HMR - use import.meta.hot (ESM syntax) +if (import.meta.hot) { + import.meta.hot.accept('./routes', (newModule) => { + if (newModule) { + updateRoutes(newModule.default) + console.log('[HMR] Routes updated') + // Trigger re-render + window.dispatchEvent(new CustomEvent('routes-updated')) + } + }) +} +``` + +```ts +// main.ts +import { getRouter } from './router' + +async function render() { + const router = getRouter() + const result = await router.resolve(window.location.pathname) + document.getElementById('app')!.innerHTML = result +} + +// Initial render +render() + +// Re-render on HMR +window.addEventListener('routes-updated', render) + +// Re-render on navigation +window.addEventListener('popstate', render) +``` + +## Webpack HMR Setup (Legacy) + +For projects still using Webpack, use `module.hot` for HMR handling. + +### Basic Setup + +```ts +// routes.ts +import UniversalRouter from 'universal-router' + +const routes = [ + { path: '/', action: () => import('./pages/Home') }, + { path: '/about', action: () => import('./pages/About') }, + { path: '/dashboard', action: () => import('./pages/Dashboard') }, +] + +export default routes +``` + +```ts +// router.ts +import UniversalRouter from 'universal-router' +import routes from './routes' + +let router = new UniversalRouter(routes) + +export function getRouter() { + return router +} + +// Webpack HMR - use module.hot (CommonJS syntax) +if (module.hot) { + module.hot.accept('./routes', () => { + const nextRoutes = require('./routes').default + router = new UniversalRouter(nextRoutes) + console.log('[HMR] Routes updated') + }) +} +``` + +```ts +// client.ts +import { getRouter } from './router' + +async function render(pathname: string) { + const router = getRouter() + const result = await router.resolve(pathname) + document.getElementById('app')!.innerHTML = result +} + +// Initial render +render(window.location.pathname) + +// Re-render on route changes +window.addEventListener('popstate', () => { + render(window.location.pathname) +}) + +// Re-render on HMR update +if (module.hot) { + module.hot.accept('./router', () => { + render(window.location.pathname) + }) +} +``` + +### With React (Webpack) + +For React applications using Webpack, combine HMR with React's rendering: + +```tsx +// App.tsx +import { useState, useEffect, useCallback } from 'react' +import UniversalRouter from 'universal-router' +import routes from './routes' + +let router = new UniversalRouter(routes) + +export default function App() { + const [content, setContent] = useState(null) + const [pathname, setPathname] = useState(window.location.pathname) + + const navigate = useCallback(async (path: string) => { + const result = await router.resolve(path) + setContent(result) + setPathname(path) + }, []) + + useEffect(() => { + navigate(window.location.pathname) + }, [navigate]) + + useEffect(() => { + const handlePopState = () => navigate(window.location.pathname) + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, [navigate]) + + return
{content}
+} + +// Webpack HMR for routes +if (module.hot) { + module.hot.accept('./routes', () => { + const nextRoutes = require('./routes').default + router = new UniversalRouter(nextRoutes) + // Trigger re-render by navigating to current path + window.dispatchEvent(new PopStateEvent('popstate')) + }) +} +``` + +### Preserving Router State + +If you need to preserve router state (like cached match functions), you can +replace routes in place: + +```ts +// router.ts +import UniversalRouter from 'universal-router' +import routes from './routes' + +const router = new UniversalRouter(routes) + +export default router + +if (module.hot) { + module.hot.accept('./routes', () => { + const nextRoutes = require('./routes').default + + // Replace the root route's children + // This preserves any router-level state + router.root = Array.isArray(nextRoutes) + ? { path: '', children: nextRoutes, parent: null } + : nextRoutes + router.root.parent = null + + console.log('[HMR] Routes replaced') + }) +} +``` + +## Dynamic Imports and HMR + +When using code-splitting with dynamic imports, HMR for page components works +automatically because the imports are re-executed: + +```ts +// routes.ts +const routes = [ + { + path: '/dashboard', + async action() { + // This import is re-executed on HMR, loading the updated module + const { default: Dashboard } = await import('./pages/Dashboard') + return + }, + }, +] +``` + +This works because: + +1. When `Dashboard.tsx` changes, Vite/Webpack invalidates that module +2. The next time `/dashboard` is visited, the import fetches the new module +3. No special HMR handling needed for the page component itself + +### Immediate Updates for Dynamic Routes + +If you want changes to reflect immediately (without re-navigating), add HMR +handling in the page component: + +```tsx +// pages/Dashboard.tsx +export default function Dashboard() { + const [data, setData] = useState(null) + + useEffect(() => { + // Fetch data... + }, []) + + return
Dashboard content
+} + +// This makes the component update in place on HMR +if (import.meta.hot) { + import.meta.hot.accept() +} +``` + +## Context and State Preservation + +Preserve context data across HMR updates: + +```ts +// context.ts +// This module is not accepted by HMR, so it persists across updates +export const appContext = { + user: null as User | null, + theme: 'light' as 'light' | 'dark', + locale: 'en', +} + +export function setUser(user: User | null) { + appContext.user = user +} +``` + +```ts +// router.ts +import UniversalRouter from 'universal-router' +import routes from './routes' +import { appContext } from './context' + +let router = new UniversalRouter(routes, { + context: appContext, +}) + +export function getRouter() { + return router +} + +if (import.meta.hot) { + import.meta.hot.accept('./routes', (newModule) => { + if (newModule) { + // Context is preserved because it's in a separate module + router = new UniversalRouter(newModule.default, { + context: appContext, + }) + window.dispatchEvent(new CustomEvent('routes-updated')) + } + }) +} +``` + +## Development vs Production + +Ensure HMR code is stripped in production builds: + +```ts +// router.ts +import UniversalRouter from 'universal-router' +import routes from './routes' + +let router = new UniversalRouter(routes) + +export function getRouter() { + return router +} + +// This entire block is removed in production builds +if (import.meta.env.DEV && import.meta.hot) { + import.meta.hot.accept('./routes', (newModule) => { + if (newModule) { + router = new UniversalRouter(newModule.default) + window.dispatchEvent(new CustomEvent('routes-updated')) + } + }) +} +``` + +For Webpack: + +```ts +if (process.env.NODE_ENV === 'development' && module.hot) { + module.hot.accept('./routes', () => { + const nextRoutes = require('./routes').default + router = new UniversalRouter(nextRoutes) + }) +} +``` + +## Full Example: Vite + React + TypeScript + +Here's a complete example with all pieces together: + +```ts +// src/routes.tsx +import type { ReactNode } from 'react' +import type { Route } from 'universal-router' + +const routes: Route[] = [ + { + path: '/', + async action() { + const { default: Home } = await import('./pages/Home') + return + }, + }, + { + path: '/about', + async action() { + const { default: About } = await import('./pages/About') + return + }, + }, + { + path: '/users/:id', + async action(context) { + const { default: UserProfile } = await import('./pages/UserProfile') + return + }, + }, +] + +export default routes +``` + +```tsx +// src/router.ts +import UniversalRouter from 'universal-router' +import type { ReactNode } from 'react' +import routes from './routes' + +let router = new UniversalRouter(routes) + +export function getRouter() { + return router +} + +// Development-only HMR +if (import.meta.env.DEV && import.meta.hot) { + import.meta.hot.accept('./routes', (newModule) => { + if (newModule) { + router = new UniversalRouter(newModule.default) + window.dispatchEvent(new CustomEvent('routes-updated')) + console.log('[HMR] Routes updated') + } + }) +} +``` + +```tsx +// src/App.tsx +import { useState, useEffect, useCallback } from 'react' +import { getRouter } from './router' + +export default function App() { + const [content, setContent] = useState(null) + const [error, setError] = useState(null) + + const navigate = useCallback(async (pathname: string) => { + try { + setError(null) + const result = await getRouter().resolve(pathname) + setContent(result) + } catch (err: any) { + if (err.status === 404) { + setError('Page not found') + } else { + setError('An error occurred') + console.error(err) + } + } + }, []) + + useEffect(() => { + // Initial navigation + navigate(window.location.pathname) + + // Handle browser back/forward + const handlePopState = () => navigate(window.location.pathname) + window.addEventListener('popstate', handlePopState) + + // Handle HMR route updates + const handleRoutesUpdated = () => navigate(window.location.pathname) + window.addEventListener('routes-updated', handleRoutesUpdated) + + return () => { + window.removeEventListener('popstate', handlePopState) + window.removeEventListener('routes-updated', handleRoutesUpdated) + } + }, [navigate]) + + if (error) { + return
{error}
+ } + + return <>{content} +} +``` + +```tsx +// src/main.tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) +``` + +## Common Pitfalls + +### 1. Module Cache Issues + +Old module references might be cached. Ensure you're getting fresh imports: + +```ts +// Wrong - might use cached module +import routes from './routes' +let router = new UniversalRouter(routes) + +if (module.hot) { + module.hot.accept('./routes', () => { + // 'routes' still points to old module! + router = new UniversalRouter(routes) + }) +} + +// Correct - use require to get fresh module +if (module.hot) { + module.hot.accept('./routes', () => { + const nextRoutes = require('./routes').default + router = new UniversalRouter(nextRoutes) + }) +} +``` + +### 2. Not Re-rendering After Update + +Creating a new router isn't enough - you need to trigger a re-render: + +```ts +// Wrong - updates router but page shows old content +if (module.hot) { + module.hot.accept('./routes', () => { + router = new UniversalRouter(require('./routes').default) + }) +} + +// Correct - trigger re-render +if (module.hot) { + module.hot.accept('./routes', () => { + router = new UniversalRouter(require('./routes').default) + // Trigger re-render + window.dispatchEvent(new PopStateEvent('popstate')) + }) +} +``` + +### 3. Circular Dependencies + +HMR can expose circular dependency issues. Keep router creation separate: + +```ts +// Bad - circular dependency risk +// routes.ts imports components that import routes.ts + +// Good - separate concerns +// routes.ts only defines route structure +// router.ts creates the router instance +// components.ts contains components +``` + +### 4. Memory Leaks from Event Listeners + +Clean up event listeners in HMR: + +```ts +let cleanupFn: (() => void) | null = null + +function setup() { + const handler = () => { + /* ... */ + } + window.addEventListener('popstate', handler) + + // Return cleanup function + return () => window.removeEventListener('popstate', handler) +} + +cleanupFn = setup() + +if (module.hot) { + module.hot.dispose(() => { + // Clean up before module is replaced + cleanupFn?.() + }) +} +``` + +## See Also + +- [Code Splitting](./code-splitting.md) - Lazy loading routes +- [SPA Navigation](./spa-navigation.md) - Client-side routing +- [Getting Started](./getting-started.md) - Basic setup diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..6f06c77 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,735 @@ +# Internationalization and Localized Routes + +Universal Router supports multiple strategies for internationalized (i18n) routing. This recipe +covers language prefix URLs, localized path translations, route aliases, and integrations with +popular i18n libraries. + +## Language Prefix Strategy + +The most common i18n pattern adds a language code prefix to URLs: + +``` +/en/about +/fr/about +/de/about +``` + +### Basic Implementation + +```ts +import UniversalRouter, { RouteContext } from 'universal-router' + +const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'es'] as const +const DEFAULT_LOCALE = 'en' + +type Locale = (typeof SUPPORTED_LOCALES)[number] + +interface I18nContext { + locale: Locale + t: (key: string) => string +} + +// Translations +const translations: Record> = { + en: { + 'home.title': 'Welcome', + 'about.title': 'About Us', + }, + fr: { + 'home.title': 'Bienvenue', + 'about.title': 'A propos', + }, + de: { + 'home.title': 'Willkommen', + 'about.title': 'Uber uns', + }, + es: { + 'home.title': 'Bienvenido', + 'about.title': 'Sobre nosotros', + }, +} + +function createTranslator(locale: Locale) { + return (key: string): string => { + return ( + translations[locale]?.[key] || translations[DEFAULT_LOCALE][key] || key + ) + } +} + +const routes = [ + { + // Match locale prefix + path: '/:locale', + action(context: RouteContext) { + const locale = context.params.locale as Locale + + // Validate locale + if (!SUPPORTED_LOCALES.includes(locale)) { + return null // Skip, let other routes handle it + } + + // Set locale in context for child routes + context.locale = locale + context.t = createTranslator(locale) + + // Continue to children + return context.next() + }, + children: [ + { + path: '', + action: (ctx: RouteContext) => { + return `

${ctx.t('home.title')}

` + }, + }, + { + path: '/about', + action: (ctx: RouteContext) => { + return `

${ctx.t('about.title')}

` + }, + }, + ], + }, + { + // Redirect root to default locale + path: '/', + action: () => ({ redirect: `/${DEFAULT_LOCALE}` }), + }, +] + +const router = new UniversalRouter(routes) + +// Usage +await router.resolve('/en/about') // '

About Us

' +await router.resolve('/fr/about') // '

A propos

' +await router.resolve('/') // { redirect: '/en' } +``` + +### Locale Detection and Redirect + +Detect user's preferred locale from headers or browser settings: + +```ts +function detectLocale(request?: { + headers?: { 'accept-language'?: string } +}): Locale { + // Server-side: use Accept-Language header + if (request?.headers?.['accept-language']) { + const acceptLanguage = request.headers['accept-language'] + const preferred = acceptLanguage.split(',')[0]?.split('-')[0] + if (preferred && SUPPORTED_LOCALES.includes(preferred as Locale)) { + return preferred as Locale + } + } + + // Client-side: use navigator.language + if (typeof navigator !== 'undefined') { + const browserLang = navigator.language.split('-')[0] + if (SUPPORTED_LOCALES.includes(browserLang as Locale)) { + return browserLang as Locale + } + } + + return DEFAULT_LOCALE +} + +const routes = [ + { + path: '/', + action(context) { + const locale = detectLocale(context.request) + return { redirect: `/${locale}` } + }, + }, + // ... locale-prefixed routes +] +``` + +## Localized Path Translation + +For fully localized URLs where paths themselves are translated: + +``` +/en/about -> About page (English) +/fr/a-propos -> About page (French) +/de/uber-uns -> About page (German) +``` + +### Implementation with Path Aliases + +```ts +interface LocalizedRoute { + name: string + paths: Record + action: (context: RouteContext) => any +} + +const localizedRoutes: LocalizedRoute[] = [ + { + name: 'home', + paths: { + en: '', + fr: '', + de: '', + es: '', + }, + action: (ctx) => renderHome(ctx.locale), + }, + { + name: 'about', + paths: { + en: '/about', + fr: '/a-propos', + de: '/uber-uns', + es: '/acerca-de', + }, + action: (ctx) => renderAbout(ctx.locale), + }, + { + name: 'contact', + paths: { + en: '/contact', + fr: '/contactez-nous', + de: '/kontakt', + es: '/contacto', + }, + action: (ctx) => renderContact(ctx.locale), + }, + { + name: 'products', + paths: { + en: '/products/:category', + fr: '/produits/:category', + de: '/produkte/:category', + es: '/productos/:category', + }, + action: (ctx) => renderProducts(ctx.locale, ctx.params.category), + }, +] + +// Generate routes for all locales +function generateI18nRoutes(localizedRoutes: LocalizedRoute[]) { + return SUPPORTED_LOCALES.map((locale) => ({ + path: `/${locale}`, + action(context: RouteContext) { + context.locale = locale + context.t = createTranslator(locale) + return context.next() + }, + children: localizedRoutes.map((route) => ({ + name: `${locale}.${route.name}`, + path: route.paths[locale], + action: route.action, + })), + })) +} + +const routes = [ + ...generateI18nRoutes(localizedRoutes), + { + path: '/', + action: () => ({ redirect: `/${DEFAULT_LOCALE}` }), + }, +] + +const router = new UniversalRouter(routes) +``` + +### URL Generation for Localized Routes + +Generate URLs respecting the current or target locale: + +```ts +import generateUrls from 'universal-router/generate-urls' + +const url = generateUrls(router, { uniqueRouteNameSep: '.' }) + +// Generate URL for specific locale +function localizedUrl( + routeName: string, + params?: Record, + targetLocale?: Locale, +): string { + const locale = targetLocale || currentLocale + return url(`${locale}.${routeName}`, params) +} + +// Usage +localizedUrl('about') // '/en/about' (if currentLocale is 'en') +localizedUrl('about', {}, 'fr') // '/fr/a-propos' +localizedUrl('products', { category: 'shoes' }, 'de') // '/de/produkte/shoes' +``` + +## Hreflang Tags + +Generate hreflang tags for SEO to indicate alternate language versions: + +```ts +interface HreflangTag { + locale: Locale + url: string +} + +function generateHreflangTags( + routeName: string, + params?: Record, + baseUrl = 'https://example.com', +): HreflangTag[] { + return SUPPORTED_LOCALES.map((locale) => ({ + locale, + url: `${baseUrl}${localizedUrl(routeName, params, locale)}`, + })) +} + +// In your page rendering +function renderPage(routeName: string, params?: Record) { + const hreflangs = generateHreflangTags(routeName, params) + + const hreflangHtml = hreflangs + .map( + ({ locale, url }) => + ``, + ) + .join('\n') + + // Also add x-default for language selection page + const defaultUrl = `https://example.com/${localizedUrl(routeName, params, DEFAULT_LOCALE)}` + const xDefault = `` + + return ` + + ${hreflangHtml} + ${xDefault} + + ` +} +``` + +## Integration with react-intl + +Use Universal Router with react-intl for message formatting: + +```tsx +import { IntlProvider, FormattedMessage, useIntl } from 'react-intl' +import UniversalRouter, { RouteContext } from 'universal-router' + +// Message catalogs +const messages: Record> = { + en: { + 'nav.home': 'Home', + 'nav.about': 'About', + 'page.home.title': 'Welcome to our site', + 'page.about.title': 'Learn about us', + }, + fr: { + 'nav.home': 'Accueil', + 'nav.about': 'A propos', + 'page.home.title': 'Bienvenue sur notre site', + 'page.about.title': 'En savoir plus sur nous', + }, +} + +// Route components +function HomePage() { + return ( +
+

+ +

+
+ ) +} + +function AboutPage() { + return ( +
+

+ +

+
+ ) +} + +// Router setup +interface AppContext { + locale: Locale +} + +const routes = [ + { + path: '/:locale', + action(context: RouteContext) { + const locale = context.params.locale as Locale + if (!SUPPORTED_LOCALES.includes(locale)) { + return null + } + context.locale = locale + return context.next() + }, + children: [ + { path: '', action: () => }, + { path: '/about', action: () => }, + ], + }, +] + +const router = new UniversalRouter(routes) + +// App wrapper +function App() { + const [locale, setLocale] = useState(DEFAULT_LOCALE) + const [page, setPage] = useState(null) + + useEffect(() => { + async function navigate() { + const result = await router.resolve({ + pathname: window.location.pathname, + }) + // Extract locale from the resolution context + const pathLocale = window.location.pathname.split('/')[1] as Locale + if (SUPPORTED_LOCALES.includes(pathLocale)) { + setLocale(pathLocale) + } + setPage(result) + } + navigate() + }, []) + + return ( + + + {page} + + ) +} +``` + +## Integration with i18next + +Use Universal Router with i18next: + +```ts +import i18next from 'i18next' +import UniversalRouter from 'universal-router' + +// Initialize i18next +await i18next.init({ + lng: DEFAULT_LOCALE, + resources: { + en: { + translation: { + home: { title: 'Welcome' }, + about: { title: 'About Us' }, + }, + }, + fr: { + translation: { + home: { title: 'Bienvenue' }, + about: { title: 'A propos' }, + }, + }, + }, +}) + +const routes = [ + { + path: '/:locale', + async action(context) { + const locale = context.params.locale + if (!SUPPORTED_LOCALES.includes(locale)) { + return null + } + + // Change i18next language + await i18next.changeLanguage(locale) + + return context.next() + }, + children: [ + { + path: '', + action: () => `

${i18next.t('home.title')}

`, + }, + { + path: '/about', + action: () => `

${i18next.t('about.title')}

`, + }, + ], + }, +] + +const router = new UniversalRouter(routes) +``` + +## Language Switcher Component + +Create a language switcher that preserves the current route: + +```tsx +function LanguageSwitcher({ + currentLocale, + currentRouteName, + currentParams, +}: { + currentLocale: Locale + currentRouteName: string + currentParams?: Record +}) { + return ( + + ) +} + +function getLanguageName(locale: Locale): string { + const names: Record = { + en: 'English', + fr: 'Francais', + de: 'Deutsch', + es: 'Espanol', + } + return names[locale] +} + +// Usage in route action +{ + path: '/about', + name: 'about', + action: (context) => { + return ( +
+ +

{context.t('about.title')}

+
+ ) + } +} +``` + +## Server-Side Locale Detection + +Comprehensive locale detection for server-side rendering: + +```ts +import express from 'express' +import UniversalRouter from 'universal-router' + +const app = express() + +// Locale detection middleware +app.use((req, res, next) => { + // Priority: URL > Cookie > Accept-Language > Default + const urlLocale = req.path.split('/')[1] + if (SUPPORTED_LOCALES.includes(urlLocale as Locale)) { + req.locale = urlLocale as Locale + return next() + } + + // Check cookie + const cookieLocale = req.cookies?.locale + if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) { + req.locale = cookieLocale + return res.redirect(`/${cookieLocale}${req.path}`) + } + + // Parse Accept-Language + const acceptLanguage = req.headers['accept-language'] || '' + const preferredLocales = acceptLanguage + .split(',') + .map((lang) => lang.split(';')[0].split('-')[0].trim()) + .filter((lang) => SUPPORTED_LOCALES.includes(lang as Locale)) + + const detectedLocale = (preferredLocales[0] as Locale) || DEFAULT_LOCALE + req.locale = detectedLocale + + // Redirect to localized URL + if (req.path === '/') { + return res.redirect(`/${detectedLocale}`) + } + + res.redirect(`/${detectedLocale}${req.path}`) +}) + +// Route handling +app.get('/:locale/*', async (req, res) => { + const result = await router.resolve({ + pathname: req.path, + locale: req.locale, + }) + res.send(result) +}) +``` + +## RTL Language Support + +Handle right-to-left languages: + +```ts +const RTL_LOCALES = ['ar', 'he', 'fa'] as const + +function isRTL(locale: Locale): boolean { + return RTL_LOCALES.includes(locale as any) +} + +// In your layout +function Layout({ + locale, + children, +}: { + locale: Locale + children: React.ReactNode +}) { + return ( + + + + + {children} + + ) +} +``` + +## SEO Considerations + +### Canonical URLs + +Set canonical URLs to prevent duplicate content issues: + +```ts +function renderPage(locale: Locale, routeName: string, content: string) { + const canonicalUrl = `https://example.com${localizedUrl(routeName, {}, locale)}` + + return ` + + + + + ${generateHreflangHtml(routeName)} + + ${content} + + ` +} +``` + +### Sitemap Generation + +Generate a sitemap with all localized URLs: + +```ts +function generateSitemap(): string { + const urls: string[] = [] + + localizedRoutes.forEach((route) => { + SUPPORTED_LOCALES.forEach((locale) => { + const url = `https://example.com${localizedUrl(route.name, {}, locale)}` + urls.push(` + + ${url} + ${SUPPORTED_LOCALES.map( + (l) => + ``, + ).join('\n')} + + `) + }) + }) + + return ` + + ${urls.join('\n')} + ` +} +``` + +## Common Pitfalls + +### 1. Forgetting to Handle Missing Locale + +Always handle URLs without locale prefix: + +```ts +// Wrong - 404 for /about without locale +const routes = [ + { path: '/:locale/about', action: ... } +] + +// Correct - redirect to localized version +const routes = [ + { path: '/:locale/about', action: ... }, + { + path: '/about', + action: (ctx) => ({ redirect: `/${detectLocale()}/about` }) + }, + { + path: '/', + action: (ctx) => ({ redirect: `/${detectLocale()}` }) + } +] +``` + +### 2. Hardcoding Locale in Links + +Always use URL generators for internal links: + +```tsx +// Wrong - hardcoded locale +About + +// Correct - dynamic locale +About +``` + +### 3. Not Updating HTML lang Attribute + +Always set the `lang` attribute on the HTML element: + +```tsx +// Wrong + + +// Correct + +``` + +### 4. Missing Hreflang x-default + +Include x-default for users with no language preference: + +```html + + +``` + +## See Also + +- [Nested Routes](./nested-routes.md) - Locale prefix as parent route +- [URL Generation](./url-generation.md) - Generating localized URLs +- [Isomorphic Routing](./isomorphic-routing.md) - Server-side locale detection +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/isomorphic-routing.md b/docs/isomorphic-routing.md new file mode 100644 index 0000000..33cb63a --- /dev/null +++ b/docs/isomorphic-routing.md @@ -0,0 +1,524 @@ +# Isomorphic Routing with React and Express + +Universal Router is designed to work identically on both server and client, making it ideal for +isomorphic (universal) applications. This recipe shows how to share routes between Node.js and +the browser, implement server-side rendering with React, and hydrate on the client. + +## What is Isomorphic Routing? + +Isomorphic routing means using the same route definitions and routing logic on both: + +- **Server**: Render the initial HTML for SEO, performance, and users without JavaScript +- **Client**: Handle subsequent navigation without full page reloads + +Universal Router excels at this because: + +1. It has no browser-specific dependencies +2. Route definitions are plain JavaScript objects +3. Actions can return anything (strings, React elements, data, etc.) +4. The same `router.resolve()` call works everywhere + +## Project Structure + +A typical isomorphic React app structure: + +``` +src/ + routes.ts # Shared route definitions + App.tsx # Root React component + client.tsx # Client entry point + server.tsx # Server entry point + pages/ + Home.tsx + About.tsx + User.tsx +``` + +## Shared Route Definitions + +Define routes once, use everywhere: + +```tsx +// src/routes.ts +import type { Route, RouteContext } from 'universal-router' +import type { ReactNode } from 'react' + +// Define your context type +export interface AppContext { + pathname: string + query?: Record + user?: { id: string; name: string } | null +} + +// Route result type +export interface PageResult { + title: string + component: ReactNode + data?: unknown +} + +export type AppRoute = Route + +const routes: AppRoute[] = [ + { + path: '/', + async action() { + const { default: Home } = await import('./pages/Home') + return { + title: 'Home', + component: , + } + }, + }, + { + path: '/about', + async action() { + const { default: About } = await import('./pages/About') + return { + title: 'About Us', + component: , + } + }, + }, + { + path: '/users/:id', + async action(context) { + const { default: User } = await import('./pages/User') + const userId = context.params.id as string + + // Fetch data - works on both server and client + const response = await fetch(`https://api.example.com/users/${userId}`) + const user = await response.json() + + return { + title: `User: ${user.name}`, + component: , + data: user, + } + }, + }, +] + +export default routes +``` + +## Server-Side Rendering with Express + +```tsx +// src/server.tsx +import express from 'express' +import { renderToString } from 'react-dom/server' +import UniversalRouter from 'universal-router' +import routes, { AppContext, PageResult } from './routes' + +const app = express() + +// Serve static assets +app.use('/static', express.static('dist/static')) + +// Handle all routes +app.get('*', async (req, res) => { + const router = new UniversalRouter(routes) + + try { + // Create context with request data + const context: AppContext = { + pathname: req.path, + query: req.query as Record, + user: req.user || null, // From auth middleware + } + + // Resolve the route + const page = await router.resolve(context) + + // Render React component to string + const html = renderToString(page.component) + + // Send the complete HTML page + res.send(` + + + + + + ${page.title} + + + +
${html}
+ + + + + `) + } catch (error: any) { + if (error.status === 404) { + res.status(404).send(` + + + Not Found + +

Page Not Found

+ Go Home + + + `) + } else { + console.error('Routing error:', error) + res.status(500).send('Internal Server Error') + } + } +}) + +app.listen(3000, () => { + console.log('Server running on http://localhost:3000') +}) +``` + +## Client-Side Hydration + +```tsx +// src/client.tsx +import { hydrateRoot } from 'react-dom/client' +import UniversalRouter from 'universal-router' +import routes, { AppContext, PageResult } from './routes' + +const router = new UniversalRouter(routes) +const container = document.getElementById('root')! + +// Track current render for preventing stale updates +let currentRender = 0 + +// IMPORTANT: Create the root ONCE and store it for reuse +// Creating a new root on every navigation causes memory leaks and breaks React state +let root: ReturnType | null = null + +async function render(pathname: string, isInitial = false) { + const renderVersion = ++currentRender + + try { + const context: AppContext = { + pathname, + query: Object.fromEntries(new URLSearchParams(window.location.search)), + } + + const page = await router.resolve(context) + + // Prevent rendering if a newer navigation started + if (renderVersion !== currentRender) return + + document.title = page.title + + if (isInitial) { + // Hydrate server-rendered HTML and store the root + root = hydrateRoot(container, page.component) + } else if (root) { + // Reuse the existing root for subsequent navigations + root.render(page.component) + } + } catch (error: any) { + if (renderVersion !== currentRender) return + + if (error.status === 404) { + document.title = 'Not Found' + root?.render(

Page Not Found

) + } else { + throw error + } + } +} + +// Navigation functions +function navigate(pathname: string, options: { replace?: boolean } = {}) { + if (options.replace) { + window.history.replaceState(null, '', pathname) + } else { + window.history.pushState(null, '', pathname) + } + render(pathname) +} + +// Handle back/forward +window.addEventListener('popstate', () => { + render(window.location.pathname) +}) + +// Intercept link clicks +document.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a') + if (!link) return + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + if (link.hostname !== window.location.hostname) return + if (link.target && link.target !== '_self') return + + event.preventDefault() + navigate(link.pathname + link.search) +}) + +// Initial hydration +render(window.location.pathname, true) + +// Export for programmatic navigation +;(window as any).navigate = navigate +``` + +## Data Fetching Strategies + +### Strategy 1: Fetch in Route Actions + +Fetch data in the action and pass it to the component: + +```tsx +const routes = [ + { + path: '/posts/:id', + async action(context) { + const { default: Post } = await import('./pages/Post') + + // Fetch data server or client + const post = await fetchPost(context.params.id) + + return { + title: post.title, + component: , + data: post, // Serialize for client + } + }, + }, +] +``` + +### Strategy 2: Fetch on Client Only + +For data that should always be fresh: + +```tsx +// pages/Dashboard.tsx +import { useEffect, useState } from 'react' + +export default function Dashboard() { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchDashboardStats().then((data) => { + setStats(data) + setLoading(false) + }) + }, []) + + if (loading) return
Loading...
+ return
{/* render stats */}
+} +``` + +### Strategy 3: Hybrid with Initial Data + +Pass server-fetched data to avoid client refetch: + +```tsx +// Server passes initial data via window.__INITIAL_DATA__ + +// pages/User.tsx +import { useState, useEffect } from 'react' + +interface UserProps { + user?: User + userId: string +} + +export default function UserPage({ user: initialUser, userId }: UserProps) { + const [user, setUser] = useState(initialUser) + const [loading, setLoading] = useState(!initialUser) + + useEffect(() => { + // Only fetch if no initial data + if (!initialUser) { + fetchUser(userId).then((data) => { + setUser(data) + setLoading(false) + }) + } + }, [initialUser, userId]) + + if (loading) return
Loading...
+ return
{user.name}
+} +``` + +## Handling Redirects + +Handle redirects consistently on server and client: + +```tsx +// routes.ts +const routes = [ + { + path: '/old-path', + action() { + return { redirect: '/new-path' } + }, + }, + { + path: '/dashboard', + action(context) { + if (!context.user) { + return { redirect: '/login' } + } + // ... render dashboard + }, + }, +] + +// server.tsx +const page = await router.resolve(context) + +if (page.redirect) { + res.redirect(302, page.redirect) + return +} + +// Render normally... + +// client.tsx +const page = await router.resolve(context) + +if (page.redirect) { + navigate(page.redirect, { replace: true }) + return +} + +// Render normally... +``` + +## Environment-Specific Code + +Use conditional imports or environment checks for code that differs: + +```tsx +// routes.ts +const routes = [ + { + path: '/api-test', + async action() { + // Use different base URL on server vs client + const baseUrl = + typeof window === 'undefined' + ? 'http://localhost:3000' // Server + : '' // Client (relative) + + const data = await fetch(`${baseUrl}/api/data`).then((r) => r.json()) + + return { component:
{JSON.stringify(data)}
} + }, + }, +] +``` + +## Common Pitfalls + +### 1. Hydration Mismatch + +Server and client must render identical HTML initially: + +```tsx +// Wrong - different output on server vs client +function Component() { + const [time] = useState(new Date().toISOString()) + return
{time}
// Different on each render! +} + +// Correct - use useEffect for client-only values +function Component() { + const [time, setTime] = useState(null) + + useEffect(() => { + setTime(new Date().toISOString()) + }, []) + + return
{time ?? 'Loading...'}
+} +``` + +### 2. Not Awaiting Async Actions + +Server must wait for all data before rendering: + +```tsx +// Wrong - renders before data loads +const page = router.resolve(pathname) // Missing await! + +// Correct +const page = await router.resolve(pathname) +``` + +### 3. Browser APIs on Server + +Guard browser-specific code: + +```tsx +function Component() { + // Wrong - crashes on server + const width = window.innerWidth + + // Correct + const [width, setWidth] = useState(0) + + useEffect(() => { + setWidth(window.innerWidth) + }, []) + + return
Width: {width}
+} +``` + +### 4. Memory Leaks on Server + +Create a new router instance per request to avoid state leakage: + +```tsx +// Wrong - shared router accumulates state +const router = new UniversalRouter(routes) + +app.get('*', async (req, res) => { + const page = await router.resolve(req.path) +}) + +// Correct - new router per request +app.get('*', async (req, res) => { + const router = new UniversalRouter(routes, { + context: { user: req.user }, + }) + const page = await router.resolve(req.path) +}) +``` + +## TypeScript Configuration + +For isomorphic TypeScript projects: + +```json +// tsconfig.json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} +``` + +## See Also + +- [SPA Navigation](./spa-navigation.md) - Client-only navigation +- [Code Splitting](./code-splitting.md) - Lazy loading routes +- [Redirects](./redirects.md) - Server and client redirects +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/nested-routes.md b/docs/nested-routes.md new file mode 100644 index 0000000..201549c --- /dev/null +++ b/docs/nested-routes.md @@ -0,0 +1,542 @@ +# Nested Routes and Layouts + +Nested routes allow you to build hierarchical URL structures where child routes inherit context +from their parents. This pattern is essential for creating layouts, shared navigation, and +multi-level applications like dashboards or content management systems. + +## Understanding Nested Routes + +When you define child routes, Universal Router matches the parent path first, then continues +matching against children using the remaining pathname. Parameters from parent routes are +automatically inherited by children. + +``` +URL: /admin/users/123 + +Route tree: +/admin <- matches, continues to children + /users <- matches, continues to children + /:userId <- matches, action is called +``` + +## Basic Nested Structure + +```js +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter({ + path: '/admin', + children: [ + { + path: '', // Matches /admin exactly + action: () => '

Admin Dashboard

', + }, + { + path: '/users', + children: [ + { + path: '', // Matches /admin/users + action: () => '

User List

', + }, + { + path: '/:userId', // Matches /admin/users/123 + action: (context, params) => `

User ${params.userId}

`, + }, + ], + }, + { + path: '/settings', + action: () => '

Settings

', + }, + ], +}) + +await router.resolve('/admin') +// => '

Admin Dashboard

' + +await router.resolve('/admin/users') +// => '

User List

' + +await router.resolve('/admin/users/123') +// => '

User ${params.userId}

' +``` + +## Index Routes + +An index route is a child with an empty path (`path: ''`). It matches when the parent path +matches exactly, without any additional segments. + +```js +const router = new UniversalRouter({ + path: '/products', + children: [ + { + path: '', // Index route - matches /products + action: () => 'Product Catalog', + }, + { + path: '/:productId', // Matches /products/abc + action: (ctx, params) => `Product: ${params.productId}`, + }, + ], +}) + +await router.resolve('/products') +// => 'Product Catalog' + +await router.resolve('/products/') +// => 'Product Catalog' (trailing slash is handled) + +await router.resolve('/products/widget-x') +// => 'Product: widget-x' +``` + +## Parameter Inheritance + +Child routes automatically receive parameters captured by parent routes. This is useful for +deeply nested structures where multiple levels need access to parent IDs. + +### Type-Safe Parameter Inheritance (Recommended) + +Use `defineRoute` for automatic type inference across nested routes: + +```ts +import UniversalRouter, { defineRoute } from 'universal-router' + +// Parameters from all ancestor routes are automatically typed +const routes = defineRoute({ + path: '/organizations/:orgId', + children: [ + defineRoute({ + path: '/teams/:teamId', + children: [ + defineRoute({ + path: '/members/:memberId', + action: (context, params) => { + // All three parameters are typed as string: + // - params.orgId (from grandparent) + // - params.teamId (from parent) + // - params.memberId (from this route) + return { + org: params.orgId, + team: params.teamId, + member: params.memberId, + } + }, + }), + ], + }), + ], +}) + +const router = new UniversalRouter(routes) + +await router.resolve('/organizations/acme/teams/engineering/members/alice') +// => { org: 'acme', team: 'engineering', member: 'alice' } +``` + +### JavaScript Version + +For JavaScript projects without TypeScript: + +```js +const router = new UniversalRouter({ + path: '/organizations/:orgId', + children: [ + { + path: '/teams/:teamId', + children: [ + { + path: '/members/:memberId', + action: (context, params) => { + // All three parameters are available + return { + org: params.orgId, + team: params.teamId, + member: params.memberId, + } + }, + }, + ], + }, + ], +}) + +await router.resolve('/organizations/acme/teams/engineering/members/alice') +// => { org: 'acme', team: 'engineering', member: 'alice' } +``` + +## Shared Layouts with Middleware + +Parent routes can act as middleware that wraps child content. Use `context.next()` to render +children and wrap the result. + +```js +const router = new UniversalRouter({ + path: '', + async action(context) { + // Call children first + const content = await context.next() + + // Wrap with layout + return ` + + + My App + + +
${content}
+
Footer here
+ + + ` + }, + children: [ + { path: '/', action: () => '

Home

' }, + { path: '/about', action: () => '

About

' }, + ], +}) +``` + +### Multiple Layout Levels + +Create nested layouts for complex applications: + +```js +const router = new UniversalRouter({ + path: '', + async action(context) { + const content = await context.next() + return `
${content}
` + }, + children: [ + { + path: '/', + action: () => '

Home

', + }, + { + path: '/dashboard', + async action(context) { + const content = await context.next() + return ` +
+ +
${content}
+
+ ` + }, + children: [ + { + path: '', // /dashboard + action: () => '

Dashboard Overview

', + }, + { + path: '/analytics', // /dashboard/analytics + action: () => '

Analytics

', + }, + { + path: '/reports', // /dashboard/reports + action: () => '

Reports

', + }, + ], + }, + ], +}) + +await router.resolve('/dashboard/analytics') +// Returns nested layouts: root-layout > dashboard-layout > Analytics content +``` + +## Dynamic Layout Selection + +Choose layouts based on route properties or context: + +```js +const router = new UniversalRouter({ + path: '', + async action(context) { + const content = await context.next() + + // Check if route wants full-width layout + if (context.route.fullWidth) { + return `
${content}
` + } + + return ` +
+ +
${content}
+
+ ` + }, + children: [ + { + path: '/', + action: () => '

Home

', + }, + { + path: '/presentation', + fullWidth: true, // Custom property + action: () => '
Presentation Content
', + }, + ], +}) +``` + +## Handling Missing Children + +When a parent matches but no child matches, the router continues searching. Control this +behavior explicitly: + +```js +const router = new UniversalRouter({ + path: '/docs', + action(context) { + // This runs when /docs matches + // Return undefined to continue to children + // Return null to skip this entire branch + // Return a value to stop matching + + // Continue to children + return context.next() + }, + children: [ + { path: '', action: () => 'Documentation Home' }, + { path: '/:page', action: (ctx, p) => `Doc: ${p.page}` }, + ], +}) +``` + +### Catch-All Children + +Use an empty `children` array to match all paths under a parent: + +```js +const router = new UniversalRouter({ + path: '/legacy', + children: [], // Empty array acts as catch-all + action: () => 'This matches /legacy and all sub-paths', +}) + +await router.resolve('/legacy') +// => 'This matches /legacy and all sub-paths' + +await router.resolve('/legacy/any/path/here') +// => 'This matches /legacy and all sub-paths' +``` + +## Route Organization Patterns + +### Feature-Based Organization + +Organize routes by feature for better maintainability: + +```js +// routes/users.js +export const userRoutes = { + path: '/users', + children: [ + { path: '', action: () => 'User List' }, + { path: '/:id', action: (ctx, p) => `User ${p.id}` }, + { path: '/:id/edit', action: (ctx, p) => `Edit User ${p.id}` }, + ], +} + +// routes/products.js +export const productRoutes = { + path: '/products', + children: [ + { path: '', action: () => 'Product Catalog' }, + { path: '/:id', action: (ctx, p) => `Product ${p.id}` }, + ], +} + +// routes/index.js +import { userRoutes } from './users' +import { productRoutes } from './products' + +export const routes = { + path: '', + children: [{ path: '/', action: () => 'Home' }, userRoutes, productRoutes], +} +``` + +### Dynamic Route Loading + +Load route definitions dynamically for code splitting: + +```js +const router = new UniversalRouter({ + path: '', + children: [ + { path: '/', action: () => 'Home' }, + { + path: '/admin', + async action(context) { + // Dynamically load admin routes + const { adminRoutes } = await import('./admin-routes') + + // Create a sub-router for admin section + const adminRouter = new UniversalRouter(adminRoutes) + + // Resolve the remaining path + const subPath = context.pathname.replace('/admin', '') || '/' + return adminRouter.resolve(subPath) + }, + }, + ], +}) +``` + +## React Component Example + +Here is how nested routes work with React components: + +```jsx +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter({ + path: '', + async action(context) { + const children = await context.next() + return {children} + }, + children: [ + { + path: '/', + action: () => , + }, + { + path: '/dashboard', + async action(context) { + const children = await context.next() + return {children} + }, + children: [ + { + path: '', + action: () => , + }, + { + path: '/settings', + action: () => , + }, + { + path: '/profile/:userId', + action: (context, params) => , + }, + ], + }, + ], +}) +``` + +## Common Pitfalls + +### 1. Parent Action Returns Value + +If a parent action returns a value (not `undefined`), children are not matched: + +```js +// Wrong - children will never be reached +{ + path: '/admin', + action: () => 'Admin', // Returns value, stops matching + children: [ + { path: '/users', action: () => 'Users' }, // Never reached! + ], +} + +// Correct - use context.next() or return undefined +{ + path: '/admin', + action: (context) => context.next(), // Continues to children + children: [ + { path: '/users', action: () => 'Users' }, + ], +} + +// Also correct - no action, just structure +{ + path: '/admin', + children: [ + { path: '', action: () => 'Admin Home' }, + { path: '/users', action: () => 'Users' }, + ], +} +``` + +### 2. Forgetting Index Route + +Without an index route, the parent path without trailing content returns 404: + +```js +// Wrong - /dashboard returns 404 +{ + path: '/dashboard', + children: [ + { path: '/stats', action: () => 'Stats' }, + ], +} + +// Correct - add index route +{ + path: '/dashboard', + children: [ + { path: '', action: () => 'Dashboard Home' }, // Handles /dashboard + { path: '/stats', action: () => 'Stats' }, + ], +} +``` + +### 3. Child Path Starting Without Slash + +Child paths that do not start with `/` are relative to parent: + +```js +// These are equivalent +{ path: '/users', children: [{ path: '/:id' }] } +{ path: '/users', children: [{ path: ':id' }] } + +// Both match: /users/123 +``` + +### 4. Parameter Name Conflicts + +When parent and child have the same parameter name, child wins: + +```js +{ + path: '/items/:id', + children: [ + { + path: '/subitems/:id', // Same name 'id' + action: (ctx, params) => { + // params.id is the subitem ID, not item ID! + // Parent ID is lost + }, + }, + ], +} + +// Better - use unique names +{ + path: '/items/:itemId', + children: [ + { + path: '/subitems/:subitemId', + action: (ctx, params) => { + // params.itemId = parent item + // params.subitemId = child item + }, + }, + ], +} +``` + +## See Also + +- [Route Matching Order and Priorities](./route-priorities.md) - How routes are matched +- [Building Navigation Breadcrumbs](./breadcrumbs.md) - Using route hierarchy for breadcrumbs +- [Code Splitting](./code-splitting.md) - Lazy loading nested routes +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/page-transitions.md b/docs/page-transitions.md new file mode 100644 index 0000000..f74857e --- /dev/null +++ b/docs/page-transitions.md @@ -0,0 +1,762 @@ +# Page Transitions and Animations + +Universal Router is a minimal, unopinionated routing library that does not include built-in animation +support. This design allows you to use any animation library or technique that fits your project. +This recipe shows how to implement smooth page transitions using CSS, React Transition Group, +and Framer Motion. + +## Understanding the Challenge + +Page transitions require coordination between routing and animation timing: + +1. **Exit animation**: The outgoing page must animate out before removal +2. **Enter animation**: The incoming page must animate in after mounting +3. **Timing coordination**: The router must wait for exit animations to complete +4. **State management**: Both pages may need to exist simultaneously during transitions + +Universal Router's action-based approach gives you full control over this process. + +## CSS Transitions (Vanilla JavaScript) + +For simple fade transitions without a framework, use CSS and manage element lifecycles manually: + +```css +/* styles.css */ +.page { + opacity: 1; + transition: opacity 300ms ease-in-out; +} + +.page.fade-out { + opacity: 0; +} + +.page.fade-in { + opacity: 0; +} +``` + +```js +import UniversalRouter from 'universal-router' + +const routes = [ + { path: '/', action: () => '

Home

' }, + { path: '/about', action: () => '

About

' }, + { + path: '/contact', + action: () => '

Contact

', + }, +] + +const router = new UniversalRouter(routes) +const container = document.getElementById('app') + +async function renderWithTransition(pathname) { + const newContent = await router.resolve(pathname) + const currentPage = container.querySelector('.page') + + if (currentPage) { + // Start exit animation + currentPage.classList.add('fade-out') + + // Wait for animation to complete + await new Promise((resolve) => { + currentPage.addEventListener('transitionend', resolve, { once: true }) + }) + } + + // Insert new content + container.innerHTML = newContent + + // Trigger enter animation + const newPage = container.querySelector('.page') + if (newPage) { + newPage.classList.add('fade-in') + // Force reflow to ensure animation triggers + void newPage.offsetWidth + newPage.classList.remove('fade-in') + } +} + +// Navigation handler +function navigate(pathname) { + window.history.pushState(null, '', pathname) + renderWithTransition(pathname) +} + +// Initial render +renderWithTransition(window.location.pathname) + +// Handle browser back/forward +window.addEventListener('popstate', () => { + renderWithTransition(window.location.pathname) +}) +``` + +## React Transition Group + +React Transition Group provides components for managing enter/exit transitions. Use `CSSTransition` +or `SwitchTransition` for page-level animations. + +### Basic Setup with CSSTransition + +```tsx +import { useState, useEffect, useRef } from 'react' +import { CSSTransition, SwitchTransition } from 'react-transition-group' +import UniversalRouter from 'universal-router' + +// Define routes that return React components +const routes = [ + { path: '/', action: () => ({ Component: HomePage, key: 'home' }) }, + { path: '/about', action: () => ({ Component: AboutPage, key: 'about' }) }, + { + path: '/users/:id', + action: (ctx) => ({ + Component: () => , + key: `user-${ctx.params.id}`, + }), + }, +] + +const router = new UniversalRouter(routes) + +function App() { + const [route, setRoute] = useState<{ + Component: React.FC + key: string + } | null>(null) + const nodeRef = useRef(null) + + useEffect(() => { + async function handleNavigation() { + const result = await router.resolve(window.location.pathname) + setRoute(result) + } + + handleNavigation() + + const handlePopState = () => handleNavigation() + window.addEventListener('popstate', handlePopState) + + return () => window.removeEventListener('popstate', handlePopState) + }, []) + + if (!route) return
Loading...
+ + const { Component, key } = route + + return ( + + +
+ +
+
+
+ ) +} +``` + +```css +/* Page transition styles */ +.page-container { + width: 100%; +} + +/* Enter transition */ +.page-enter { + opacity: 0; + transform: translateX(20px); +} + +.page-enter-active { + opacity: 1; + transform: translateX(0); + transition: + opacity 300ms ease-out, + transform 300ms ease-out; +} + +/* Exit transition */ +.page-exit { + opacity: 1; + transform: translateX(0); +} + +.page-exit-active { + opacity: 0; + transform: translateX(-20px); + transition: + opacity 300ms ease-in, + transform 300ms ease-in; +} +``` + +### Handling Same-Route Parameter Changes + +When navigating between `/user/123` and `/user/456`, you want transitions even though the route +structure is the same. The key to this is using a unique key that includes the parameters: + +```tsx +const routes = [ + { + path: '/users/:id', + action: (context) => ({ + Component: UserPage, + // Include params in the key to trigger transitions + key: `user-${context.params.id}`, + params: context.params, + }), + }, +] + +function App() { + const [route, setRoute] = useState(null) + const nodeRef = useRef(null) + + // ... navigation setup + + return ( + + +
+ +
+
+
+ ) +} +``` + +## Framer Motion + +Framer Motion provides a more powerful and declarative API for animations with built-in +support for exit animations through `AnimatePresence`. + +### Basic Framer Motion Setup + +```tsx +import { useState, useEffect } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import UniversalRouter from 'universal-router' + +const pageVariants = { + initial: { + opacity: 0, + x: 20, + }, + animate: { + opacity: 1, + x: 0, + }, + exit: { + opacity: 0, + x: -20, + }, +} + +const pageTransition = { + type: 'tween', + ease: 'easeInOut', + duration: 0.3, +} + +// Wrap each page component with motion +function PageWrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +const routes = [ + { + path: '/', + action: () => ({ + element: ( + + + + ), + key: 'home', + }), + }, + { + path: '/about', + action: () => ({ + element: ( + + + + ), + key: 'about', + }), + }, + { + path: '/users/:id', + action: (ctx) => ({ + element: ( + + + + ), + key: `user-${ctx.params.id}`, + }), + }, +] + +const router = new UniversalRouter(routes) + +function App() { + const [route, setRoute] = useState<{ + element: JSX.Element + key: string + } | null>(null) + + useEffect(() => { + async function navigate() { + const result = await router.resolve(window.location.pathname) + setRoute(result) + } + + navigate() + window.addEventListener('popstate', navigate) + return () => window.removeEventListener('popstate', navigate) + }, []) + + return ( +
+ + {route && {route.element}} + +
+ ) +} +``` + +### Direction-Aware Transitions + +Create transitions that animate based on navigation direction (forward/backward): + +```tsx +import { useState, useEffect, useRef } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import UniversalRouter from 'universal-router' + +// Track navigation history for direction detection +const history: string[] = [] + +function getDirection(pathname: string): 'forward' | 'back' { + const currentIndex = history.indexOf(pathname) + if (currentIndex === -1) { + history.push(pathname) + return 'forward' + } + // Going back in history + history.length = currentIndex + 1 + return 'back' +} + +const variants = { + enter: (direction: 'forward' | 'back') => ({ + x: direction === 'forward' ? '100%' : '-100%', + opacity: 0, + }), + center: { + x: 0, + opacity: 1, + }, + exit: (direction: 'forward' | 'back') => ({ + x: direction === 'forward' ? '-100%' : '100%', + opacity: 0, + }), +} + +function App() { + const [route, setRoute] = useState(null) + const [direction, setDirection] = useState<'forward' | 'back'>('forward') + + async function handleNavigation(pathname: string) { + setDirection(getDirection(pathname)) + const result = await router.resolve(pathname) + setRoute(result) + } + + useEffect(() => { + handleNavigation(window.location.pathname) + window.addEventListener('popstate', () => { + handleNavigation(window.location.pathname) + }) + }, []) + + return ( + + {route && ( + + {route.element} + + )} + + ) +} +``` + +## Implementing onLeave Logic + +Universal Router does not have built-in lifecycle hooks like `onLeave`. However, you can implement +this pattern using wrapper functions or middleware: + +### Using Action Wrappers + +```ts +import UniversalRouter, { RouteContext } from 'universal-router' + +interface RouteWithLifecycle { + path: string + onEnter?: (context: RouteContext) => void | Promise + onLeave?: (context: RouteContext) => void | Promise + action: (context: RouteContext) => any +} + +// Track current route for onLeave calls +let currentRoute: RouteWithLifecycle | null = null +let currentContext: RouteContext | null = null + +function createLifecycleRoutes(routes: RouteWithLifecycle[]) { + return routes.map((route) => ({ + path: route.path, + async action(context: RouteContext) { + // Call onLeave for previous route + if (currentRoute?.onLeave && currentContext) { + await currentRoute.onLeave(currentContext) + } + + // Call onEnter for new route + if (route.onEnter) { + await route.onEnter(context) + } + + // Update current route + currentRoute = route + currentContext = context + + return route.action(context) + }, + })) +} + +const routes = createLifecycleRoutes([ + { + path: '/editor', + onEnter: () => console.log('Entering editor'), + onLeave: async (context) => { + // Prompt user to save changes + if (hasUnsavedChanges()) { + const confirmed = await showConfirmDialog('Save changes?') + if (confirmed) { + await saveChanges() + } + } + }, + action: () => '
Editor Page
', + }, + { + path: '/home', + action: () => '
Home Page
', + }, +]) + +const router = new UniversalRouter(routes) +``` + +### Using Navigation Guards + +For more complex scenarios, implement navigation guards that can prevent navigation: + +```ts +type NavigationGuard = (from: string, to: string) => boolean | Promise + +const guards: NavigationGuard[] = [] + +function addNavigationGuard(guard: NavigationGuard) { + guards.push(guard) + return () => { + const index = guards.indexOf(guard) + if (index > -1) guards.splice(index, 1) + } +} + +async function canNavigate(from: string, to: string): Promise { + for (const guard of guards) { + const allowed = await guard(from, to) + if (!allowed) return false + } + return true +} + +// Usage +let currentPath = window.location.pathname + +async function navigate(pathname: string) { + const canProceed = await canNavigate(currentPath, pathname) + if (!canProceed) { + console.log('Navigation blocked') + return + } + + window.history.pushState(null, '', pathname) + currentPath = pathname + await render(pathname) +} + +// Add a guard for unsaved changes +addNavigationGuard(async (from, to) => { + if (from.startsWith('/editor') && hasUnsavedChanges()) { + return window.confirm('You have unsaved changes. Leave anyway?') + } + return true +}) +``` + +## Exit Animations with Async Actions + +Routes can return promises to delay rendering until animations complete: + +```tsx +const routes = [ + { + path: '/gallery/:id', + async action(context) { + const imageId = context.params.id + + // Preload next image while exit animation plays + const imagePromise = preloadImage(`/images/${imageId}.jpg`) + + // Return component with preloaded data + const image = await imagePromise + return { + element: , + key: `gallery-${imageId}`, + } + }, + }, +] +``` + +## Shared Element Transitions (View Transitions API) + +Modern browsers support the View Transitions API for native page transitions: + +```ts +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter(routes) +const container = document.getElementById('app') + +async function renderWithViewTransition(pathname: string) { + const newContent = await router.resolve(pathname) + + // Check if View Transitions API is supported + if (!document.startViewTransition) { + container.innerHTML = newContent + return + } + + // Use View Transitions API + const transition = document.startViewTransition(() => { + container.innerHTML = newContent + }) + + await transition.finished +} + +function navigate(pathname: string) { + window.history.pushState(null, '', pathname) + renderWithViewTransition(pathname) +} +``` + +```css +/* Customize the view transition */ +::view-transition-old(root) { + animation: 300ms ease-out fade-out; +} + +::view-transition-new(root) { + animation: 300ms ease-in fade-in; +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Shared element transitions */ +.hero-image { + view-transition-name: hero; +} +``` + +## Performance Considerations + +### Avoid Layout Thrashing + +Use `transform` and `opacity` for animations instead of properties that trigger layout: + +```css +/* Good - only composite properties */ +.page-enter-active { + transform: translateX(0); + opacity: 1; +} + +/* Avoid - triggers layout */ +.page-enter-active { + left: 0; + width: 100%; +} +``` + +### Use will-change Sparingly + +```css +.page-container { + /* Only add when animation is about to start */ + will-change: transform, opacity; +} +``` + +### Reduce Motion for Accessibility + +Respect user preferences for reduced motion: + +```css +@media (prefers-reduced-motion: reduce) { + .page-enter-active, + .page-exit-active { + transition: none; + } +} +``` + +```tsx +// In React with Framer Motion +const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', +).matches + +const pageTransition = prefersReducedMotion + ? { duration: 0 } + : { type: 'tween', duration: 0.3 } +``` + +## Common Pitfalls + +### 1. Forgetting Unique Keys + +Without unique keys, React cannot properly track which elements should animate: + +```tsx +// Wrong - no key or non-unique key + + + {route.element} + + + +// Correct - unique key per route + + + {route.element} + + +``` + +### 2. Exit Animation Not Playing + +AnimatePresence requires the exiting component to remain mounted during exit: + +```tsx +// Wrong - conditionally rendering outside AnimatePresence +{ + route && ( + + {route.element} + + ) +} + +// Correct - condition inside AnimatePresence +; + {route && {route.element}} + +``` + +### 3. Memory Leaks with Cleanup + +Always clean up animation listeners and timeouts: + +```ts +useEffect(() => { + const controller = new AbortController() + + async function handleNavigation() { + // Use AbortController to cancel pending animations + const result = await router.resolve(pathname) + if (!controller.signal.aborted) { + setRoute(result) + } + } + + handleNavigation() + + return () => controller.abort() +}, [pathname]) +``` + +## See Also + +- [SPA Navigation](./spa-navigation.md) - Client-side navigation setup +- [Code Splitting](./code-splitting.md) - Lazy loading pages for better performance +- [React and Redux Integration](./react-redux.md) - State management with routing +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/query-params-hash.md b/docs/query-params-hash.md new file mode 100644 index 0000000..247081d --- /dev/null +++ b/docs/query-params-hash.md @@ -0,0 +1,544 @@ +# URL Parameters, Query Strings, and Hash Fragments + +Universal Router focuses on matching URL pathnames using path-to-regexp patterns. Query strings and +hash fragments are not part of the pathname and must be handled separately. This design keeps the +router simple while giving you full control over how to parse and use these URL components. + +## Understanding URL Structure + +A complete URL consists of several parts: + +``` +https://example.com/users/123?sort=name&order=asc#profile + └─────┬─────┘└────────┬────────┘└──┬───┘ + pathname query string hash +``` + +Universal Router only matches the **pathname** portion. You pass query strings and hash fragments +through the context for use in route actions. + +## Path Parameters + +Path parameters are segments of the URL pathname that capture dynamic values. Define them using +the `:paramName` syntax. + +### Type-Safe Parameters with defineRoute (Recommended) + +Use `defineRoute` for automatic type inference in TypeScript: + +```ts +import UniversalRouter, { defineRoute } from 'universal-router' + +// Parameters are automatically typed from the path string +const userRoute = defineRoute({ + path: '/users/:userId', + action: (context, params) => { + // params.userId is typed as string - no manual annotation needed + return { user: params.userId } + }, +}) + +const postRoute = defineRoute({ + path: '/posts/:year/:month/:slug', + action: (context, params) => { + // All three parameters are typed as string + return { + year: params.year, + month: params.month, + slug: params.slug, + } + }, +}) + +const router = new UniversalRouter([userRoute, postRoute]) + +await router.resolve('/users/123') +// => { user: '123' } + +await router.resolve('/posts/2025/01/hello-world') +// => { year: '2025', month: '01', slug: 'hello-world' } +``` + +### Basic Named Parameters (JavaScript) + +For JavaScript projects without TypeScript: + +```js +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter([ + { + path: '/users/:userId', + action: (context, params) => { + // params.userId contains the captured value + return { user: params.userId } + }, + }, + { + path: '/posts/:year/:month/:slug', + action: (context, params) => { + // Multiple parameters + return { + year: params.year, + month: params.month, + slug: params.slug, + } + }, + }, +]) + +await router.resolve('/users/123') +// => { user: '123' } + +await router.resolve('/posts/2025/01/hello-world') +// => { year: '2025', month: '01', slug: 'hello-world' } +``` + +```ts +import type { ExtractParams } from 'universal-router' + +type UserParams = ExtractParams<'/users/:userId'> +// { userId: string } + +type PostParams = ExtractParams<'/posts/:year/:month/:slug'> +// { year: string; month: string; slug: string } +``` + +## Wildcard Parameters + +Wildcards capture multiple path segments, useful for file paths or catch-all routes. +Use the `*paramName` syntax. + +```js +const router = new UniversalRouter([ + { + path: '/files/*filepath', + action: (context, params) => { + // filepath is an array of path segments + return { segments: params.filepath } + }, + }, + { + path: '/docs/*rest', + action: (context, params) => { + const fullPath = params.rest.join('/') + return { documentPath: fullPath } + }, + }, +]) + +await router.resolve('/files/images/photos/vacation.jpg') +// => { segments: ['images', 'photos', 'vacation.jpg'] } + +await router.resolve('/docs/api/reference/methods') +// => { documentPath: 'api/reference/methods' } +``` + +### TypeScript Wildcard Types + +Wildcard parameters are typed as `string[]`: + +```ts +import type { ExtractParams } from 'universal-router' + +type FileParams = ExtractParams<'/files/*filepath'> +// { filepath: string[] } +``` + +## Query String Parsing + +Universal Router does not parse query strings automatically. This is intentional - it allows you +to choose your preferred parsing library and strategy. + +### Using URLSearchParams (Browser/Node.js) + +```js +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter([ + { + path: '/search', + action: (context) => { + const { query } = context + return { + term: query.q, + page: parseInt(query.page, 10) || 1, + filters: query.filters, + } + }, + }, +]) + +// Parse query string before resolving +function resolve(url) { + const urlObj = new URL(url, 'http://localhost') + const query = Object.fromEntries(urlObj.searchParams) + + return router.resolve({ + pathname: urlObj.pathname, + query, // Pass as context + }) +} + +await resolve('/search?q=router&page=2') +// => { term: 'router', page: 2, filters: undefined } +``` + +### Handling Complex Query Parameters + +For nested objects and arrays, use a library like [qs](https://github.com/ljharb/qs): + +```js +import UniversalRouter from 'universal-router' +import qs from 'qs' + +const router = new UniversalRouter([ + { + path: '/products', + action: (context) => { + const { query } = context + return { + filters: query.filters, + sort: query.sort, + } + }, + }, +]) + +function resolve(url) { + const [pathname, queryString] = url.split('?') + const query = qs.parse(queryString || '') + + return router.resolve({ pathname, query }) +} + +// Complex query: /products?filters[color]=red&filters[size][]=M&filters[size][]=L +await resolve( + '/products?filters[color]=red&filters[size][]=M&filters[size][]=L', +) +// => { filters: { color: 'red', size: ['M', 'L'] }, sort: undefined } +``` + +### Server-Side Query Parsing + +On the server, query strings are typically parsed by your framework: + +```js +import http from 'http' +import { URL } from 'url' +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter(routes) + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`) + const query = Object.fromEntries(url.searchParams) + + const result = await router.resolve({ + pathname: url.pathname, + query, + // Include other useful context + method: req.method, + headers: req.headers, + }) + + res.end(JSON.stringify(result)) +}) +``` + +With Express: + +```js +import express from 'express' +import UniversalRouter from 'universal-router' + +const app = express() +const router = new UniversalRouter(routes) + +app.use(async (req, res) => { + const result = await router.resolve({ + pathname: req.path, + query: req.query, // Express parses query automatically + method: req.method, + }) + + res.json(result) +}) +``` + +## Hash Fragments + +Hash fragments (everything after `#`) are handled entirely by the browser and are never sent to +the server. Use them for: + +- In-page navigation (scroll to anchors) +- Client-side state (though query strings are often preferred) +- Single-page application routing (hash-based routing) + +### Scroll to Anchor + +Handle hash-based scrolling in your navigation code: + +```js +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter(routes) + +async function navigate(url) { + const urlObj = new URL(url, window.location.origin) + + // Resolve the route + const result = await router.resolve({ + pathname: urlObj.pathname, + query: Object.fromEntries(urlObj.searchParams), + hash: urlObj.hash, + }) + + // Render the page + renderPage(result) + + // Handle hash scrolling after render + if (urlObj.hash) { + // Wait for next frame to ensure DOM is updated + requestAnimationFrame(() => { + const element = document.querySelector(urlObj.hash) + if (element) { + element.scrollIntoView({ behavior: 'smooth' }) + } + }) + } else { + // Scroll to top for new pages + window.scrollTo(0, 0) + } +} +``` + +### Hash in Route Actions + +Pass the hash through context for use in actions: + +```js +const router = new UniversalRouter([ + { + path: '/docs/:section', + action: (context, params) => { + return { + section: params.section, + anchor: context.hash, // e.g., '#installation' + } + }, + }, +]) + +// When resolving +const url = new URL('/docs/api#methods', window.location.origin) +await router.resolve({ + pathname: url.pathname, + hash: url.hash, +}) +// => { section: 'api', anchor: '#methods' } +``` + +### Hash-Based Routing (Legacy) + +For applications that need to support older browsers without History API: + +```js +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter([ + { path: '/', action: () => 'Home' }, + { path: '/about', action: () => 'About' }, +]) + +// Listen for hash changes +window.addEventListener('hashchange', () => { + const pathname = window.location.hash.slice(1) || '/' + render(pathname) +}) + +async function render(pathname) { + const result = await router.resolve(pathname) + document.getElementById('app').innerHTML = result +} + +// Navigate using hash +function navigate(path) { + window.location.hash = path +} + +// URLs will look like: example.com/#/about +navigate('/about') +``` + +## Generating URLs with Query Strings + +Use the `generateUrls` add-on with `stringifyQueryParams` option: + +```js +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +const routes = [ + { name: 'search', path: '/search' }, + { name: 'user', path: '/users/:userId' }, +] + +const router = new UniversalRouter(routes) +const url = generateUrls(router, { + stringifyQueryParams: (params) => new URLSearchParams(params).toString(), +}) + +// Generate URLs with query strings +url('search', { q: 'router', page: '2' }) +// => '/search?q=router&page=2' + +// Path params are used for the path, extras become query string +url('user', { userId: '123', tab: 'settings' }) +// => '/users/123?tab=settings' +``` + +For complex query strings, use a library: + +```js +import qs from 'qs' + +const url = generateUrls(router, { + stringifyQueryParams: (params) => + qs.stringify(params, { arrayFormat: 'brackets' }), +}) + +url('search', { filters: { color: 'red', sizes: ['M', 'L'] } }) +// => '/search?filters[color]=red&filters[sizes][]=M&filters[sizes][]=L' +``` + +## Complete Example: Search Page + +```ts +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +interface SearchQuery { + q?: string + page?: string + sort?: string + filters?: Record +} + +interface SearchContext { + pathname: string + query: SearchQuery +} + +const routes = [ + { + name: 'search', + path: '/search', + action: (context: SearchContext) => { + const { query } = context + return { + term: query.q || '', + page: parseInt(query.page || '1', 10), + sort: query.sort || 'relevance', + filters: query.filters || {}, + } + }, + }, +] + +const router = new UniversalRouter(routes) +const url = generateUrls(router, { + stringifyQueryParams: (params) => + new URLSearchParams(params as Record).toString(), +}) + +// Resolve a search URL +async function handleSearch(urlString: string) { + const urlObj = new URL(urlString, 'http://localhost') + const query: SearchQuery = Object.fromEntries(urlObj.searchParams) + + return router.resolve({ + pathname: urlObj.pathname, + query, + }) +} + +await handleSearch('/search?q=universal+router&page=2&sort=date') +// => { term: 'universal router', page: 2, sort: 'date', filters: {} } + +// Generate a search URL +url('search', { q: 'router', page: '1' }) +// => '/search?q=router&page=1' +``` + +## Common Pitfalls + +### 1. Including Query String in pathname + +The router only matches the pathname. Passing a full URL will fail: + +```js +// Wrong - query string is part of pathname +await router.resolve('/search?q=test') +// May not match /search route! + +// Correct - parse URL first +const url = new URL('/search?q=test', 'http://localhost') +await router.resolve({ + pathname: url.pathname, // '/search' + query: Object.fromEntries(url.searchParams), +}) +``` + +### 2. Forgetting to Decode Parameters + +Path parameters are automatically decoded, but query strings may need manual handling: + +```js +// Path params are decoded +await router.resolve('/users/John%20Doe') +// params.userId === 'John Doe' (decoded) + +// Query strings from URLSearchParams are also decoded +const params = new URLSearchParams('name=John%20Doe') +params.get('name') // 'John Doe' +``` + +### 3. Hash Not Available on Server + +The hash fragment is never sent to the server. Handle it client-side only: + +```js +// Server-side: hash is not available +// req.url === '/docs/api' (no hash) + +// Client-side: extract hash from window.location +const hash = window.location.hash // '#methods' +``` + +### 4. Empty Query Parameters + +Handle empty or missing query parameters gracefully: + +```js +{ + path: '/search', + action: (context) => { + const q = context.query?.q || '' + const page = parseInt(context.query?.page, 10) || 1 + + if (!q) { + return { error: 'Search term required' } + } + + return { term: q, page } + } +} +``` + +## See Also + +- [Single Page Application Navigation](./spa-navigation.md) - Full SPA routing setup +- [URL Generation](./api.md#url-generation) - Generating URLs with parameters +- [Isomorphic Routing](./isomorphic-routing.md) - Server and client routing +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/react-redux.md b/docs/react-redux.md new file mode 100644 index 0000000..defb124 --- /dev/null +++ b/docs/react-redux.md @@ -0,0 +1,590 @@ +# Usage with React and Redux + +Universal Router integrates naturally with Redux, enabling "Redux-first routing" where the +application's location state lives in the Redux store. This approach enables time-travel +debugging, easier testing, and centralized state management for navigation. + +## Redux-First Routing Philosophy + +In traditional routing, the browser URL is the source of truth and components read from it directly. +In Redux-first routing: + +1. **URL changes** dispatch actions to Redux +2. **Redux store** holds the current location +3. **Components** read location from Redux store +4. **Router** resolves routes based on store state + +This gives you: + +- Time-travel debugging with Redux DevTools +- Ability to navigate by dispatching actions +- Serializable state for server-side rendering +- Easier testing (mock store instead of browser) + +## Basic Setup with Redux Toolkit (Recommended) + +Redux Toolkit is the modern, recommended way to use Redux. It reduces boilerplate +and provides better TypeScript support out of the box. + +### 1. Router Slice + +```ts +// store/router/slice.ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +interface RouterState { + pathname: string + search: string + hash: string + query: Record +} + +function parseQuery(search: string): Record { + return Object.fromEntries(new URLSearchParams(search)) +} + +const initialState: RouterState = { + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + query: parseQuery(window.location.search), +} + +const routerSlice = createSlice({ + name: 'router', + initialState, + reducers: { + locationChanged(state, action: PayloadAction>) { + const { pathname, search = '', hash = '' } = action.payload + if (pathname) state.pathname = pathname + state.search = search + state.hash = hash + state.query = parseQuery(search) + }, + }, +}) + +export const { locationChanged } = routerSlice.actions +export default routerSlice.reducer +``` + +### 2. Router Middleware + +```ts +// store/router/middleware.ts +import type { Middleware } from '@reduxjs/toolkit' +import { locationChanged } from './slice' + +export const routerMiddleware: Middleware = (store) => { + // Sync browser navigation with Redux + window.addEventListener('popstate', () => { + store.dispatch( + locationChanged({ + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + }), + ) + }) + + return (next) => (action) => next(action) +} + +// Navigation helpers (call these from components) +export function push(pathname: string) { + window.history.pushState(null, '', pathname) + // Dispatch will be handled by the component +} + +export function replace(pathname: string) { + window.history.replaceState(null, '', pathname) +} +``` + +### 3. Configure Store + +```ts +// store/index.ts +import { configureStore } from '@reduxjs/toolkit' +import routerReducer from './router/slice' +import { routerMiddleware } from './router/middleware' + +export const store = configureStore({ + reducer: { + router: routerReducer, + // ... other reducers + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(routerMiddleware), +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch +``` + +## Integrating Universal Router + +### Route Resolution Based on Store State + +```tsx +// router/index.tsx +import UniversalRouter from 'universal-router' +import type { ReactNode } from 'react' +import { store, RootState } from '../store' + +const routes = [ + { path: '/', action: () => import('../pages/Home') }, + { path: '/users', action: () => import('../pages/Users') }, + { path: '/users/:id', action: () => import('../pages/User') }, +] + +const router = new UniversalRouter(routes, { + context: { + // Make store available in route actions + store, + }, +}) + +// Resolve routes when store changes +let currentPathname = '' + +export function subscribeToRouteChanges( + onRoute: (component: ReactNode) => void, +) { + return store.subscribe(async () => { + const state = store.getState() + const { pathname } = state.router + + // Skip if pathname hasn't changed + if (pathname === currentPathname) return + currentPathname = pathname + + try { + const module = await router.resolve({ + pathname, + // Pass Redux state to routes + state, + }) + onRoute() + } catch (error: any) { + if (error.status === 404) { + onRoute() + } + } + }) +} +``` + +### App Component + +```tsx +// App.tsx +import { useState, useEffect } from 'react' +import { Provider } from 'react-redux' +import { store } from './store' +import { subscribeToRouteChanges } from './router' + +function Router() { + const [content, setContent] = useState(null) + + useEffect(() => { + const unsubscribe = subscribeToRouteChanges(setContent) + return unsubscribe + }, []) + + return <>{content} +} + +export default function App() { + return ( + + + + ) +} +``` + +## Navigation with Redux + +### Using Hooks + +```tsx +// hooks/useNavigation.ts +import { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { push, replace, goBack, goForward } from '../store/router/actions' + +export function useNavigation() { + const dispatch = useDispatch() + + return { + navigate: useCallback( + (pathname: string) => dispatch(push(pathname)), + [dispatch], + ), + replace: useCallback( + (pathname: string) => dispatch(replace(pathname)), + [dispatch], + ), + goBack: useCallback(() => dispatch(goBack()), [dispatch]), + goForward: useCallback(() => dispatch(goForward()), [dispatch]), + } +} +``` + +### Navigation Components + +```tsx +// components/Link.tsx +import { useCallback, MouseEvent, ReactNode } from 'react' +import { useDispatch } from 'react-redux' +import { push } from '../store/router/actions' + +interface LinkProps { + to: string + children: ReactNode + replace?: boolean + className?: string +} + +export function Link({ + to, + children, + replace: shouldReplace, + className, +}: LinkProps) { + const dispatch = useDispatch() + + const handleClick = useCallback( + (event: MouseEvent) => { + // Allow ctrl/cmd click for new tab + if (event.metaKey || event.ctrlKey) return + + event.preventDefault() + dispatch(shouldReplace ? replace(to) : push(to)) + }, + [dispatch, to, shouldReplace], + ) + + return ( + + {children} + + ) +} +``` + +### Usage in Components + +```tsx +// pages/UserList.tsx +import { useSelector } from 'react-redux' +import { Link } from '../components/Link' +import { useNavigation } from '../hooks/useNavigation' +import type { RootState } from '../store' + +export default function UserList() { + const { pathname, query } = useSelector((state: RootState) => state.router) + const { navigate, goBack } = useNavigation() + + const handleUserClick = (userId: string) => { + navigate(`/users/${userId}`) + } + + return ( +
+

Users

+

Current path: {pathname}

+

Search: {query.search || 'none'}

+ + + + {/* Declarative navigation */} + User 1 + User 2 + + {/* Programmatic navigation */} + +
+ ) +} +``` + +## Passing Route Params to Redux + +Store route params in Redux for components that need them: + +```ts +// store/router/types.ts +export interface RouterState { + pathname: string + search: string + hash: string + query: Record + params: Record // Add params +} + +// store/router/actions.ts +export const setRouteParams = (params: Record) => ({ + type: 'router/SET_PARAMS' as const, + payload: params, +}) +``` + +```tsx +// router/index.tsx +const routes = [ + { + path: '/users/:id', + async action(context) { + const { id } = context.params + + // Store params in Redux before rendering + context.store.dispatch(setRouteParams({ id })) + + const { default: User } = await import('../pages/User') + return + }, + }, +] +``` + +```tsx +// pages/User.tsx +import { useSelector } from 'react-redux' +import type { RootState } from '../store' + +export default function User() { + // Get params from Redux, not from router directly + const { id } = useSelector((state: RootState) => state.router.params) + + return

User {id}

+} +``` + +## Time-Travel Debugging + +With Redux DevTools, you can: + +- See navigation history as actions +- Jump to any previous location +- Replay navigation sequences + +To make time-travel work correctly, the middleware needs to sync history: + +```ts +// Enhanced middleware with time-travel support +export function createRouterMiddleware(): Middleware { + return (store) => { + let isTimeTraveling = false + + // Detect Redux DevTools time travel + if (window.__REDUX_DEVTOOLS_EXTENSION__) { + window.__REDUX_DEVTOOLS_EXTENSION__.subscribe((message) => { + if ( + message.type === 'DISPATCH' && + message.payload.type === 'JUMP_TO_STATE' + ) { + isTimeTraveling = true + const state = JSON.parse(message.state) + // Update browser URL to match jumped-to state + window.history.replaceState(null, '', state.router.pathname) + isTimeTraveling = false + } + }) + } + + window.addEventListener('popstate', () => { + if (!isTimeTraveling) { + store.dispatch( + locationChange({ + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + }), + ) + } + }) + + // ... rest of middleware + } +} +``` + +## Server-Side Rendering + +For SSR, create the store with the initial location: + +```tsx +// server.tsx +import { configureStore } from '@reduxjs/toolkit' +import { renderToString } from 'react-dom/server' +import { Provider } from 'react-redux' +import UniversalRouter from 'universal-router' +import routes from './routes' +import { routerReducer } from './store/router/reducer' + +app.get('*', async (req, res) => { + // Create store with current URL + const store = configureStore({ + reducer: { router: routerReducer }, + preloadedState: { + router: { + pathname: req.path, + search: req.search || '', + hash: '', + query: req.query, + params: {}, + }, + }, + }) + + const router = new UniversalRouter(routes, { + context: { store }, + }) + + const page = await router.resolve(req.path) + + const html = renderToString({page}) + + res.send(` + + + +
${html}
+ + + + + `) +}) +``` + +## Legacy: Verbose Action Constants + +If you need explicit action type constants (for older codebases or specific tooling), +here's the traditional Redux approach: + +```ts +// store/router/types.ts +export const LOCATION_CHANGE = 'router/LOCATION_CHANGE' +export const PUSH = 'router/PUSH' +export const REPLACE = 'router/REPLACE' + +// store/router/actions.ts +export const locationChange = (payload: LocationChangePayload) => ({ + type: LOCATION_CHANGE as typeof LOCATION_CHANGE, + payload, +}) + +export const push = (pathname: string) => ({ + type: PUSH as typeof PUSH, + payload: { pathname }, +}) + +export type RouterAction = + | ReturnType + | ReturnType +// ... other actions + +// store/router/reducer.ts +export function routerReducer( + state = initialState, + action: RouterAction, +): RouterState { + switch (action.type) { + case LOCATION_CHANGE: + return { ...state, ...action.payload } + default: + return state + } +} +``` + +> **Note**: Redux Toolkit's `createSlice` generates action types automatically and +> provides better TypeScript inference. Use the legacy approach only when required +> by existing infrastructure. + +## Common Pitfalls + +### 1. Stale Component Renders + +When route params change, connected components may render with old params: + +```tsx +// Problem: Component sees old params briefly +function User() { + const params = useSelector((state) => state.router.params) + const users = useSelector((state) => state.users) + + // If params update before users, this may show wrong user + return
{users[params.id]?.name}
+} + +// Solution: Handle loading/transition states +function User() { + const params = useSelector((state) => state.router.params) + const users = useSelector((state) => state.users) + const user = users[params.id] + + if (!user) return + return
{user.name}
+} +``` + +### 2. Circular Dependencies + +Route actions dispatching actions that trigger re-routing: + +```tsx +// Problem: Infinite loop +{ + path: '/users', + action(context) { + context.store.dispatch(push('/users')) // Triggers itself! + } +} + +// Solution: Only dispatch if necessary +{ + path: '/users', + action(context) { + const state = context.store.getState() + if (state.router.pathname !== '/users') { + context.store.dispatch(push('/users')) + } + } +} +``` + +### 3. Not Handling Initial Route + +Ensure store is initialized before first render: + +```tsx +// Wrong - store not initialized +const store = configureStore({ reducer: { router: routerReducer } }) + +// Correct - initialize with current location +const store = configureStore({ + reducer: { router: routerReducer }, + preloadedState: { + router: { + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + query: Object.fromEntries(new URLSearchParams(window.location.search)), + }, + }, +}) +``` + +## See Also + +- [SPA Navigation](./spa-navigation.md) - Client-side navigation basics +- [Isomorphic Routing](./isomorphic-routing.md) - Server-side rendering +- [Authorization](./authorization.md) - Protected routes +- [redux-first-routing](https://github.com/mksarge/redux-first-routing) - Alternative routing library +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/route-priorities.md b/docs/route-priorities.md new file mode 100644 index 0000000..4dece80 --- /dev/null +++ b/docs/route-priorities.md @@ -0,0 +1,426 @@ +# Route Matching Order and Priorities + +Universal Router uses a simple, predictable matching algorithm: routes are matched in the order +they are defined, from top to bottom. The first route whose path matches the URL pathname +**and** whose action returns a value (not `null` or `undefined`) wins. Understanding this +behavior is crucial for designing reliable route configurations. + +## First-Match Wins + +Routes are evaluated in definition order. Once a match is found and the action returns a value, +the search stops. + +```js +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter([ + { path: '/about', action: () => 'About Page' }, // Checked first + { path: '/contact', action: () => 'Contact Page' }, // Checked second + { path: '/:page', action: (ctx, p) => `Dynamic: ${p.page}` }, // Checked third +]) + +await router.resolve('/about') +// => 'About Page' (first route matches) + +await router.resolve('/contact') +// => 'Contact Page' (second route matches) + +await router.resolve('/pricing') +// => 'Dynamic: pricing' (third route matches) +``` + +## Static vs. Parameterized Routes + +A common pitfall is placing parameterized routes before static routes. The parameter route +will match URLs that should go to specific routes. + +### Problem: Parameter Route Too Early + +```js +// Wrong order - parameter catches everything! +const router = new UniversalRouter([ + { path: '/:username', action: (ctx, p) => `User: ${p.username}` }, + { path: '/about', action: () => 'About Page' }, // Never reached! + { path: '/settings', action: () => 'Settings' }, // Never reached! +]) + +await router.resolve('/about') +// => 'User: about' - Wrong! Matched the parameter route +``` + +### Solution: Specific Routes First + +```js +// Correct order - specific routes before parameters +const router = new UniversalRouter([ + { path: '/about', action: () => 'About Page' }, + { path: '/settings', action: () => 'Settings' }, + { path: '/:username', action: (ctx, p) => `User: ${p.username}` }, +]) + +await router.resolve('/about') +// => 'About Page' - Correct! + +await router.resolve('/john') +// => 'User: john' - Also correct! +``` + +## Ordering Guidelines + +Follow these guidelines when ordering routes: + +### 1. Most Specific First + +```js +const router = new UniversalRouter([ + // Most specific - exact static paths + { path: '/users/me', action: () => 'Current User' }, + { path: '/users/new', action: () => 'Create User' }, + + // Less specific - single parameter + { path: '/users/:id', action: (ctx, p) => `User ${p.id}` }, + + // Least specific - multiple parameters + { + path: '/users/:id/:action', + action: (ctx, p) => `${p.action} user ${p.id}`, + }, +]) +``` + +### 2. Longer Paths Before Shorter + +```js +const router = new UniversalRouter([ + { path: '/api/v2/users', action: () => 'API v2 Users' }, + { path: '/api/v1/users', action: () => 'API v1 Users' }, + { path: '/api/users', action: () => 'API Users (default)' }, +]) +``` + +### 3. Wildcards Last + +Wildcard routes (`*name`) match multiple segments and should be placed after more specific routes: + +```js +const router = new UniversalRouter([ + { path: '/docs', action: () => 'Documentation Home' }, + { path: '/docs/api', action: () => 'API Reference' }, + { path: '/docs/guides/:guide', action: (ctx, p) => `Guide: ${p.guide}` }, + { path: '/docs/*path', action: (ctx, p) => `Doc: ${p.path.join('/')}` }, // Catch-all last +]) +``` + +## Handling Conflicts + +### Same Path, Different Conditions + +When you need the same path to behave differently based on conditions, use a single route +with conditional logic: + +```js +const router = new UniversalRouter([ + { + path: '/dashboard', + action: (context) => { + if (!context.user) { + return { redirect: '/login' } + } + if (context.user.role === 'admin') { + return { component: 'AdminDashboard' } + } + return { component: 'UserDashboard' } + }, + }, +]) +``` + +### Overlapping Parameter Patterns + +When routes have different parameter patterns that could overlap: + +```js +// Problem: How to match /tickets/seattle-washington vs /tickets/UA-123? +const router = new UniversalRouter([ + { + path: '/tickets/:route', // Could be either! + action: (context, params) => { + // Determine which type based on pattern + if (params.route.match(/^[A-Z]{2}-\d+$/)) { + return { type: 'flight', code: params.route } + } + return { type: 'destination', route: params.route } + }, + }, +]) +``` + +Or use separate routes with validation: + +```js +const router = new UniversalRouter([ + { + path: '/tickets/:flightCode', + action: (context, params) => { + // Only match flight codes (e.g., UA-123) + if (!params.flightCode.match(/^[A-Z]{2}-\d+$/)) { + return null // Skip to next route + } + return { type: 'flight', code: params.flightCode } + }, + }, + { + path: '/tickets/:destination', + action: (context, params) => { + return { type: 'destination', name: params.destination } + }, + }, +]) + +await router.resolve('/tickets/UA-123') +// => { type: 'flight', code: 'UA-123' } + +await router.resolve('/tickets/seattle-washington') +// => { type: 'destination', name: 'seattle-washington' } +``` + +## Using `null` to Skip Routes + +Return `null` from an action to explicitly skip the route and continue matching: + +```js +const router = new UniversalRouter([ + { + path: '/posts/:id', + action: async (context, params) => { + // Only match numeric IDs + if (!/^\d+$/.test(params.id)) { + return null // Skip to next route + } + const post = await fetchPost(params.id) + return post ? { post } : null // Skip if not found + }, + }, + { + path: '/posts/:slug', + action: async (context, params) => { + // Match slug-based posts + const post = await fetchPostBySlug(params.slug) + return post ? { post } : null + }, + }, + { + path: '/posts/*rest', + action: () => ({ error: 'Post not found', status: 404 }), + }, +]) +``` + +## Nested Route Priority + +Within nested routes, children are matched in order after the parent matches: + +```js +const router = new UniversalRouter({ + path: '/admin', + children: [ + { path: '/users/new', action: () => 'Create User' }, // Matched first for /admin/users/new + { path: '/users/:id', action: (ctx, p) => `User ${p.id}` }, // Matched second + { path: '/users', action: () => 'User List' }, // Matched for /admin/users + ], +}) +``` + +### Parent Action with `next()` + +When a parent has an action that calls `next()`, children are evaluated in order: + +```js +const router = new UniversalRouter({ + path: '/api', + action: async (context) => { + // Middleware - runs first + console.log('API request:', context.pathname) + return context.next() // Continue to children + }, + children: [ + { path: '/users', action: () => ({ data: 'users' }) }, + { path: '/posts', action: () => ({ data: 'posts' }) }, + { path: '/*rest', action: () => ({ error: 'Not found' }) }, + ], +}) +``` + +## Route Priority Patterns + +### Feature Flags + +```js +const router = new UniversalRouter([ + { + path: '/checkout', + action: (context) => { + // New checkout for beta users + if (context.features?.newCheckout) { + return { component: 'CheckoutV2' } + } + return null // Fall through to old checkout + }, + }, + { + path: '/checkout', + action: () => ({ component: 'CheckoutV1' }), + }, +]) +``` + +### A/B Testing + +```js +const router = new UniversalRouter([ + { + path: '/landing', + action: (context) => { + // 50/50 split + const variant = + context.abTest?.landing || (Math.random() > 0.5 ? 'A' : 'B') + + if (variant === 'A') { + return { component: 'LandingA', variant } + } + return { component: 'LandingB', variant } + }, + }, +]) +``` + +### Gradual Migration + +```js +const router = new UniversalRouter([ + // New routes being rolled out + { + path: '/products/:id', + action: (context, params) => { + // Only serve new page for certain product categories + if (context.newProductPages?.includes(params.id.split('-')[0])) { + return { component: 'NewProductPage' } + } + return null // Use old page + }, + }, + // Legacy route + { + path: '/products/:id', + action: () => ({ component: 'LegacyProductPage' }), + }, +]) +``` + +## Debugging Route Matching + +Add logging to understand which routes are being evaluated: + +```js +const router = new UniversalRouter(routes, { + resolveRoute(context, params) { + console.log('Checking route:', context.route.path, 'for:', context.pathname) + + if (typeof context.route.action === 'function') { + const result = context.route.action(context, params) + console.log('Action result:', result === null ? 'null (skip)' : result) + return result + } + return undefined + }, +}) +``` + +### Using Route Names for Debugging + +```js +const router = new UniversalRouter([ + { path: '/users', name: 'user-list', action: () => 'List' }, + { path: '/users/:id', name: 'user-detail', action: () => 'Detail' }, +]) + +// In resolveRoute +console.log('Matched route:', context.route.name) +``` + +## Common Pitfalls + +### 1. Catch-All Before Specific Routes + +```js +// Wrong - catch-all first +const routes = [ + { path: '/*path', action: () => 'Catch All' }, + { path: '/about', action: () => 'About' }, // Never reached! +] + +// Correct - catch-all last +const routes = [ + { path: '/about', action: () => 'About' }, + { path: '/*path', action: () => 'Catch All' }, +] +``` + +### 2. Empty Path Priority + +Empty paths (`path: ''`) match their parent exactly: + +```js +const router = new UniversalRouter({ + path: '/users', + children: [ + { path: '', action: () => 'User Index' }, // Matches /users + { path: '/:id', action: () => 'User Detail' }, // Matches /users/123 + ], +}) +``` + +### 3. Forgetting Return Value Matters + +Routes that return `undefined` continue matching; routes that return `null` skip: + +```js +{ + path: '/page', + action: (context) => { + if (!context.authorized) { + // Returns undefined - continues to children or next route + return undefined + } + // Returns value - stops matching + return { content: 'Page' } + } +} +``` + +### 4. Async Actions and Order + +Async actions are awaited before checking the result: + +```js +const router = new UniversalRouter([ + { + path: '/:id', + async action(context, params) { + const item = await fetchItem(params.id) + if (!item) return null // Skip to next route + return { item } + }, + }, + { + path: '/:id', + action: () => ({ error: 'Not found' }), + }, +]) +``` + +## See Also + +- [Nested Routes and Layouts](./nested-routes.md) - Route hierarchy and matching +- [Handling 404 Not Found](./404-not-found.md) - Catch-all and error routes +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/scroll-behavior.md b/docs/scroll-behavior.md new file mode 100644 index 0000000..e60b69a --- /dev/null +++ b/docs/scroll-behavior.md @@ -0,0 +1,716 @@ +# Controlling Scroll Behavior + +When building single-page applications (SPAs), managing scroll position is essential for +a good user experience. Users expect the page to scroll to the top when navigating to +a new page, return to their previous position when going back, and jump to anchors +when clicking hash links. + +Universal Router doesn't manage scroll behavior directly - it focuses on URL matching +and route resolution. This guide shows how to implement scroll management alongside +your router. + +## Common Scroll Behaviors + +There are several scroll behaviors users expect: + +| Navigation Type | Expected Behavior | +| ---------------------- | -------------------------------- | +| New page (link click) | Scroll to top | +| Back/forward button | Restore previous scroll position | +| Hash link (`#section`) | Scroll to element with that ID | +| Same-page navigation | Maintain current position | +| Route with state | Use custom position from state | + +## Basic Scroll to Top + +The simplest implementation scrolls to the top on every navigation: + +```ts +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter(routes) + +async function navigate(pathname: string) { + const result = await router.resolve(pathname) + render(result) + + // Scroll to top after rendering + window.scrollTo(0, 0) +} + +// Handle link clicks +document.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a') + if (link && link.hostname === window.location.hostname) { + event.preventDefault() + history.pushState(null, '', link.href) + navigate(link.pathname) + } +}) +``` + +## Scroll Position Restoration + +Preserve and restore scroll positions for browser back/forward navigation: + +```ts +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter(routes) + +// Store scroll positions by history state key +const scrollPositions = new Map() + +// Generate unique key for each history entry +function getHistoryKey(): string { + return history.state?.key || 'initial' +} + +// Save current scroll position before navigating away +function saveScrollPosition() { + const key = getHistoryKey() + scrollPositions.set(key, { + x: window.scrollX, + y: window.scrollY, + }) +} + +// Restore scroll position for current history entry +function restoreScrollPosition() { + const key = getHistoryKey() + const position = scrollPositions.get(key) + + if (position) { + window.scrollTo(position.x, position.y) + } else { + window.scrollTo(0, 0) + } +} + +async function navigate(pathname: string, options?: { replace?: boolean }) { + const result = await router.resolve(pathname) + render(result) + + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + restoreScrollPosition() + }) +} + +// Handle link clicks - new navigation +document.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a') + if (link && link.hostname === window.location.hostname) { + event.preventDefault() + + // Save current position before navigating + saveScrollPosition() + + // Create new history entry with unique key + const key = Date.now().toString() + history.pushState({ key }, '', link.href) + + navigate(link.pathname) + } +}) + +// Handle back/forward - restore position +window.addEventListener('popstate', () => { + navigate(window.location.pathname) +}) +``` + +## Hash Anchor Support + +Handle hash links that scroll to elements on the page: + +```ts +import UniversalRouter from 'universal-router' + +const router = new UniversalRouter(routes) + +interface NavigateOptions { + replace?: boolean + hash?: string + scroll?: boolean +} + +async function navigate(pathname: string, options: NavigateOptions = {}) { + const result = await router.resolve(pathname) + render(result) + + // Handle scrolling after render + requestAnimationFrame(() => { + const hash = options.hash || window.location.hash + + if (hash) { + // Scroll to element with matching ID + scrollToHash(hash) + } else if (options.scroll !== false) { + // Default: scroll to top for new pages + window.scrollTo(0, 0) + } + }) +} + +function scrollToHash(hash: string) { + // Remove the leading # + const id = hash.slice(1) + if (!id) return + + // Find element by ID or name attribute + const element = + document.getElementById(id) || document.querySelector(`[name="${id}"]`) + + if (element) { + element.scrollIntoView() + // Optionally set focus for accessibility + if (element.tabIndex === -1) { + element.tabIndex = -1 + } + element.focus({ preventScroll: true }) + } +} + +// Handle link clicks +document.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a') + if (!link || link.hostname !== window.location.hostname) return + + event.preventDefault() + + const url = new URL(link.href) + const isSamePage = url.pathname === window.location.pathname + + if (isSamePage && url.hash) { + // Same page, just scroll to anchor + history.pushState(null, '', url.hash) + scrollToHash(url.hash) + } else { + // Different page + history.pushState(null, '', link.href) + navigate(url.pathname, { hash: url.hash }) + } +}) +``` + +## Smooth Scrolling + +Add smooth scrolling for a polished feel: + +```ts +interface ScrollOptions { + behavior?: ScrollBehavior + offset?: number +} + +function scrollToTop(options: ScrollOptions = {}) { + window.scrollTo({ + top: 0, + left: 0, + behavior: options.behavior || 'smooth', + }) +} + +function scrollToElement(element: Element, options: ScrollOptions = {}) { + const rect = element.getBoundingClientRect() + const offset = options.offset || 0 + + window.scrollTo({ + top: window.scrollY + rect.top - offset, + left: 0, + behavior: options.behavior || 'smooth', + }) +} + +function scrollToHash(hash: string, options: ScrollOptions = {}) { + const id = hash.slice(1) + const element = document.getElementById(id) + + if (element) { + scrollToElement(element, options) + } +} + +// Usage +async function navigate(pathname: string) { + const result = await router.resolve(pathname) + render(result) + + requestAnimationFrame(() => { + const hash = window.location.hash + if (hash) { + // Account for fixed header (e.g., 64px) + scrollToHash(hash, { offset: 64, behavior: 'smooth' }) + } else { + scrollToTop({ behavior: 'smooth' }) + } + }) +} +``` + +## Instant vs Smooth Scrolling + +Use instant scrolling for back/forward, smooth for new navigation: + +```ts +type NavigationType = 'push' | 'pop' | 'replace' + +async function navigate(pathname: string, type: NavigationType = 'push') { + const result = await router.resolve(pathname) + render(result) + + requestAnimationFrame(() => { + const hash = window.location.hash + // Use instant scroll for back/forward, smooth for new navigation + const behavior = type === 'pop' ? 'instant' : 'smooth' + + if (hash) { + scrollToHash(hash, { behavior }) + } else if (type === 'pop') { + restoreScrollPosition() + } else { + scrollToTop({ behavior }) + } + }) +} + +// Back/forward +window.addEventListener('popstate', () => { + navigate(window.location.pathname, 'pop') +}) + +// Link clicks +document.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a') + if (link && link.hostname === window.location.hostname) { + event.preventDefault() + saveScrollPosition() + history.pushState({ key: Date.now().toString() }, '', link.href) + navigate(new URL(link.href).pathname, 'push') + } +}) +``` + +## Preserving Scroll on Filter/Sort Changes + +When updating filters (like in issue #104), preserve scroll position: + +```ts +async function updateFilters(filters: Record) { + // Build new URL with filter params + const params = new URLSearchParams(filters) + const newPath = `${window.location.pathname}?${params}` + + // Use replaceState to not add history entry + history.replaceState(history.state, '', newPath) + + // Resolve with the new query params + const result = await router.resolve({ + pathname: window.location.pathname, + query: filters, + }) + + render(result) + + // Don't scroll - preserve current position +} + +// Example usage in a filter component +function FilterCheckbox({ name, value, checked, onChange }) { + return ( + { + const newFilters = { ...currentFilters } + if (e.target.checked) { + newFilters[name] = value + } else { + delete newFilters[name] + } + updateFilters(newFilters) + }} + /> + ) +} +``` + +## Complete Scroll Manager + +Here's a full-featured scroll manager you can use: + +```ts +interface ScrollManagerOptions { + /** Offset from top for fixed headers */ + offset?: number + /** Default scroll behavior */ + behavior?: ScrollBehavior + /** Custom scroll container (defaults to window) */ + container?: Element | null +} + +class ScrollManager { + private positions = new Map() + private options: Required + + constructor(options: ScrollManagerOptions = {}) { + this.options = { + offset: options.offset || 0, + behavior: options.behavior || 'smooth', + container: options.container || null, + } + } + + /** Save current scroll position */ + save(key: string = this.getKey()) { + if (this.options.container) { + this.positions.set(key, { + x: this.options.container.scrollLeft, + y: this.options.container.scrollTop, + }) + } else { + this.positions.set(key, { + x: window.scrollX, + y: window.scrollY, + }) + } + } + + /** Restore saved scroll position */ + restore(key: string = this.getKey(), behavior?: ScrollBehavior) { + const position = this.positions.get(key) + if (position) { + this.scrollTo(position.x, position.y, behavior || 'instant') + return true + } + return false + } + + /** Scroll to top */ + toTop(behavior?: ScrollBehavior) { + this.scrollTo(0, 0, behavior || this.options.behavior) + } + + /** Scroll to element by ID */ + toElement(id: string, behavior?: ScrollBehavior) { + const element = document.getElementById(id) + if (element) { + this.toElementNode(element, behavior) + } + } + + /** Scroll to element node */ + toElementNode(element: Element, behavior?: ScrollBehavior) { + const rect = element.getBoundingClientRect() + const currentScroll = this.options.container + ? this.options.container.scrollTop + : window.scrollY + + this.scrollTo( + 0, + currentScroll + rect.top - this.options.offset, + behavior || this.options.behavior, + ) + } + + /** Scroll to hash (including #) */ + toHash(hash: string, behavior?: ScrollBehavior) { + if (hash.startsWith('#')) { + this.toElement(hash.slice(1), behavior) + } + } + + /** Get current history key */ + private getKey(): string { + return history.state?.key || 'initial' + } + + /** Perform the scroll */ + private scrollTo(x: number, y: number, behavior: ScrollBehavior) { + const options: ScrollToOptions = { left: x, top: y, behavior } + + if (this.options.container) { + this.options.container.scrollTo(options) + } else { + window.scrollTo(options) + } + } +} + +// Usage +const scrollManager = new ScrollManager({ offset: 64 }) + +async function navigate( + pathname: string, + type: 'push' | 'pop' | 'replace' = 'push', +) { + // Save before navigating (for push/replace) + if (type !== 'pop') { + scrollManager.save() + } + + const result = await router.resolve(pathname) + render(result) + + requestAnimationFrame(() => { + const hash = window.location.hash + + if (hash) { + scrollManager.toHash(hash, type === 'pop' ? 'instant' : 'smooth') + } else if (type === 'pop') { + scrollManager.restore() + } else { + scrollManager.toTop() + } + }) +} +``` + +## React Integration + +Create a hook for scroll management in React: + +```tsx +import { useEffect, useRef, useCallback } from 'react' + +interface UseScrollRestorationOptions { + offset?: number +} + +export function useScrollRestoration( + options: UseScrollRestorationOptions = {}, +) { + const positions = useRef(new Map()) + const { offset = 0 } = options + + const getKey = useCallback(() => { + return history.state?.key || window.location.pathname + }, []) + + const save = useCallback(() => { + positions.current.set(getKey(), window.scrollY) + }, [getKey]) + + const restore = useCallback(() => { + const y = positions.current.get(getKey()) + if (y !== undefined) { + window.scrollTo({ top: y, behavior: 'instant' }) + return true + } + return false + }, [getKey]) + + const scrollToTop = useCallback((smooth = true) => { + window.scrollTo({ + top: 0, + behavior: smooth ? 'smooth' : 'instant', + }) + }, []) + + const scrollToHash = useCallback( + (hash: string, smooth = true) => { + const element = document.getElementById(hash.replace('#', '')) + if (element) { + const y = element.getBoundingClientRect().top + window.scrollY - offset + window.scrollTo({ + top: y, + behavior: smooth ? 'smooth' : 'instant', + }) + } + }, + [offset], + ) + + return { save, restore, scrollToTop, scrollToHash } +} + +// Usage in a navigation component +function AppRouter() { + const [content, setContent] = useState(null) + const { save, restore, scrollToTop, scrollToHash } = useScrollRestoration({ + offset: 64, + }) + const navigationTypeRef = useRef<'push' | 'pop'>('push') + + useEffect(() => { + async function handleNavigation() { + const result = await router.resolve(window.location.pathname) + setContent(result) + + requestAnimationFrame(() => { + const hash = window.location.hash + const isPop = navigationTypeRef.current === 'pop' + + if (hash) { + scrollToHash(hash, !isPop) + } else if (isPop) { + restore() + } else { + scrollToTop() + } + + navigationTypeRef.current = 'push' + }) + } + + handleNavigation() + + const handlePopState = () => { + navigationTypeRef.current = 'pop' + handleNavigation() + } + + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, [restore, scrollToTop, scrollToHash]) + + const navigate = useCallback( + (href: string) => { + save() + history.pushState({ key: Date.now().toString() }, '', href) + // Trigger navigation + window.dispatchEvent(new PopStateEvent('popstate')) + }, + [save], + ) + + return ( + + {content} + + ) +} +``` + +## CSS scroll-behavior + +You can also use CSS for smooth scrolling, but it affects all scrolling: + +```css +/* Global smooth scrolling */ +html { + scroll-behavior: smooth; +} + +/* Respect user preference for reduced motion */ +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} +``` + +Then in JavaScript, just use regular `scrollTo`: + +```ts +// CSS handles the smooth animation +window.scrollTo(0, 0) +``` + +## Common Pitfalls + +### 1. Scrolling Before Render Completes + +Don't scroll immediately - wait for the DOM to update: + +```ts +// Wrong - DOM might not be ready +const result = await router.resolve(pathname) +render(result) +window.scrollTo(0, 0) // Might scroll before content renders + +// Correct - wait for next frame +const result = await router.resolve(pathname) +render(result) +requestAnimationFrame(() => { + window.scrollTo(0, 0) +}) +``` + +### 2. Hash Elements Not Found + +Elements might not exist yet when scrolling to hash: + +```ts +// Wrong - element might not exist +function navigate(pathname) { + render(result) + scrollToHash(window.location.hash) +} + +// Correct - wait for render, with retry +function navigate(pathname) { + render(result) + + requestAnimationFrame(() => { + const hash = window.location.hash + if (!hash) return + + const element = document.getElementById(hash.slice(1)) + if (element) { + element.scrollIntoView() + } else { + // Retry after async content loads + setTimeout(() => { + const el = document.getElementById(hash.slice(1)) + el?.scrollIntoView() + }, 100) + } + }) +} +``` + +### 3. Losing Position on Re-renders + +Component re-renders can disrupt scroll position: + +```tsx +// Wrong - loses scroll position on every state change +function Page() { + const [data, setData] = useState(null) + + useEffect(() => { + fetchData().then(setData) + }, []) + + if (!data) return // Different height! + + return +} + +// Better - maintain layout during loading +function Page() { + const [data, setData] = useState(null) + + useEffect(() => { + fetchData().then(setData) + }, []) + + return ( +
+ {data ? : } +
+ ) +} +``` + +### 4. Fixed Headers Covering Content + +Account for fixed headers when scrolling to elements: + +```ts +// Wrong - element hidden under fixed header +element.scrollIntoView() + +// Correct - account for header height +const headerHeight = 64 +const y = element.getBoundingClientRect().top + window.scrollY - headerHeight +window.scrollTo({ top: y, behavior: 'smooth' }) +``` + +## See Also + +- [SPA Navigation](./spa-navigation.md) - Client-side routing basics +- [Query Params and Hash](./query-params-hash.md) - Working with URL fragments +- [Page Transitions](./page-transitions.md) - Animated route transitions diff --git a/docs/server-methods.md b/docs/server-methods.md new file mode 100644 index 0000000..079f889 --- /dev/null +++ b/docs/server-methods.md @@ -0,0 +1,695 @@ +# Handling HTTP Methods (GET, POST, PUT, DELETE) + +Universal Router is a path-based router that does not include built-in HTTP method handling. +This is by design - it keeps the core library lightweight and allows you to implement +method-aware routing in a way that fits your specific use case. + +This guide shows how to implement HTTP method handling for REST APIs, server-side +applications, and hybrid setups. + +## Why Universal Router Doesn't Include Method Handling + +Universal Router focuses on URL path matching and can run in any JavaScript environment +(browser, Node.js, workers, etc.). HTTP methods are only relevant in server contexts, +so method handling is left as an implementation detail: + +``` +Browser: Only uses GET (navigation) - no method handling needed +Server: Full REST API - needs method handling +Worker: Can be either - depends on use case +``` + +This approach keeps the router versatile and allows you to implement exactly what you need. + +## Basic Method Handling with Custom Resolver + +The most straightforward approach uses a custom `resolveRoute` function: + +```ts +import UniversalRouter from 'universal-router' +import type { Route, RouteContext, RouteParams } from 'universal-router' + +// Extend Route to include method handlers +interface MethodRoute extends Route { + get?: (context: RouteContext, params: RouteParams) => any + post?: (context: RouteContext, params: RouteParams) => any + put?: (context: RouteContext, params: RouteParams) => any + patch?: (context: RouteContext, params: RouteParams) => any + delete?: (context: RouteContext, params: RouteParams) => any +} + +// Define routes with method handlers +const routes: MethodRoute[] = [ + { + path: '/api/users', + get: async (ctx) => { + const users = await db.users.findAll() + return { status: 200, body: users } + }, + post: async (ctx) => { + const user = await db.users.create(ctx.body) + return { status: 201, body: user } + }, + }, + { + path: '/api/users/:id', + get: async (ctx, params) => { + const user = await db.users.findById(params.id) + if (!user) return { status: 404, body: { error: 'Not found' } } + return { status: 200, body: user } + }, + put: async (ctx, params) => { + const user = await db.users.update(params.id, ctx.body) + return { status: 200, body: user } + }, + delete: async (ctx, params) => { + await db.users.delete(params.id) + return { status: 204, body: null } + }, + }, +] + +const router = new UniversalRouter(routes, { + resolveRoute(context, params) { + const route = context.route as MethodRoute + const method = context.method?.toLowerCase() + + // Check for method-specific handler + if (method && route[method as keyof MethodRoute]) { + const handler = route[method as keyof MethodRoute] + if (typeof handler === 'function') { + return handler(context, params) + } + } + + // Fall back to generic action + if (typeof route.action === 'function') { + return route.action(context, params) + } + + return undefined + }, +}) +``` + +## Using with Express + +Integrate Universal Router as middleware in Express applications: + +```ts +import express from 'express' +import UniversalRouter from 'universal-router' + +const app = express() +app.use(express.json()) + +interface ApiResponse { + status: number + body: unknown + headers?: Record +} + +interface MethodRoute { + path: string + methods?: { + get?: (context: any) => Promise + post?: (context: any) => Promise + put?: (context: any) => Promise + delete?: (context: any) => Promise + } + children?: MethodRoute[] +} + +const routes: MethodRoute[] = [ + { + path: '/api', + children: [ + { + path: '/users', + methods: { + get: async () => ({ + status: 200, + body: await db.users.findAll(), + }), + post: async (ctx) => ({ + status: 201, + body: await db.users.create(ctx.body), + }), + }, + }, + { + path: '/users/:id', + methods: { + get: async (ctx) => { + const user = await db.users.findById(ctx.params.id) + return user + ? { status: 200, body: user } + : { status: 404, body: { error: 'User not found' } } + }, + put: async (ctx) => ({ + status: 200, + body: await db.users.update(ctx.params.id, ctx.body), + }), + delete: async (ctx) => { + await db.users.delete(ctx.params.id) + return { status: 204, body: null } + }, + }, + }, + ], + }, +] + +const router = new UniversalRouter(routes, { + resolveRoute(context, params) { + const route = context.route as unknown as MethodRoute + const method = context.method?.toLowerCase() as keyof MethodRoute['methods'] + + if (route.methods && route.methods[method]) { + return route.methods[method]!(context) + } + + return undefined + }, +}) + +// Express middleware +app.use('/api/*', async (req, res, next) => { + try { + const result = await router.resolve({ + pathname: req.path, + method: req.method, + body: req.body, + query: req.query, + headers: req.headers, + }) + + if (result) { + if (result.headers) { + Object.entries(result.headers).forEach(([key, value]) => { + res.setHeader(key, value) + }) + } + res.status(result.status).json(result.body) + } else { + next() + } + } catch (error: any) { + if (error.status === 404) { + res.status(404).json({ error: 'Not found' }) + } else { + next(error) + } + } +}) + +app.listen(3000) +``` + +## Using with Koa + +Similar integration for Koa applications: + +```ts +import Koa from 'koa' +import bodyParser from 'koa-bodyparser' +import UniversalRouter from 'universal-router' + +const app = new Koa() +app.use(bodyParser()) + +interface ApiResponse { + status: number + body: unknown +} + +interface MethodRoute { + path: string + methods?: Record Promise> + children?: MethodRoute[] +} + +const routes: MethodRoute[] = [ + { + path: '/api/posts', + methods: { + get: async () => ({ + status: 200, + body: await db.posts.findAll(), + }), + post: async (ctx) => ({ + status: 201, + body: await db.posts.create(ctx.body), + }), + }, + }, + { + path: '/api/posts/:id', + methods: { + get: async (ctx) => { + const post = await db.posts.findById(ctx.params.id) + return post + ? { status: 200, body: post } + : { status: 404, body: { error: 'Not found' } } + }, + put: async (ctx) => ({ + status: 200, + body: await db.posts.update(ctx.params.id, ctx.body), + }), + delete: async (ctx) => { + await db.posts.delete(ctx.params.id) + return { status: 204, body: null } + }, + }, + }, +] + +const router = new UniversalRouter(routes, { + resolveRoute(context, params) { + const route = context.route as unknown as MethodRoute + const method = context.method?.toLowerCase() + + if (route.methods && method && route.methods[method]) { + return route.methods[method](context) + } + + return undefined + }, +}) + +// Koa middleware +app.use(async (ctx, next) => { + try { + const result = await router.resolve({ + pathname: ctx.path, + method: ctx.method, + body: ctx.request.body, + query: ctx.query, + }) + + if (result) { + ctx.status = result.status + ctx.body = result.body + } else { + await next() + } + } catch (error: any) { + if (error.status === 404) { + ctx.status = 404 + ctx.body = { error: 'Not found' } + } else { + throw error + } + } +}) + +app.listen(3000) +``` + +## Method Not Allowed (405) Responses + +Properly return 405 when a route exists but doesn't support the requested method: + +```ts +interface MethodRoute { + path: string + methods?: Record Promise> + children?: MethodRoute[] +} + +const router = new UniversalRouter(routes, { + resolveRoute(context, params) { + const route = context.route as unknown as MethodRoute + const method = context.method?.toLowerCase() + + if (route.methods) { + // Route has method handlers defined + if (method && route.methods[method]) { + return route.methods[method](context) + } + + // Route matched but method not supported - return 405 + const allowedMethods = Object.keys(route.methods) + .map((m) => m.toUpperCase()) + .join(', ') + + return { + status: 405, + body: { error: 'Method not allowed' }, + headers: { Allow: allowedMethods }, + } + } + + return undefined + }, +}) +``` + +## RESTful Resource Pattern + +Create a helper function for defining RESTful resources: + +```ts +import UniversalRouter from 'universal-router' + +interface ResourceHandlers { + list?: () => Promise + create?: (data: Partial) => Promise + get?: (id: string) => Promise + update?: (id: string, data: Partial) => Promise + delete?: (id: string) => Promise +} + +interface ApiResponse { + status: number + body: unknown + headers?: Record +} + +function createResource( + basePath: string, + handlers: ResourceHandlers, +): any[] { + const routes: any[] = [] + + // Collection routes: /resource + const collectionMethods: Record = {} + + if (handlers.list) { + collectionMethods.get = async () => ({ + status: 200, + body: await handlers.list!(), + }) + } + + if (handlers.create) { + collectionMethods.post = async (ctx: any) => ({ + status: 201, + body: await handlers.create!(ctx.body), + }) + } + + if (Object.keys(collectionMethods).length > 0) { + routes.push({ + path: basePath, + methods: collectionMethods, + }) + } + + // Individual resource routes: /resource/:id + const itemMethods: Record = {} + + if (handlers.get) { + itemMethods.get = async (ctx: any) => { + const item = await handlers.get!(ctx.params.id) + return item + ? { status: 200, body: item } + : { status: 404, body: { error: 'Not found' } } + } + } + + if (handlers.update) { + itemMethods.put = async (ctx: any) => ({ + status: 200, + body: await handlers.update!(ctx.params.id, ctx.body), + }) + } + + if (handlers.delete) { + itemMethods.delete = async (ctx: any) => { + await handlers.delete!(ctx.params.id) + return { status: 204, body: null } + } + } + + if (Object.keys(itemMethods).length > 0) { + routes.push({ + path: `${basePath}/:id`, + methods: itemMethods, + }) + } + + return routes +} + +// Usage +const userRoutes = createResource('/api/users', { + list: () => db.users.findAll(), + create: (data) => db.users.create(data), + get: (id) => db.users.findById(id), + update: (id, data) => db.users.update(id, data), + delete: (id) => db.users.delete(id), +}) + +const postRoutes = createResource('/api/posts', { + list: () => db.posts.findAll(), + create: (data) => db.posts.create(data), + get: (id) => db.posts.findById(id), + update: (id, data) => db.posts.update(id, data), + delete: (id) => db.posts.delete(id), +}) + +const router = new UniversalRouter( + [...userRoutes, ...postRoutes], + { + resolveRoute(context, params) { + const route = context.route as any + const method = context.method?.toLowerCase() + + if (route.methods && method && route.methods[method]) { + return route.methods[method](context) + } + + return undefined + }, + }, +) +``` + +## Nested Resources + +Handle nested REST resources like `/users/:userId/posts/:postId`: + +```ts +const routes = [ + { + path: '/api/users/:userId', + children: [ + { + path: '/posts', + methods: { + get: async (ctx: any) => ({ + status: 200, + body: await db.posts.findByUser(ctx.params.userId), + }), + post: async (ctx: any) => ({ + status: 201, + body: await db.posts.create({ + ...ctx.body, + userId: ctx.params.userId, + }), + }), + }, + }, + { + path: '/posts/:postId', + methods: { + get: async (ctx: any) => { + const post = await db.posts.findByUserAndId( + ctx.params.userId, + ctx.params.postId, + ) + return post + ? { status: 200, body: post } + : { status: 404, body: { error: 'Post not found' } } + }, + put: async (ctx: any) => ({ + status: 200, + body: await db.posts.update(ctx.params.postId, ctx.body), + }), + delete: async (ctx: any) => { + await db.posts.delete(ctx.params.postId) + return { status: 204, body: null } + }, + }, + }, + ], + }, +] +``` + +## TypeScript Types for Method Routes + +Create proper TypeScript types for method-aware routes: + +```ts +import type { RouteContext, RouteParams, Route } from 'universal-router' + +// HTTP methods +type HttpMethod = + | 'get' + | 'post' + | 'put' + | 'patch' + | 'delete' + | 'head' + | 'options' + +// Response type +interface ApiResponse { + status: number + body: T + headers?: Record +} + +// Handler function type +type MethodHandler = ( + context: RouteContext & C, + params: RouteParams, +) => Promise> | ApiResponse + +// Route with method handlers +interface MethodRoute extends Omit { + methods?: Partial>> + children?: MethodRoute[] +} + +// Context with HTTP-specific properties +interface HttpContext { + method: string + body?: unknown + query?: Record + headers?: Record +} + +// Create typed router +function createHttpRouter(routes: MethodRoute[]) { + return new UniversalRouter(routes as Route[], { + resolveRoute(context, params) { + const route = context.route as unknown as MethodRoute + const method = context.method?.toLowerCase() as HttpMethod + + if (route.methods && route.methods[method]) { + return route.methods[method]!(context, params) + } + + // Return 405 if route has methods but not this one + if (route.methods && Object.keys(route.methods).length > 0) { + const allowed = Object.keys(route.methods) + .map((m) => m.toUpperCase()) + .join(', ') + return { + status: 405, + body: { error: 'Method not allowed' }, + headers: { Allow: allowed }, + } + } + + return undefined + }, + }) +} + +// Usage with full type safety +const router = createHttpRouter([ + { + path: '/api/users', + methods: { + get: async () => ({ + status: 200, + body: await db.users.findAll(), + }), + post: async (ctx) => ({ + status: 201, + body: await db.users.create(ctx.body as any), + }), + }, + }, +]) +``` + +## Common Pitfalls + +### 1. Forgetting to Pass Method to Context + +Always include the HTTP method when resolving: + +```ts +// Wrong - method not passed +const result = await router.resolve(req.path) + +// Correct - include method +const result = await router.resolve({ + pathname: req.path, + method: req.method, + body: req.body, +}) +``` + +### 2. Case Sensitivity + +HTTP methods from Express/Koa are uppercase, but your handlers might expect lowercase: + +```ts +// Normalize method case in resolver +const method = context.method?.toLowerCase() +``` + +### 3. Not Handling OPTIONS for CORS + +Remember to handle OPTIONS requests for CORS preflight: + +```ts +const routes = [ + { + path: '/api/users', + methods: { + options: async () => ({ + status: 204, + body: null, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }), + get: async () => ({ status: 200, body: await db.users.findAll() }), + post: async (ctx) => ({ + status: 201, + body: await db.users.create(ctx.body), + }), + }, + }, +] +``` + +### 4. Mixing Route-Level and Method-Level Handlers + +Be consistent about where logic lives: + +```ts +// Confusing - mixing action and methods +{ + path: '/api/users', + action: () => { /* ... */ }, // When is this called? + methods: { + get: () => { /* ... */ }, + }, +} + +// Clear - methods only +{ + path: '/api/users', + methods: { + get: () => { /* ... */ }, + post: () => { /* ... */ }, + }, +} +``` + +## See Also + +- [Authorization](./authorization.md) - Protecting API routes +- [API Reference](./api.md) - Complete router API +- [Nested Routes](./nested-routes.md) - Organizing route hierarchies +- [TypeScript Guide](./typescript.md) - Type-safe routing diff --git a/docs/spa-navigation.md b/docs/spa-navigation.md new file mode 100644 index 0000000..8723f78 --- /dev/null +++ b/docs/spa-navigation.md @@ -0,0 +1,416 @@ +# Single Page Application Navigation + +Universal Router is designed to be framework-agnostic and does not handle browser navigation directly. +This gives you complete control over how and when routes are resolved. This recipe shows how to +integrate the router with the browser's History API for seamless SPA navigation. + +## Understanding the Architecture + +Unlike traditional routers that automatically listen for URL changes, Universal Router follows a +"pull" model - you call `router.resolve()` when you want to handle navigation. This design makes +the router truly universal and gives you flexibility to: + +- Control exactly when routing happens +- Handle navigation differently on client and server +- Integrate with any state management solution +- Support custom navigation behaviors + +## Basic History API Integration + +The simplest way to handle SPA navigation is to listen for `popstate` events and intercept link clicks: + +```js +import UniversalRouter from 'universal-router' + +const routes = [ + { path: '/', action: () => '

Home

' }, + { path: '/about', action: () => '

About

' }, + { path: '/users/:id', action: (ctx) => `

User ${ctx.params.id}

` }, +] + +const router = new UniversalRouter(routes) + +// Render function that updates the page +async function render(pathname) { + try { + const html = await router.resolve(pathname) + document.getElementById('app').innerHTML = html + } catch (error) { + if (error.status === 404) { + document.getElementById('app').innerHTML = '

Page Not Found

' + } else { + console.error(error) + } + } +} + +// Handle browser back/forward buttons +window.addEventListener('popstate', () => { + render(window.location.pathname) +}) + +// Handle initial page load +render(window.location.pathname) +``` + +## Link Interception + +To enable client-side navigation for anchor tags without full page reloads: + +```js +// Navigate programmatically +function navigate(pathname) { + window.history.pushState(null, '', pathname) + render(pathname) +} + +// Intercept link clicks +document.addEventListener('click', (event) => { + // Find the closest anchor tag + const link = event.target.closest('a') + if (!link) return + + // Skip if modifier keys are pressed (open in new tab, etc.) + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + + // Skip external links + if (link.hostname !== window.location.hostname) return + + // Skip links with target attribute + if (link.target && link.target !== '_self') return + + // Skip download links + if (link.hasAttribute('download')) return + + // Skip hash-only links (same-page anchors) + if (link.pathname === window.location.pathname && link.hash) return + + // Prevent default and navigate + event.preventDefault() + navigate(link.pathname + link.search + link.hash) +}) +``` + +## Complete Navigation Module + +Here's a complete, reusable navigation module for SPAs: + +```ts +import UniversalRouter, { Route, RouteContext } from 'universal-router' + +interface NavigationOptions { + router: UniversalRouter + render: (result: unknown) => void + onError?: (error: Error & { status?: number }) => void +} + +interface NavigateOptions { + replace?: boolean + state?: unknown +} + +function createNavigation({ router, render, onError }: NavigationOptions) { + let currentPathname = '' + + async function handleNavigation(pathname: string) { + if (pathname === currentPathname) return + currentPathname = pathname + + try { + const result = await router.resolve(pathname) + render(result) + } catch (error) { + if (onError) { + onError(error as Error & { status?: number }) + } else { + throw error + } + } + } + + function navigate(pathname: string, options: NavigateOptions = {}) { + const { replace = false, state = null } = options + + if (replace) { + window.history.replaceState(state, '', pathname) + } else { + window.history.pushState(state, '', pathname) + } + + handleNavigation(pathname) + } + + function start() { + // Handle browser back/forward + window.addEventListener('popstate', () => { + handleNavigation(window.location.pathname) + }) + + // Intercept link clicks + document.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a') + if (!link) return + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) + return + if (link.hostname !== window.location.hostname) return + if (link.target && link.target !== '_self') return + if (link.hasAttribute('download')) return + if (link.pathname === window.location.pathname && link.hash) return + + event.preventDefault() + navigate(link.pathname + link.search + link.hash) + }) + + // Handle initial navigation + handleNavigation(window.location.pathname) + } + + return { navigate, start } +} + +// Usage +const router = new UniversalRouter(routes) + +const navigation = createNavigation({ + router, + render: (html) => { + document.getElementById('app')!.innerHTML = html as string + }, + onError: (error) => { + if (error.status === 404) { + document.getElementById('app')!.innerHTML = '

Page Not Found

' + } + }, +}) + +navigation.start() + +// Navigate programmatically from anywhere +navigation.navigate('/users/123') +navigation.navigate('/login', { replace: true }) // Replace current history entry +``` + +## Using the History Library + +For more robust history management, use the [history](https://github.com/remix-run/history) library: + +```js +import UniversalRouter from 'universal-router' +import { createBrowserHistory } from 'history' + +const history = createBrowserHistory() +const router = new UniversalRouter(routes) + +async function render(location) { + const result = await router.resolve(location.pathname) + document.getElementById('app').innerHTML = result +} + +// Listen for location changes +history.listen(({ location }) => { + render(location) +}) + +// Initial render +render(history.location) + +// Navigate programmatically +history.push('/about') +history.replace('/login') +history.back() +``` + +## Handling Page Reload + +When users reload the page, your server must be configured to serve the same HTML for all routes. +This is because the browser requests the current URL from the server, not just the root. + +For development servers: + +**Vite (vite.config.js):** + +```js +export default { + server: { + // Fallback to index.html for SPA routing + historyApiFallback: true, + }, +} +``` + +**Webpack Dev Server (webpack.config.js):** + +```js +module.exports = { + devServer: { + historyApiFallback: true, + }, +} +``` + +For production, configure your web server (nginx, Apache, etc.) to serve `index.html` for all routes: + +**nginx:** + +```nginx +location / { + try_files $uri $uri/ /index.html; +} +``` + +**Express:** + +```js +const express = require('express') +const path = require('path') + +const app = express() + +// Serve static files +app.use(express.static('dist')) + +// Fallback to index.html for SPA +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'dist', 'index.html')) +}) + +app.listen(3000) +``` + +## Scroll Restoration + +Browsers automatically manage scroll position for traditional navigation but not for SPA navigation. +Handle this manually: + +```js +// Store scroll positions +const scrollPositions = new Map() + +function navigate(pathname, options = {}) { + // Save current scroll position before navigating + scrollPositions.set(window.location.pathname, { + x: window.scrollX, + y: window.scrollY, + }) + + if (options.replace) { + window.history.replaceState(null, '', pathname) + } else { + window.history.pushState(null, '', pathname) + } + + render(pathname).then(() => { + if (options.scrollToTop !== false) { + window.scrollTo(0, 0) + } + }) +} + +// Restore scroll on back/forward +window.addEventListener('popstate', async () => { + const pathname = window.location.pathname + await render(pathname) + + const savedPosition = scrollPositions.get(pathname) + if (savedPosition) { + window.scrollTo(savedPosition.x, savedPosition.y) + } +}) +``` + +## Query String Handling + +Universal Router focuses on pathname matching. Handle query strings separately: + +```js +import UniversalRouter from 'universal-router' + +const routes = [ + { + path: '/search', + action(context) { + // Access query params from the context you passed + const query = context.query || {} + return `

Search results for: ${query.q || ''}

` + }, + }, +] + +const router = new UniversalRouter(routes) + +async function render(pathname) { + const url = new URL(pathname, window.location.origin) + const query = Object.fromEntries(url.searchParams) + + const result = await router.resolve({ + pathname: url.pathname, + query, // Pass query params as context + }) + + document.getElementById('app').innerHTML = result +} +``` + +## Common Pitfalls + +### 1. Forgetting Initial Render + +Always render on page load, not just on navigation: + +```js +// Wrong - only handles navigation, not initial load +window.addEventListener('popstate', () => render(window.location.pathname)) + +// Correct - handle both +window.addEventListener('popstate', () => render(window.location.pathname)) +render(window.location.pathname) // Initial render +``` + +### 2. Not Handling Hash Links + +Hash-only links should scroll to elements, not trigger routing: + +```js +document.addEventListener('click', (event) => { + const link = event.target.closest('a') + if (!link) return + + // Let hash-only links work normally + if (link.pathname === window.location.pathname && link.hash) { + return // Don't prevent default + } + + // Handle other links... +}) +``` + +### 3. Memory Leaks from Event Listeners + +Clean up listeners when appropriate (e.g., in component unmount): + +```js +function createNavigation(router) { + const handlePopState = () => render(window.location.pathname) + const handleClick = (event) => { + /* ... */ + } + + return { + start() { + window.addEventListener('popstate', handlePopState) + document.addEventListener('click', handleClick) + }, + stop() { + window.removeEventListener('popstate', handlePopState) + document.removeEventListener('click', handleClick) + }, + } +} +``` + +## See Also + +- [Isomorphic Routing](./isomorphic-routing.md) - Server-side rendering with client hydration +- [Redirects](./redirects.md) - Handling redirects in SPAs +- [Usage with React and Redux](./react-redux.md) - State management integration +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/synchronous-routing.md b/docs/synchronous-routing.md new file mode 100644 index 0000000..aad6724 --- /dev/null +++ b/docs/synchronous-routing.md @@ -0,0 +1,535 @@ +# Synchronous Routing Mode + +Universal Router provides a synchronous variant, `UniversalRouterSync`, for use cases +where async operations are not needed. This can simplify code, improve performance, +and is required when integrating with APIs that expect synchronous responses. + +## Why Use Synchronous Routing? + +The async `UniversalRouter` wraps all operations in Promises, even when your route +actions are synchronous. While this provides flexibility, it has overhead: + +```ts +// Async router always returns a Promise +const router = new UniversalRouter(routes) +const result = await router.resolve('/') // Must await + +// Sync router returns immediately +const routerSync = new UniversalRouterSync(routes) +const result = routerSync.resolve('/') // Direct value +``` + +Use `UniversalRouterSync` when: + +- All your route actions are synchronous +- You're integrating with sync-only APIs +- You want to avoid Promise overhead +- You need predictable execution timing + +## Basic Usage + +Import `UniversalRouterSync` from the dedicated module: + +```ts +import UniversalRouterSync from 'universal-router/sync' + +const routes = [ + { + path: '/', + action: () => 'Home Page', + }, + { + path: '/about', + action: () => 'About Page', + }, + { + path: '/users/:id', + action: (context, params) => `User ${params.id}`, + }, +] + +const router = new UniversalRouterSync(routes) + +// Returns value directly - no await needed +const result = router.resolve('/users/123') +console.log(result) // 'User 123' +``` + +## API Comparison + +The sync router has the same API as the async version, but returns values directly: + +```ts +// Async version +import UniversalRouter from 'universal-router' + +const asyncRouter = new UniversalRouter(routes, options) +const result = await asyncRouter.resolve(pathname) // Promise + +// Sync version +import UniversalRouterSync from 'universal-router/sync' + +const syncRouter = new UniversalRouterSync(routes, options) +const result = syncRouter.resolve(pathname) // R (direct value) +``` + +### Route Action Return Types + +```ts +// Async route actions can return promises +const asyncRoutes = [ + { + path: '/data', + async action() { + const data = await fetchData() + return data + }, + }, +] + +// Sync route actions must return values directly +const syncRoutes = [ + { + path: '/data', + action() { + // Cannot use async/await here + return getCachedData() + }, + }, +] +``` + +## TypeScript Types + +The sync router exports its own types: + +```ts +import UniversalRouterSync, { + RouteContext, + Route, + Routes, + RouterOptions, + RouteResultSync, +} from 'universal-router/sync' + +// Define typed routes +const routes: Routes = [ + { + path: '/', + action: () => 'Home', + }, + { + path: '/users/:id', + action: (context: RouteContext, params) => { + return `User ${params.id}` + }, + }, +] + +const router = new UniversalRouterSync(routes) +``` + +### Route Result Type + +The sync version uses `RouteResultSync` instead of `RouteResult`: + +```ts +// Async: can be T, null, undefined, or Promise +type RouteResult = T | null | undefined | Promise + +// Sync: no Promise allowed +type RouteResultSync = T | null | undefined +``` + +## Error Handling + +Error handling works the same as the async version, but errors are thrown +synchronously: + +```ts +import UniversalRouterSync from 'universal-router/sync' + +const routes = [{ path: '/', action: () => 'Home' }] + +const router = new UniversalRouterSync(routes) + +// Try/catch works directly +try { + const result = router.resolve('/not-found') +} catch (error) { + if (error.status === 404) { + console.log('Page not found') + } +} +``` + +### Error Handler Option + +```ts +const router = new UniversalRouterSync(routes, { + errorHandler(error, context) { + if (error.status === 404) { + return 'Page Not Found' + } + throw error // Re-throw other errors + }, +}) + +// No try/catch needed for 404s +const result = router.resolve('/not-found') // 'Page Not Found' +``` + +## Use Cases + +### Server-Side Rendering with Streaming + +Some SSR frameworks require synchronous route resolution: + +```ts +import UniversalRouterSync from 'universal-router/sync' +import { renderToString } from 'react-dom/server' + +const routes = [ + { path: '/', action: () => }, + { path: '/about', action: () => }, +] + +const router = new UniversalRouterSync(routes) + +function handleRequest(req, res) { + const component = router.resolve(req.path) + const html = renderToString(component) + res.send(html) +} +``` + +### State Management Integration + +Redux reducers must be synchronous: + +```ts +import UniversalRouterSync from 'universal-router/sync' + +const routes = [ + { path: '/', action: () => ({ page: 'home' }) }, + { path: '/about', action: () => ({ page: 'about' }) }, + { + path: '/users/:id', + action: (ctx, params) => ({ + page: 'user', + userId: params.id, + }), + }, +] + +const router = new UniversalRouterSync(routes) + +// Redux reducer +function routeReducer(state = {}, action) { + switch (action.type) { + case 'NAVIGATE': + // Sync resolution inside reducer + return router.resolve(action.pathname) + default: + return state + } +} +``` + +### Testing + +Sync routing simplifies testing by avoiding async/await: + +```ts +import UniversalRouterSync from 'universal-router/sync' + +describe('Router', () => { + const routes = [ + { path: '/', action: () => 'home' }, + { path: '/users/:id', action: (ctx, params) => `user-${params.id}` }, + ] + + const router = new UniversalRouterSync(routes) + + it('resolves home route', () => { + // No async/await needed + expect(router.resolve('/')).toBe('home') + }) + + it('resolves parameterized route', () => { + expect(router.resolve('/users/123')).toBe('user-123') + }) + + it('throws on unknown route', () => { + expect(() => router.resolve('/unknown')).toThrow() + }) +}) +``` + +### CLI Tools + +Command-line tools often work better with synchronous code: + +```ts +#!/usr/bin/env node +import UniversalRouterSync from 'universal-router/sync' + +const commands = [ + { + path: '/help', + action: () => 'Usage: cli ', + }, + { + path: '/version', + action: () => '1.0.0', + }, + { + path: '/greet/:name', + action: (ctx, params) => `Hello, ${params.name}!`, + }, +] + +const router = new UniversalRouterSync(commands, { + errorHandler: () => 'Unknown command. Try "cli help"', +}) + +// Parse command from argv +const command = '/' + process.argv.slice(2).join('/') +const output = router.resolve(command) +console.log(output) +``` + +### Performance-Critical Paths + +When route resolution is in a hot path: + +```ts +import UniversalRouterSync from 'universal-router/sync' + +const routes = [ + { path: '/api/health', action: () => ({ status: 'ok' }) }, + { path: '/api/metrics', action: () => getMetrics() }, +] + +const router = new UniversalRouterSync(routes) + +// Called thousands of times per second +function handleRequest(path: string) { + // No Promise overhead + return router.resolve(path) +} +``` + +## Custom Context + +Pass custom context data just like the async version: + +```ts +import UniversalRouterSync from 'universal-router/sync' + +interface AppContext { + user: { id: string; role: string } | null + config: { debug: boolean } +} + +const routes = [ + { + path: '/admin', + action(context) { + if (!context.user || context.user.role !== 'admin') { + return 'Access denied' + } + return 'Admin panel' + }, + }, + { + path: '/debug', + action(context) { + if (!context.config.debug) { + return 'Debug mode disabled' + } + return 'Debug info...' + }, + }, +] + +const router = new UniversalRouterSync(routes, { + context: { + user: null, + config: { debug: false }, + }, +}) + +// Override context per request +const result = router.resolve({ + pathname: '/admin', + user: { id: '1', role: 'admin' }, + config: { debug: true }, +}) +``` + +## Nested Routes and next() + +The `next()` function works synchronously: + +```ts +import UniversalRouterSync from 'universal-router/sync' + +const routes = [ + { + path: '/users', + action(context) { + console.log('Users middleware') + // Continue to children + return context.next() + }, + children: [ + { + path: '/:id', + action(context, params) { + return `User ${params.id}` + }, + }, + ], + }, +] + +const router = new UniversalRouterSync(routes) + +// Logs 'Users middleware' then returns 'User 123' +const result = router.resolve('/users/123') +``` + +## Migrating from Async to Sync + +If your routes don't use async operations, migrating is straightforward: + +```ts +// Before: async router +import UniversalRouter from 'universal-router' + +const routes = [ + { path: '/', action: () => 'Home' }, + { path: '/about', action: () => 'About' }, +] + +const router = new UniversalRouter(routes) + +async function handleRoute(path: string) { + const result = await router.resolve(path) + return result +} + +// After: sync router +import UniversalRouterSync from 'universal-router/sync' + +const routes = [ + { path: '/', action: () => 'Home' }, + { path: '/about', action: () => 'About' }, +] + +const router = new UniversalRouterSync(routes) + +function handleRoute(path: string) { + return router.resolve(path) +} +``` + +### What Changes + +| Aspect | Async | Sync | +| ------------------ | ----------------------- | ------------------------- | +| Import | `'universal-router'` | `'universal-router/sync'` | +| `resolve()` return | `Promise` | `R` | +| Route actions | Can be async | Must be sync | +| Error handling | `.catch()` or try/await | try/catch | +| `next()` return | `Promise` | `R` | + +## Common Pitfalls + +### 1. Using Async Actions + +Sync router doesn't work with async actions: + +```ts +// Wrong - will not work as expected +const routes = [ + { + path: '/data', + async action() { + return await fetchData() // Returns a Promise, not data + }, + }, +] + +const router = new UniversalRouterSync(routes) +const result = router.resolve('/data') // result is a Promise! + +// Correct - use pre-fetched or cached data +const routes = [ + { + path: '/data', + action() { + return getCachedData() // Returns data directly + }, + }, +] +``` + +### 2. Mixing Sync and Async Routes + +If some routes need async, use the async router: + +```ts +// If ANY route needs async, use async router +const routes = [ + { path: '/', action: () => 'Home' }, // sync + { + path: '/data', + async action() { + // async + return await fetchData() + }, + }, +] + +// Must use async router +import UniversalRouter from 'universal-router' +const router = new UniversalRouter(routes) +``` + +### 3. Wrong Import Path + +Make sure to import from the correct path: + +```ts +// Wrong - this is the async router +import UniversalRouterSync from 'universal-router' + +// Correct +import UniversalRouterSync from 'universal-router/sync' +``` + +### 4. Expecting Promises + +Don't wrap sync results in Promise handling: + +```ts +const router = new UniversalRouterSync(routes) + +// Wrong - unnecessary Promise handling +router.resolve('/').then((result) => { + console.log(result) +}) + +// Correct - direct value +const result = router.resolve('/') +console.log(result) +``` + +## See Also + +- [API Reference](./api.md) - Complete API documentation +- [TypeScript Guide](./typescript.md) - Type-safe routing +- [Getting Started](./getting-started.md) - Basic setup +- [Nested Routes](./nested-routes.md) - Route hierarchies diff --git a/docs/typescript.md b/docs/typescript.md new file mode 100644 index 0000000..fd1e98f --- /dev/null +++ b/docs/typescript.md @@ -0,0 +1,649 @@ +# TypeScript Integration and Type-Safe Routes + +Universal Router provides comprehensive TypeScript support with automatic parameter inference, +type-safe URL generation, and customizable context types. This guide covers everything from +basic setup to advanced type patterns. + +## Quick Start + +Install Universal Router with TypeScript: + +```bash +npm install universal-router +``` + +Basic typed usage: + +```ts +import UniversalRouter from 'universal-router' + +const routes = [ + { path: '/', action: () => 'Home' }, + { path: '/users/:id', action: (ctx) => `User ${ctx.params.id}` }, +] as const + +const router = new UniversalRouter(routes) +const result = await router.resolve('/users/123') // result is string +``` + +## ExtractParams - Automatic Parameter Type Inference + +The `ExtractParams` utility type extracts parameter types directly from path strings: + +```ts +import type { ExtractParams } from 'universal-router' + +// Basic parameter extraction +type UserParams = ExtractParams<'/users/:id'> +// { id: string } + +type PostParams = ExtractParams<'/users/:userId/posts/:postId'> +// { userId: string; postId: string } + +// Wildcard parameters become string arrays +type FileParams = ExtractParams<'/files/*path'> +// { path: string[] } + +// Combined parameters +type ComplexParams = ExtractParams<'/orgs/:orgId/repos/*repoPath'> +// { orgId: string; repoPath: string[] } + +// No parameters +type HomeParams = ExtractParams<'/'> +// {} +``` + +### Using ExtractParams in Functions + +```ts +import type { ExtractParams } from 'universal-router' + +function fetchResource

( + path: P, + params: ExtractParams

, +): Promise { + let url = path as string + for (const [key, value] of Object.entries(params)) { + url = url.replace(`:${key}`, String(value)) + } + return fetch(url) +} + +// TypeScript enforces correct params +fetchResource('/users/:id', { id: '123' }) // OK +fetchResource('/users/:id', {}) // Error: missing 'id' +fetchResource('/users/:id', { id: '1', extra: 'x' }) // Error: extra property +``` + +## defineRoute - Type-Safe Route Definitions + +The `defineRoute` helper provides type inference for route actions without manual type annotations: + +```ts +import UniversalRouter, { defineRoute } from 'universal-router' + +// Parameters are automatically typed from the path +const route = defineRoute({ + path: '/users/:userId', + action: (context, params) => { + // params.userId is typed as string + return fetchUser(params.userId) + }, +}) + +// Also works with destructured context +const routeDestructured = defineRoute({ + path: '/posts/:postId/comments/:commentId', + action: ({ params }) => { + // params.postId and params.commentId are both string + return fetchComment(params.postId, params.commentId) + }, +}) +``` + +### Factory Pattern for Consistent Types + +Use the factory form when you need consistent result and context types across routes: + +```tsx +import UniversalRouter, { defineRoute, RouterContext } from 'universal-router' + +// Custom context with app-specific properties +interface AppContext extends RouterContext { + user?: { id: string; role: string } + db: Database +} + +// Create a factory with fixed result and context types +const route = defineRoute() + +// All routes created with this factory share the same types +const homeRoute = route({ + path: '/', + action: (context) => { + // context.user and context.db are available and typed + return + }, +}) + +const userRoute = route({ + path: '/users/:id', + action: (context, params) => { + // params.id is string, context.db is Database + return + }, +}) +``` + +### Nested Routes with Parameter Inheritance + +Child routes automatically inherit parent parameters: + +```ts +import { defineRoute } from 'universal-router' + +const routes = defineRoute({ + path: '/orgs/:orgId', + children: [ + defineRoute({ + path: '/teams/:teamId', + children: [ + defineRoute({ + path: '/members/:memberId', + action: (context, params) => { + // All three parameters are typed: + // params.orgId: string + // params.teamId: string + // params.memberId: string + return fetchMember(params.orgId, params.teamId, params.memberId) + }, + }), + ], + }), + ], +}) +``` + +## Typed Router Context + +Extend `RouterContext` to add custom properties available in all route actions: + +```ts +import UniversalRouter, { RouterContext, Route } from 'universal-router' + +// Define custom context +interface AppContext extends RouterContext { + user: { id: string; name: string } | null + locale: string + theme: 'light' | 'dark' +} + +// Define result type +type RouteResult = { + component: React.ComponentType + title: string +} + +// Create typed routes +const routes: Route[] = [ + { + path: '/', + action: (context) => { + // context.user, context.locale, context.theme are typed + return { + component: HomePage, + title: context.locale === 'en' ? 'Home' : 'Accueil', + } + }, + }, + { + path: '/profile', + action: (context) => { + if (!context.user) { + throw { status: 401, message: 'Unauthorized' } + } + return { + component: () => , + title: `${context.user.name}'s Profile`, + } + }, + }, +] + +// Create router with typed context +const router = new UniversalRouter(routes, { + context: { + user: null, + locale: 'en', + theme: 'light', + }, +}) + +// Resolve with additional context +const result = await router.resolve({ + pathname: '/profile', + user: { id: '123', name: 'Alice' }, +}) +``` + +## Type-Safe URL Generation + +The `generateUrls` function provides full type safety when routes are defined with `as const`: + +```ts +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +// IMPORTANT: Use 'as const' to preserve literal types +const routes = [ + { name: 'home', path: '/' }, + { name: 'users', path: '/users' }, + { name: 'user', path: '/users/:id' }, + { name: 'userPosts', path: '/users/:userId/posts/:postId' }, +] as const + +const router = new UniversalRouter(routes) +const url = generateUrls(router) + +// Route names are type-checked +url('home') // OK: '/' +url('users') // OK: '/users' +url('user', { id: '123' }) // OK: '/users/123' +url('unknown') // Error: invalid route name + +// Required parameters are enforced +url('user') // Error: missing params +url('user', {}) // Error: missing 'id' +url('userPosts', { userId: '1' }) // Error: missing 'postId' +url('userPosts', { userId: '1', postId: '2' }) // OK +``` + +### ExtractRouteNames and RouteNameToParams + +These utility types let you work with route names and their parameters: + +```ts +import type { + ExtractRouteNames, + RouteNameToParams, +} from 'universal-router/generate-urls' + +const routes = [ + { name: 'home', path: '/' }, + { name: 'user', path: '/users/:id' }, +] as const + +// Extract all valid route names +type RouteNames = ExtractRouteNames +// 'home' | 'user' + +// Get params for a specific route +type UserParams = RouteNameToParams +// { id: string } + +type HomeParams = RouteNameToParams +// {} +``` + +### Hierarchical Route Names + +With `uniqueRouteNameSep`, nested route names are also type-checked: + +```ts +const routes = [ + { + name: 'admin', + path: '/admin', + children: [ + { name: 'dashboard', path: '' }, + { name: 'users', path: '/users' }, + { name: 'user', path: '/users/:userId' }, + ], + }, +] as const + +const router = new UniversalRouter(routes) +const url = generateUrls(router, { uniqueRouteNameSep: '.' }) + +// Hierarchical names are type-checked +url('admin') // OK: '/admin' +url('admin.dashboard') // OK: '/admin' +url('admin.users') // OK: '/admin/users' +url('admin.user', { userId: '1' }) // OK: '/admin/users/1' +url('admin.unknown') // Error: invalid route name +``` + +## TypedRouteContext and TypedRoute + +For advanced scenarios, use the typed route interfaces directly: + +```ts +import type { TypedRouteContext, RouteParams } from 'universal-router' + +// TypedRouteContext with specific params +interface UserContext extends TypedRouteContext<{ id: string }> {} + +function userAction(context: UserContext): string { + return `User ${context.params.id}` // params.id is string +} + +// Or use inline +const route = { + path: '/users/:id', + action: (context: TypedRouteContext<{ id: string }>) => { + return `User ${context.params.id}` + }, +} +``` + +## Error Handler Typing + +Type your error handler to match your route result type: + +```ts +import UniversalRouter, { RouteError, ResolveContext } from 'universal-router' + +interface AppResult { + component: React.ComponentType + status: number +} + +const router = new UniversalRouter(routes, { + errorHandler(error: RouteError, context: ResolveContext): AppResult { + if (error.status === 404) { + return { + component: NotFoundPage, + status: 404, + } + } + return { + component: () => , + status: error.status || 500, + } + }, +}) +``` + +## Custom resolveRoute Typing + +Type-safe custom route resolution: + +```ts +import UniversalRouter, { + RouteContext, + RouteParams, + RouteResult, +} from 'universal-router' + +interface PageResult { + content: string + meta: { title: string } +} + +// Custom route with additional properties +interface AppRoute { + path: string + page?: string + data?: (params: RouteParams) => Promise +} + +const router = new UniversalRouter(routes, { + async resolveRoute( + context: RouteContext, + params: RouteParams, + ): Promise { + const route = context.route as AppRoute + + if (route.page) { + const module = await import(route.page) + const data = route.data ? await route.data(params) : null + return module.default(params, data) + } + + return undefined + }, +}) +``` + +## Strict Mode Configuration + +Enable strict TypeScript configuration for best type safety: + +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true + } +} +``` + +## Generic Route Patterns + +Create reusable typed route patterns: + +```ts +import { defineRoute, RouterContext } from 'universal-router' + +// Generic CRUD route factory +function createCrudRoutes( + resource: string, + handlers: { + list: () => Promise + get: (id: string) => Promise + create: (data: Partial) => Promise + update: (id: string, data: Partial) => Promise + delete: (id: string) => Promise + }, +) { + const route = defineRoute() + + return [ + route({ + path: `/${resource}`, + action: () => handlers.list(), + }), + route({ + path: `/${resource}/:id`, + action: (ctx, params) => handlers.get(params.id), + }), + // ... more routes + ] +} + +// Usage +interface User { + id: string + name: string + email: string +} + +const userRoutes = createCrudRoutes('users', { + list: () => fetchUsers(), + get: (id) => fetchUser(id), + create: (data) => createUser(data), + update: (id, data) => updateUser(id, data), + delete: (id) => deleteUser(id), +}) +``` + +## Middleware with Typed Context + +Create type-safe middleware that extends context: + +```ts +import UniversalRouter, { RouteContext, RouterContext } from 'universal-router' + +interface BaseContext extends RouterContext { + requestId: string +} + +interface AuthContext extends BaseContext { + user: { id: string; role: string } +} + +// Middleware that adds auth context +async function withAuth( + context: RouteContext, + next: () => Promise, +): Promise { + const user = await authenticateRequest(context.requestId) + if (!user) { + throw { status: 401, message: 'Unauthorized' } + } + + // Extend context with user + ;(context as RouteContext).user = user + return next() +} + +// Usage in route +const protectedRoute = { + path: '/admin', + async action(context: RouteContext) { + // Apply middleware + return withAuth(context, async () => { + // context.user is now available + if (context.user.role !== 'admin') { + throw { status: 403, message: 'Forbidden' } + } + return 'Admin Dashboard' + }) + }, +} +``` + +## Common TypeScript Patterns + +### Discriminated Union Results + +```ts +type RouteResult = + | { type: 'page'; component: React.ComponentType; title: string } + | { type: 'redirect'; url: string; permanent: boolean } + | { type: 'error'; status: number; message: string } + +const routes = [ + { + path: '/', + action: (): RouteResult => ({ + type: 'page', + component: HomePage, + title: 'Home', + }), + }, + { + path: '/old-page', + action: (): RouteResult => ({ + type: 'redirect', + url: '/new-page', + permanent: true, + }), + }, +] + +// Handle results with exhaustive checking +async function handleRoute(pathname: string) { + const result = await router.resolve(pathname) + + switch (result.type) { + case 'page': + document.title = result.title + render(result.component) + break + case 'redirect': + if (result.permanent) { + // 301 redirect + } + navigate(result.url) + break + case 'error': + showError(result.status, result.message) + break + default: + // TypeScript ensures all cases are handled + const _exhaustive: never = result + } +} +``` + +### Conditional Types for Optional Params + +```ts +import type { ExtractParams, Prettify } from 'universal-router' + +// Make specific params optional +type WithOptional = Prettify< + Omit & Partial> +> + +// Usage +type Params = ExtractParams<'/users/:id/posts/:postId'> +// { id: string; postId: string } + +type ParamsWithOptionalPost = WithOptional +// { id: string; postId?: string } +``` + +## Common Pitfalls + +### 1. Forgetting `as const` + +Without `as const`, literal types are widened: + +```ts +// Wrong - types widened to string +const routes = [{ name: 'home', path: '/' }] +// typeof routes[0].name is string + +// Correct - literal types preserved +const routes = [{ name: 'home', path: '/' }] as const +// typeof routes[0].name is 'home' +``` + +### 2. Incorrect Parameter Access + +Always use the params object, not direct access: + +```ts +// Wrong - params not accessible this way +{ + path: '/users/:id', + action: (context) => context.id // Error: id doesn't exist on context +} + +// Correct - use params object +{ + path: '/users/:id', + action: (context) => context.params.id // OK +} + +// Also correct - destructure params +{ + path: '/users/:id', + action: ({ params }) => params.id // OK +} +``` + +### 3. Missing Return Type Annotation + +Let TypeScript infer types or annotate explicitly: + +```ts +// Avoid any - be explicit about result types +const router = new UniversalRouter(routes) // Result is any + +// Better - specify result type +const router = new UniversalRouter(routes) +const router = new UniversalRouter(routes) +const router = new UniversalRouter(routes) +``` + +## See Also + +- [URL Generation](./url-generation.md) - Type-safe URL generation +- [Nested Routes](./nested-routes.md) - Parameter inheritance in nested routes +- [Authorization](./authorization.md) - Typed auth context patterns +- [Universal Router API](./api.md) - Complete API reference diff --git a/docs/url-generation.md b/docs/url-generation.md new file mode 100644 index 0000000..05ba5f2 --- /dev/null +++ b/docs/url-generation.md @@ -0,0 +1,528 @@ +# Generating URLs from Route Names + +Universal Router provides a `generateUrls` function that creates URL paths from route names and +parameters. This approach prevents hardcoded URLs scattered throughout your codebase and ensures +URLs stay in sync with your route definitions. + +## Why Use Named Routes? + +Hardcoded URLs create maintenance problems: + +```js +// Fragile - if route changes, must update everywhere +;View Profile +navigate('/users/123/profile') +redirect('/users/123/profile') +``` + +Named routes centralize URL structure: + +```js +// Robust - route definition is the single source of truth +;View Profile +navigate(url('userProfile', { id: '123' })) +redirect(url('userProfile', { id: '123' })) +``` + +## Basic Usage + +Import `generateUrls` from the dedicated module and create a URL generator from your router: + +```js +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +// Use 'as const' to preserve literal types for type-safe route names +const routes = [ + { name: 'home', path: '/' }, + { name: 'users', path: '/users' }, + { name: 'user', path: '/users/:id' }, + { name: 'userPosts', path: '/users/:userId/posts/:postId' }, +] as const + +const router = new UniversalRouter(routes) +const url = generateUrls(router) + +url('home') // '/' +url('users') // '/users' +url('user', { id: '123' }) // '/users/123' +url('userPosts', { userId: '123', postId: '456' }) // '/users/123/posts/456' +``` + +> **Why `as const`?** Without it, TypeScript widens route names to `string`, losing +> type safety. With `as const`, you get autocomplete for route names and compile-time +> errors for typos. See [TypeScript Integration](./typescript.md) for details. + +## Route Naming Conventions + +Choose clear, consistent names for your routes: + +```js +const routes = [ + // Resource-based naming + { name: 'users', path: '/users' }, // List + { name: 'user', path: '/users/:id' }, // Show + { name: 'userEdit', path: '/users/:id/edit' }, // Edit + { name: 'userNew', path: '/users/new' }, // Create form + + // Feature-based naming + { name: 'dashboard', path: '/dashboard' }, + { name: 'settings', path: '/settings' }, + { name: 'settingsProfile', path: '/settings/profile' }, + { name: 'settingsSecurity', path: '/settings/security' }, +] +``` + +## Nested Routes and Hierarchical Names + +For nested routes, you can use the `uniqueRouteNameSep` option to create hierarchical names +automatically: + +```js +const routes = [ + { + name: 'admin', + path: '/admin', + children: [ + { name: 'dashboard', path: '' }, + { name: 'users', path: '/users' }, + { + name: 'settings', + path: '/settings', + children: [ + { name: 'general', path: '' }, + { name: 'security', path: '/security' }, + ], + }, + ], + }, +] + +const router = new UniversalRouter(routes) + +// Without separator - names are independent (may conflict) +const url = generateUrls(router) +url('dashboard') // '/admin' +url('users') // '/admin/users' +url('general') // '/admin/settings' + +// With separator - names are prefixed with parent names +const urlWithSep = generateUrls(router, { uniqueRouteNameSep: '.' }) +urlWithSep('admin') // '/admin' +urlWithSep('admin.dashboard') // '/admin' +urlWithSep('admin.users') // '/admin/users' +urlWithSep('admin.settings') // '/admin/settings' +urlWithSep('admin.settings.general') // '/admin/settings' +urlWithSep('admin.settings.security') // '/admin/settings/security' +``` + +### Choosing a Separator + +Common separators include: + +- `.` (dot): `admin.users.edit` - Most common, familiar from object notation +- `/` (slash): `admin/users/edit` - Mirrors URL structure +- `:` (colon): `admin:users:edit` - Clear visual separation + +```js +// Dot notation (recommended) +generateUrls(router, { uniqueRouteNameSep: '.' }) + +// Slash notation +generateUrls(router, { uniqueRouteNameSep: '/' }) + +// Colon notation +generateUrls(router, { uniqueRouteNameSep: ':' }) +``` + +## Query String Parameters + +Parameters not used in the path can become query strings using the `stringifyQueryParams` option: + +```js +import generateUrls from 'universal-router/generate-urls' + +const routes = [ + { name: 'search', path: '/search' }, + { name: 'users', path: '/users' }, + { name: 'user', path: '/users/:id' }, +] + +const router = new UniversalRouter(routes) + +// Custom query string serializer +const url = generateUrls(router, { + stringifyQueryParams(params) { + return new URLSearchParams(params).toString() + }, +}) + +// Path params used in URL, extra params become query string +url('search', { q: 'javascript', page: '1' }) +// '/search?q=javascript&page=1' + +url('user', { id: '123', tab: 'posts', sort: 'date' }) +// '/users/123?tab=posts&sort=date' + +url('users', { role: 'admin', status: 'active' }) +// '/users?role=admin&status=active' +``` + +### Using qs Library for Complex Query Strings + +For nested objects and arrays in query strings, use a library like `qs`: + +```js +import qs from 'qs' +import generateUrls from 'universal-router/generate-urls' + +const url = generateUrls(router, { + stringifyQueryParams(params) { + const query = qs.stringify(params, { arrayFormat: 'brackets' }) + return query ? `?${query}` : '' + }, +}) + +url('search', { + filters: { category: 'tech', price: { min: 10, max: 100 } }, + tags: ['javascript', 'typescript'], +}) +// '/search?filters[category]=tech&filters[price][min]=10&filters[price][max]=100&tags[]=javascript&tags[]=typescript' +``` + +## Type-Safe URL Generation + +When using TypeScript with `as const` route definitions, `generateUrls` provides full type safety: + +```ts +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +// Use 'as const' for type inference +const routes = [ + { name: 'home', path: '/' }, + { name: 'user', path: '/users/:id' }, + { name: 'post', path: '/posts/:postId/comments/:commentId' }, +] as const + +const router = new UniversalRouter(routes) +const url = generateUrls(router) + +// TypeScript knows valid route names +url('home') // OK +url('user', { id: '1' }) // OK +url('invalid') // Error: Argument of type '"invalid"' is not assignable + +// TypeScript enforces required parameters +url('user') // Error: Expected 2 arguments, but got 1 +url('user', {}) // Error: Property 'id' is missing +url('user', { id: '1', extra: 'ok' }) // OK - extra params allowed +``` + +### Type-Safe Hierarchical Names + +With the separator option, hierarchical names are also type-checked: + +```ts +const routes = [ + { + name: 'admin', + path: '/admin', + children: [ + { name: 'users', path: '/users' }, + { name: 'user', path: '/users/:userId' }, + ], + }, +] as const + +const router = new UniversalRouter(routes) +const url = generateUrls(router, { uniqueRouteNameSep: '.' }) + +url('admin') // OK: '/admin' +url('admin.users') // OK: '/admin/users' +url('admin.user', { userId: '1' }) // OK: '/admin/users/1' + +url('admin.invalid') // Error: not a valid route name +url('admin.user') // Error: missing required userId param +``` + +## Base URL Support + +If your router has a `baseUrl`, generated URLs include it automatically: + +```js +const router = new UniversalRouter(routes, { + baseUrl: '/app', +}) + +const url = generateUrls(router) + +url('home') // '/app/' +url('user', { id: '1' }) // '/app/users/1' +``` + +## Path Encoding Options + +Control how parameters are encoded using path-to-regexp options: + +```js +const url = generateUrls(router, { + // Custom encoder (default is encodeURIComponent) + encode: (value, token) => { + // Don't encode slashes for wildcard params + if (token.name === 'path') { + return value + } + return encodeURIComponent(value) + }, +}) + +// With wildcard route: { name: 'file', path: '/files/*path' } +url('file', { path: 'docs/api/readme.md' }) +// '/files/docs/api/readme.md' (slashes preserved) +``` + +## Integration Patterns + +### React Navigation Helper + +Create a custom hook for type-safe navigation: + +```tsx +import { useMemo, useCallback } from 'react' +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +const routes = [ + { name: 'home', path: '/' }, + { name: 'user', path: '/users/:id' }, + { name: 'userPosts', path: '/users/:userId/posts' }, +] as const + +const router = new UniversalRouter(routes) + +export function useNavigation() { + const url = useMemo(() => generateUrls(router), []) + + const navigate = useCallback( + [0]>( + name: Name, + ...args: Parameters extends [Name, ...infer Rest] + ? Rest + : never + ) => { + const path = url(name, ...args) + window.history.pushState(null, '', path) + // Trigger your router's resolve + }, + [url], + ) + + return { url, navigate } +} + +// Usage in components +function UserLink({ userId }: { userId: string }) { + const { url } = useNavigation() + + return View Profile +} +``` + +### Express/Server Integration + +Generate URLs for server-side redirects and links: + +```ts +import express from 'express' +import UniversalRouter from 'universal-router' +import generateUrls from 'universal-router/generate-urls' + +const routes = [ + { name: 'home', path: '/' }, + { name: 'login', path: '/login' }, + { name: 'user', path: '/users/:id' }, +] as const + +const router = new UniversalRouter(routes) +const url = generateUrls(router) + +const app = express() + +// Redirect using named routes +app.get('/old-profile/:id', (req, res) => { + res.redirect(301, url('user', { id: req.params.id })) +}) + +// Generate URLs in templates +app.get('/dashboard', (req, res) => { + res.render('dashboard', { + links: { + home: url('home'), + profile: url('user', { id: req.user.id }), + }, + }) +}) +``` + +### Link Component + +Create a reusable Link component with named route support: + +```tsx +import { AnchorHTMLAttributes } from 'react' +import generateUrls from 'universal-router/generate-urls' + +// Assuming routes and router are defined elsewhere +import { router, routes } from './routes' + +const url = generateUrls(router) + +type RouteName = Parameters[0] + +interface LinkProps + extends Omit, 'href'> { + to: Name + params?: Parameters[1] +} + +function Link({ + to, + params, + children, + onClick, + ...props +}: LinkProps) { + const href = url(to, params) + + function handleClick(e: React.MouseEvent) { + if (onClick) onClick(e) + if (e.defaultPrevented) return + if (e.metaKey || e.ctrlKey || e.shiftKey) return + + e.preventDefault() + window.history.pushState(null, '', href) + // Trigger navigation + } + + return ( + + {children} + + ) +} + +// Usage +View User +Home +``` + +## Error Handling + +The `generateUrls` function throws errors for invalid route names: + +```js +const url = generateUrls(router) + +try { + url('nonexistent') +} catch (error) { + console.error(error.message) // 'Route "nonexistent" not found' +} + +try { + url('user') // Missing required 'id' param +} catch (error) { + console.error(error.message) // Error from path-to-regexp about missing param +} +``` + +### Safe URL Generation + +Create a wrapper that returns `null` for invalid routes: + +```ts +function safeUrl(name: string, params?: Record): string | null { + try { + return url(name, params) + } catch { + console.warn(`Failed to generate URL for route "${name}"`) + return null + } +} + +// Usage +const href = safeUrl('user', { id: '123' }) ?? '/fallback' +``` + +## Common Pitfalls + +### 1. Forgetting `as const` + +Without `as const`, TypeScript cannot infer literal types: + +```ts +// Wrong - types are widened to string +const routes = [{ name: 'home', path: '/' }] +// name is string, not 'home' + +// Correct - literal types preserved +const routes = [{ name: 'home', path: '/' }] as const +// name is 'home' +``` + +### 2. Route Name Conflicts + +Without a separator, nested routes can have conflicting names: + +```js +const routes = [ + { + name: 'users', + path: '/admin/users', + children: [ + { name: 'users', path: '/public/users' }, // Conflict! + ], + }, +] + +const url = generateUrls(router) +url('users') // Which one? Throws error: Route "users" already exists +``` + +Solution: Use `uniqueRouteNameSep` or unique names. + +### 3. Missing Parameters + +All path parameters must be provided: + +```js +const routes = [{ name: 'post', path: '/users/:userId/posts/:postId' }] + +// Wrong - missing postId +url('post', { userId: '1' }) // Throws error + +// Correct - all params provided +url('post', { userId: '1', postId: '2' }) +``` + +### 4. Array Parameters for Wildcards + +Wildcard parameters expect arrays: + +```js +const routes = [{ name: 'files', path: '/files/*path' }] + +// For single segment +url('files', { path: ['readme.md'] }) // '/files/readme.md' + +// For multiple segments +url('files', { path: ['docs', 'api', 'readme.md'] }) // '/files/docs/api/readme.md' +``` + +## See Also + +- [TypeScript Integration](./typescript.md) - Full TypeScript setup with type inference +- [Nested Routes](./nested-routes.md) - Working with nested route structures +- [SPA Navigation](./spa-navigation.md) - Client-side navigation patterns +- [Universal Router API](./api.md) - Complete API reference