diff --git a/.agents/skills/lambda-router/SKILL.md b/.agents/skills/lambda-router/SKILL.md new file mode 100644 index 0000000..36af26d --- /dev/null +++ b/.agents/skills/lambda-router/SKILL.md @@ -0,0 +1,294 @@ +--- +name: lambda-router +description: > + Guide for writing code that uses @studiolambda/router — a React 19 client-side + router built on the browser Navigation API with a trie-based URL matcher. + Use when writing React components, pages, layouts, or navigation logic that + imports from "@studiolambda/router" or "@studiolambda/router/react". Covers + route definitions with createRouter, the Router/Link components, all 16 hooks, + middleware, prefetch, lazy loading, SSR with createMemoryNavigation, search + params, and form handling. +metadata: + version: '2.0.0' +--- + +# Lambda Router + +`@studiolambda/router` is a React 19 client-side router built on the browser Navigation API. Two entry points: + +- `@studiolambda/router` — framework-agnostic trie-based URL matcher +- `@studiolambda/router/react` — React 19 components, hooks, and navigation + +## How It Works + +The `` component intercepts the browser Navigation API's `navigate` event, matches the destination URL against a trie of registered routes, runs prefetch logic in the precommit phase (before the URL commits to the address bar), then wraps the React state update in `startTransition` for concurrent rendering. The matched route component renders inside a `Suspense` boundary wrapped by any middleware components. + +Links don't need `onClick` or `preventDefault` — the Navigation API intercepts anchor clicks natively. + +## Quick Start + +```tsx +import { lazy, Suspense } from 'react' +import { createRouter, Router, Link } from '@studiolambda/router/react' + +const Home = lazy(() => import('./pages/Home')) +const User = lazy(() => import('./pages/User')) + +const router = createRouter((route) => { + route('/').render(Home) + route('/user/:id').render(User) +}) + +function App() { + return ( + + + + + ) +} +``` + +## Route Definition — `createRouter` + +Build a `Matcher` with the declarative builder API. See [route-builder.md](references/route-builder.md) for the full chainable API. + +```tsx +const router = createRouter((route) => { + // Static route + route('/').render(Home) + + // Dynamic params + route('/user/:id').render(User) + + // Wildcard (catches remaining segments) + route('/files/*path').render(FileViewer) + + // Redirect (static) + route('/old').redirect('/new') + + // Redirect (dynamic with params) + route('/old-user/:id').redirect(({ params }) => `/user/${params.id}`) + + // Route with prefetch, scroll, and form handling + route('/search') + .prefetch(({ url }) => prefetchSearchResults(url.searchParams.get('q'))) + .scroll('manual') + .formHandler((formData) => handleSearchForm(formData)) + .render(SearchPage) + + // Middleware group — all children inherit Auth + const authed = route().middleware([Auth]).group() + authed('/dashboard').render(Dashboard) + authed('/settings').render(Settings) + + // Nested groups accumulate prefix + middleware + const admin = authed('/admin').middleware([AdminGuard]).group() + admin('/users').render(AdminUsers) // path: /admin/users + admin('/config').render(AdminConfig) // path: /admin/config +}) +``` + +**Rules:** + +- `.render()`, `.redirect()`, `.group()` are terminal — no further chaining after +- Groups inherit middleware and prefetch from parents; redirects do NOT inherit middleware +- Duplicate route registration throws +- Static redirect cycles are detected at build time + +## Components + +### `` + +Top-level orchestrator. Provides contexts consumed by all hooks. + +```tsx + (required in practice) + navigation={memoryNav} // Navigation override (SSR/testing) + notFound={Custom404} // custom 404 component + fallback={} // Suspense fallback + transition={[isPending, startTransition]} // share transition with parent + onNavigateSuccess={() => analytics.pageView()} + onNavigateError={(error) => reportError(error)} +/> +``` + +Falls back to `window.navigation` when no `navigation` prop or context is provided. + +### `` + +Anchor element with prefetch and active link detection. + +```tsx +About + +// Prefetch on hover +About + +// Prefetch when scrolled into viewport +About + +// Dynamic className based on active state + isActive ? "nav-active" : "nav-link"} +>About + +// Prefix matching (active for /docs and /docs/*) +Docs +``` + +Active links get `data-active` and `aria-current="page"` attributes automatically. + +## Hooks + +All hooks must be used inside a `` tree. They throw descriptive errors outside their provider. See [hooks-reference.md](references/hooks-reference.md) for complete API. + +| Hook | Returns | Purpose | +| -------------------------------- | ------------------------------------- | ------------------------------------------ | +| `useParams()` | `Record` | Dynamic route params (`:id` segments) | +| `usePathname()` | `string` | Current URL pathname | +| `useSearchParams()` | `[URLSearchParams, setter]` | Search params + setter (preserves hash) | +| `useNavigate()` | `(url, options?) => NavigationResult` | Programmatic navigation | +| `useNavigation()` | `Navigation` | Raw Navigation API object | +| `useNavigationType()` | `NavigationType \| null` | `push`/`replace`/`reload`/`traverse` | +| `useNavigationSignal()` | `AbortSignal \| null` | Current navigation's abort signal | +| `useIsPending()` | `boolean` | Whether a transition is in progress | +| `useBack()` | `{ back, canGoBack }` | Back navigation + reactive availability | +| `useForward()` | `{ forward, canGoForward }` | Forward navigation + reactive availability | +| `usePrefetch()` | `(url) => void` | Trigger route prefetch manually | +| `usePrefetchEffect(ref, opts)` | `void` | Attach prefetch to DOM element | +| `useActiveLinkProps(href, opts)` | `{ isActive, props }` | Active link detection | + +### Common Hook Patterns + +```tsx +// Read route params +function UserPage() { + const { id } = useParams() + return
User {id}
+} + +// Programmatic navigation +function LogoutButton() { + const navigate = useNavigate() + return ( + + ) +} + +// Search params +function SearchPage() { + const [searchParams, setSearchParams] = useSearchParams() + const query = searchParams.get('q') ?? '' + return setSearchParams({ q: e.target.value })} /> +} + +// Pending indicator +function Layout({ children }: { children: React.ReactNode }) { + const isPending = useIsPending() + return ( +
+ {isPending && } + {children} +
+ ) +} +``` + +## Middleware + +Middleware components receive `{ children }` and wrap the route component. They can suspend, conditionally render, or add context. + +```tsx +import { type PropsWithChildren, use } from 'react' + +// Auth guard using Suspense +function Auth({ children }: PropsWithChildren) { + const session = use(fetchSession()) // suspends until resolved + if (!session) return + return {children} +} + +// Layout wrapper +function DashboardLayout({ children }: PropsWithChildren) { + return ( +
+ +
{children}
+
+ ) +} + +// Apply to routes +const router = createRouter((route) => { + const authed = route().middleware([Auth, DashboardLayout]).group() + authed('/dashboard').render(Dashboard) +}) +``` + +Middlewares nest outermost-first: `[Auth, Layout]` means Auth wraps Layout wraps RouteComponent. + +## SSR and Testing + +Use `createMemoryNavigation` for non-browser environments: + +```tsx +import { createMemoryNavigation, Router } from '@studiolambda/router/react' + +// SSR +const navigation = createMemoryNavigation({ url: 'https://example.com/page' }) +function ServerApp() { + return +} + +// Testing helper +function renderWithRouter(ui: React.ReactNode, { url = '/' } = {}) { + const nav = createMemoryNavigation({ url: `http://localhost${url}` }) + return render( + + {ui} + + ) +} +``` + +`createMemoryNavigation` provides a stub Navigation with no-op event methods, single-entry history, and pre-resolved navigation promises. `back()`/`forward()`/`traverseTo()` throw "not supported" errors. + +## URL Pattern Matching (standalone) + +The core matcher can be used independently of React: + +```ts +import { createMatcher } from '@studiolambda/router' + +const matcher = createMatcher() +matcher.register('/users', 'list') +matcher.register('/users/:id', 'detail') +matcher.register('/files/*path', 'files') + +matcher.match('/users') // { handler: "list", params: {} } +matcher.match('/users/42') // { handler: "detail", params: { id: "42" } } +matcher.match('/files/a/b/c') // { handler: "files", params: { path: "a/b/c" } } +matcher.match('/unknown') // null +``` + +- Matching priority: static > dynamic (`:param`) > wildcard (`*param`) +- Trailing slashes are ignored +- Conflicting param names at the same trie level throw +- Bare `*` captures into param named `"*"` + +## When to Load References + +- Defining routes with full builder options -> [route-builder.md](references/route-builder.md) +- Using hooks (detailed API, all options, edge cases) -> [hooks-reference.md](references/hooks-reference.md) +- Advanced patterns (prefetch, forms, redirects, cache) -> [advanced-patterns.md](references/advanced-patterns.md) diff --git a/.agents/skills/lambda-router/references/advanced-patterns.md b/.agents/skills/lambda-router/references/advanced-patterns.md new file mode 100644 index 0000000..b43d86b --- /dev/null +++ b/.agents/skills/lambda-router/references/advanced-patterns.md @@ -0,0 +1,375 @@ +# Advanced Patterns + +## Table of Contents + +- [Lazy Loading Routes](#lazy-loading-routes) +- [Prefetch Strategies](#prefetch-strategies) +- [Sharing Transition State](#sharing-transition-state) +- [Form Submissions](#form-submissions) +- [Dynamic Redirects](#dynamic-redirects) +- [Nested Layouts via Middleware](#nested-layouts-via-middleware) +- [Auth Guards with Suspense](#auth-guards-with-suspense) +- [Cancellable Data Fetching](#cancellable-data-fetching) +- [Module Federation](#module-federation) +- [Cache Invalidation](#cache-invalidation) +- [Custom Active Link Component](#custom-active-link-component) +- [Scroll and Focus Behavior](#scroll-and-focus-behavior) + +--- + +## Lazy Loading Routes + +Route components work with `React.lazy()` out of the box. The `` wraps rendering in a `` boundary. + +```tsx +import { lazy } from 'react' +import { createRouter, Router } from '@studiolambda/router/react' + +const Dashboard = lazy(() => import('./pages/Dashboard')) +const Settings = lazy(() => import('./pages/Settings')) + +const router = createRouter((route) => { + route('/dashboard').render(Dashboard) + route('/settings').render(Settings) +}) + +function App() { + return } /> +} +``` + +The `fallback` prop is passed to the internal `` boundary. + +--- + +## Prefetch Strategies + +### Via `` + +```tsx +// Prefetch when user hovers over the link +Dashboard + +// Prefetch when link scrolls into viewport (IntersectionObserver) +Dashboard + +// Prefetch once (default: true) — won't re-prefetch after first trigger +Dashboard +``` + +### Via `usePrefetch` (manual) + +```tsx +import { usePrefetch } from '@studiolambda/router/react' + +function SearchResults({ results }) { + const prefetch = usePrefetch() + + return results.map((result) => ( + prefetch(`/item/${result.id}`)}> + {result.title} + + )) +} +``` + +### Via `usePrefetchEffect` (ref-based) + +```tsx +import { useRef } from 'react' +import { usePrefetchEffect } from '@studiolambda/router/react' + +function ProductCard({ href }: { href: string }) { + const ref = useRef(null) + usePrefetchEffect(ref, { href, on: 'viewport' }) + + return
...
+} +``` + +### Prefetch with data preloading + +```tsx +const router = createRouter((route) => { + route('/user/:id') + .prefetch(async ({ params, url }) => { + // Preload the user data before the route renders + await queryClient.prefetchQuery({ + queryKey: ['user', params.id], + queryFn: () => fetchUser(params.id), + }) + }) + .render(UserPage) +}) +``` + +--- + +## Sharing Transition State + +To read `isPending` **above** the Router (e.g. for a top-level progress bar), pass a `transition` prop: + +```tsx +import { useTransition } from 'react' +import { Router } from '@studiolambda/router/react' + +function App() { + const transition = useTransition() + const [isPending] = transition + + return ( + <> + {isPending && } + + + ) +} +``` + +Without this prop, `useIsPending()` is only available inside the `` tree. + +--- + +## Form Submissions + +Routes can handle form submissions via the Navigation API's form interception: + +```tsx +const router = createRouter((route) => { + route('/contact') + .formHandler(async (formData, event) => { + const name = formData.get('name') + const email = formData.get('email') + await submitContactForm({ name, email }) + }) + .render(ContactPage) +}) +``` + +```tsx +function ContactPage() { + return ( +
+ + + +
+ ) +} +``` + +When a form submits to a route with a `formHandler`, the handler is called instead of the normal component render. The Navigation API intercepts the form submission natively. + +--- + +## Dynamic Redirects + +```tsx +const router = createRouter((route) => { + // Carry params to the new location + route('/old-user/:id').redirect(({ params }) => `/user/${params.id}`) + + // Preserve query string + route('/search-old').redirect(({ url }) => `/search${url.search}`) + + // Conditional redirect (based on param) + route('/v1/:resource').redirect(({ params }) => `/v2/${params.resource}`) +}) +``` + +Redirect targets are always absolute paths. They run during the precommit phase, so the old URL never appears in the address bar. + +--- + +## Nested Layouts via Middleware + +Middleware components are regular React components that can provide layout structure: + +```tsx +function AppLayout({ children }: PropsWithChildren) { + return ( +
+
+
{children}
+
+
+ ) +} + +function SidebarLayout({ children }: PropsWithChildren) { + return ( +
+ +
{children}
+
+ ) +} + +const router = createRouter((route) => { + const app = route().middleware([AppLayout]).group() + + app('/').render(Home) + app('/about').render(About) + + const withSidebar = app().middleware([SidebarLayout]).group() + withSidebar('/dashboard').render(Dashboard) + withSidebar('/settings').render(Settings) +}) +``` + +Nesting: `AppLayout` > `SidebarLayout` > route component. + +--- + +## Auth Guards with Suspense + +Middleware can use React 19's `use()` to suspend while checking auth: + +```tsx +import { type PropsWithChildren, use } from 'react' + +// Cache the auth promise so it's not re-created on each render +let authPromise: Promise | undefined + +function getSession() { + authPromise ??= fetchSession() + return authPromise +} + +function RequireAuth({ children }: PropsWithChildren) { + const session = use(getSession()) + if (!session) return + return {children} +} + +const router = createRouter((route) => { + const authed = route().middleware([RequireAuth]).group() + authed('/dashboard').render(Dashboard) +}) +``` + +The nearest `` boundary (the Router's `fallback`) shows while the auth check is pending. + +--- + +## Cancellable Data Fetching + +Use `useNavigationSignal()` to abort in-flight requests when the user navigates away: + +```tsx +import { useNavigationSignal } from '@studiolambda/router/react' + +function DataPage() { + const signal = useNavigationSignal() + const [data, setData] = useState(null) + + useEffect(() => { + if (!signal) return + + fetch('/api/data', { signal }) + .then((res) => res.json()) + .then(setData) + .catch((error) => { + if (error.name !== 'AbortError') throw error + }) + }, [signal]) + + return data ? : +} +``` + +The signal is aborted automatically when a new navigation starts. + +--- + +## Module Federation + +Pass the `route` factory to remote modules for decentralized route registration: + +```tsx +// host app +const router = createRouter((route) => { + route('/').render(Home) + + // remote module registers its own routes + registerDashboardRoutes(route) +}) + +// remote module +export function registerDashboardRoutes(route: RouteFactory) { + const dashboard = route('/dashboard').middleware([Auth]).group() + dashboard('/').render(DashboardHome) + dashboard('/analytics').render(Analytics) +} +``` + +--- + +## Cache Invalidation + +After events like logout, clear the prefetch cache to force re-prefetch: + +```tsx +import { clearPrefetchCache } from '@studiolambda/router/react' + +function LogoutButton() { + const navigate = useNavigate() + + return ( + + ) +} +``` + +--- + +## Custom Active Link Component + +Build a custom navigation link using `useActiveLinkProps`: + +```tsx +import { useActiveLinkProps } from '@studiolambda/router/react' + +function NavLink({ href, children }: { href: string; children: React.ReactNode }) { + const { isActive, props } = useActiveLinkProps(href, { exact: false }) + + return ( + + {children} + + ) +} +``` + +`props` includes `data-active` and `aria-current="page"` when active, so you can also style via CSS attribute selectors: `a[data-active] { ... }`. + +--- + +## Scroll and Focus Behavior + +Control per-route scroll restoration and focus reset: + +```tsx +const router = createRouter((route) => { + // Default: browser handles scroll after transition + route('/page').render(Page) + + // Manual scroll: your component calls window.scrollTo() or similar + route('/infinite-list').scroll('manual').render(InfiniteList) + + // Manual focus: your component manages focus + route('/form').focusReset('manual').render(FormPage) +}) +``` + +Values: `"after-transition"` (default) or `"manual"`. These map directly to the Navigation API's `intercept()` options. diff --git a/.agents/skills/lambda-router/references/hooks-reference.md b/.agents/skills/lambda-router/references/hooks-reference.md new file mode 100644 index 0000000..8eb2c3b --- /dev/null +++ b/.agents/skills/lambda-router/references/hooks-reference.md @@ -0,0 +1,399 @@ +# Hooks Reference + +All hooks require a `` ancestor. Using them outside the router tree throws a descriptive error. + +## Table of Contents + +- [useParams](#useparams) +- [usePathname](#usepathname) +- [useSearchParams](#usesearchparams) +- [useNavigate](#usenavigate) +- [useNavigation](#usenavigation) +- [useNavigationType](#usenavigationtype) +- [useNavigationSignal](#usenavigationsignal) +- [useIsPending](#useispending) +- [useBack](#useback) +- [useForward](#useforward) +- [usePrefetch](#useprefetch) +- [usePrefetchEffect](#useprefetcheffect) +- [useNextMatch](#usenextmatch) +- [useNavigationHandlers](#usenavigationhandlers) +- [useNavigationEvents](#usenavigationevents) +- [useActiveLinkProps](#useactivelinkprops) + +--- + +## useParams + +```ts +function useParams(): Record +``` + +Returns dynamic route parameters extracted from the URL pattern. + +```tsx +// Route: /user/:id +function UserPage() { + const { id } = useParams() // { id: "42" } for /user/42 + return

User {id}

+} +``` + +Returns `{}` for routes with no dynamic segments. + +--- + +## usePathname + +```ts +function usePathname(): string +``` + +Returns the current URL pathname (without query string or hash). + +```tsx +function Breadcrumb() { + const pathname = usePathname() // "/user/42" + return {pathname} +} +``` + +--- + +## useSearchParams + +```ts +function useSearchParams(): [URLSearchParams, setSearchParams] + +type SearchParamsUpdater = + | URLSearchParams + | Record + | ((current: URLSearchParams) => URLSearchParams | Record) + +interface SetSearchParamsOptions { + history?: NavigationHistoryBehavior // default: "replace" +} +``` + +Reads search params from React state (concurrent-safe, not from mutable `currentEntry`). Setter preserves hash fragments. + +```tsx +function FilterPage() { + const [searchParams, setSearchParams] = useSearchParams() + const sort = searchParams.get('sort') ?? 'newest' + + function updateSort(value: string) { + // Record form (replaces all params) + setSearchParams({ sort: value }) + } + + function addFilter(key: string, value: string) { + // Updater form (modify existing params) + setSearchParams(function (current) { + current.set(key, value) + return current + }) + } + + function pushToHistory() { + // Push instead of replace + setSearchParams({ sort: 'oldest' }, { history: 'push' }) + } +} +``` + +--- + +## useNavigate + +```ts +function useNavigate(): (url: string, options?: NavigationNavigateOptions) => NavigationResult +``` + +Programmatic navigation. Delegates to `navigation.navigate()`. + +```tsx +function LogoutButton() { + const navigate = useNavigate() + + function handleLogout() { + clearSession() + navigate('/login', { history: 'replace' }) + } + + return +} +``` + +--- + +## useNavigation + +```ts +function useNavigation(): Navigation +``` + +Returns the raw browser Navigation API object. Use sparingly — prefer specific hooks. + +--- + +## useNavigationType + +```ts +function useNavigationType(): NavigationType | null +``` + +Returns the type of the current navigation: `"push"`, `"replace"`, `"reload"`, or `"traverse"`. Returns `null` before the first navigation event (initial render). + +```tsx +function PageTracker() { + const type = useNavigationType() + useEffect( + function () { + if (type === 'push') { + analytics.trackPageView() + } + }, + [type] + ) +} +``` + +--- + +## useNavigationSignal + +```ts +function useNavigationSignal(): AbortSignal | null +``` + +Returns the `AbortSignal` for the current navigation. Aborted when the navigation is superseded or cancelled. Returns `null` before the first navigation. + +```tsx +function DataLoader() { + const signal = useNavigationSignal() + const [data, setData] = useState(null) + + useEffect( + function () { + if (!signal) return + fetch('/api/data', { signal }) + .then(function (res) { + return res.json() + }) + .then(setData) + .catch(function (error) { + if (error.name !== 'AbortError') throw error + }) + }, + [signal] + ) +} +``` + +--- + +## useIsPending + +```ts +function useIsPending(): boolean +``` + +Whether a concurrent transition is in progress (route is loading). + +```tsx +function GlobalLoader() { + const isPending = useIsPending() + return isPending ? : null +} +``` + +--- + +## useBack + +```ts +interface UseBackResult { + back: (options?: NavigationOptions) => NavigationResult + canGoBack: boolean // reactive, updates on currententrychange +} + +function useBack(): UseBackResult +``` + +```tsx +function BackButton() { + const { back, canGoBack } = useBack() + return ( + + ) +} +``` + +--- + +## useForward + +```ts +interface UseForwardResult { + forward: (options?: NavigationOptions) => NavigationResult + canGoForward: boolean // reactive +} + +function useForward(): UseForwardResult +``` + +Mirror of `useBack` for forward navigation. + +--- + +## usePrefetch + +```ts +interface PrefetchOptions { + matcher?: Matcher // override context matcher +} + +function usePrefetch(options?: PrefetchOptions): (url: string) => void | Promise | undefined +function clearPrefetchCache(matcher: Matcher): void +``` + +Triggers a route's prefetch function manually. Automatically deduplicates by pathname per matcher instance. + +```tsx +function PreloadButton({ href }: { href: string }) { + const prefetch = usePrefetch() + + return ( + + ) +} +``` + +Use `clearPrefetchCache(matcher)` after logout or cache invalidation to allow re-prefetch. + +--- + +## usePrefetchEffect + +```ts +type PrefetchStrategy = 'viewport' | 'hover' + +interface PrefetchEffectOptions { + href?: string + on?: PrefetchStrategy + once?: boolean // default: true + matcher?: Matcher +} + +function usePrefetchEffect(ref: RefObject, options: PrefetchEffectOptions): void +``` + +Attach prefetch to a DOM element. Used internally by ``, but available for custom components. + +- `"hover"`: triggers on `mouseenter` +- `"viewport"`: triggers via `IntersectionObserver` +- `once: true` (default): fires once then disconnects + +```tsx +function Card({ href }: { href: string }) { + const ref = useRef(null) + usePrefetchEffect(ref, { href, on: 'viewport' }) + return
...
+} +``` + +--- + +## useNextMatch + +```ts +interface NextMatchOptions { + matcher?: Matcher +} + +function useNextMatch( + options?: NextMatchOptions +): (destination: string | null, notFound: ComponentType) => Resolved +``` + +Resolves a URL to a route match. Returns a resolver function. Falls back to `notFound` component when no route matches. Used internally by Router. + +--- + +## useNavigationHandlers + +```ts +interface PrecommitHandlerOptions { + prefetch?: PrefetchFunc + params: Record + url: URL +} + +function useNavigationHandlers(transition?: ReturnType): { + createPrecommitHandler: (options: PrecommitHandlerOptions) => (() => Promise) | undefined + createHandler: (callback: () => void) => () => Promise +} +``` + +Builds Navigation API intercept handlers. The `createHandler` wraps the callback in `startTransition` inside a Promise. Low-level — used internally by Router. + +--- + +## useNavigationEvents + +```ts +interface NavigationEventHandlers { + onNavigate?: (event: NavigateEvent) => void + onNavigateSuccess?: () => void + onNavigateError?: (error: unknown) => void +} + +function useNavigationEvents(navigation: Navigation, handlers: NavigationEventHandlers): void +``` + +Subscribes to navigation lifecycle events. All callbacks wrapped in `useEffectEvent` for stable references. Note: `navigation` is passed as an argument, not read from context. + +--- + +## useActiveLinkProps + +```ts +interface ActiveLinkOptions { + exact?: boolean // default: true +} + +interface ActiveLinkProps { + 'data-active'?: true + 'aria-current'?: 'page' +} + +function useActiveLinkProps( + href: string | undefined, + options?: ActiveLinkOptions +): { isActive: boolean; props: ActiveLinkProps } +``` + +Computes active link state by comparing `href` pathname against current pathname. Only the pathname portion is compared (query/hash ignored). + +```tsx +function NavLink({ href, children }: { href: string; children: ReactNode }) { + const { isActive, props } = useActiveLinkProps(href) + return ( + + {children} + + ) +} +``` diff --git a/.agents/skills/lambda-router/references/route-builder.md b/.agents/skills/lambda-router/references/route-builder.md new file mode 100644 index 0000000..a6ff46d --- /dev/null +++ b/.agents/skills/lambda-router/references/route-builder.md @@ -0,0 +1,184 @@ +# Route Builder API — `createRouter` + +## Table of Contents + +- [Overview](#overview) +- [RouteFactory](#routefactory) +- [RouteBuilder Methods](#routebuilder-methods) +- [Groups and Inheritance](#groups-and-inheritance) +- [Redirects](#redirects) +- [Prefetch Functions](#prefetch-functions) +- [Form Handling](#form-handling) +- [Handler Type](#handler-type) + +## Overview + +`createRouter(callback)` creates a `Matcher` using a declarative builder. The callback receives a `RouteFactory` function. + +```tsx +import { createRouter } from '@studiolambda/router/react' + +const matcher = createRouter(function (route) { + // define routes here +}) +``` + +Returns a `Matcher` that plugs into ``. + +## RouteFactory + +```ts +type RouteFactory = (path?: string) => RouteBuilder +``` + +- `route("/path")` — creates a builder with a path +- `route()` — creates a config-only builder (for groups without path prefix) + +Path patterns support: + +- Static segments: `/users/list` +- Dynamic params: `/user/:id` +- Wildcards: `/files/*path` (captures rest of URL) + +## RouteBuilder Methods + +### Chainable methods (return `RouteBuilder`) + +| Method | Signature | Description | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------- | +| `.middleware(list)` | `(ComponentType[]) => RouteBuilder` | Append middleware components | +| `.prefetch(fn)` | `(PrefetchFunc) => RouteBuilder` | Add prefetch function to chain | +| `.scroll(behavior)` | `(NavigationScrollBehavior) => RouteBuilder` | `"after-transition"` (default) or `"manual"` | +| `.focusReset(behavior)` | `(NavigationFocusReset) => RouteBuilder` | `"after-transition"` (default) or `"manual"` | +| `.formHandler(fn)` | `(FormHandler) => RouteBuilder` | Handle form submissions for this route | + +### Terminal methods (consume the builder) + +| Method | Signature | Description | +| -------------------- | -------------------------- | ------------------------------------ | +| `.render(component)` | `(ComponentType) => void` | Register route with component | +| `.redirect(target)` | `(RedirectTarget) => void` | Register precommit redirect | +| `.group()` | `() => RouteFactory` | Create child scope inheriting config | + +**After calling a terminal method, no further methods can be called on the builder.** Attempting to do so throws an error. + +## Groups and Inheritance + +Groups create scoped route factories. Child routes inherit: + +- **Path prefix**: prepended to all child paths +- **Middleware**: prepended before child middleware (outermost first) +- **Prefetch functions**: run before child prefetches (sequential) + +```tsx +const router = createRouter(function (route) { + // Config-only group (no path prefix, just middleware) + const authed = route().middleware([Auth]).group() + authed('/dashboard').render(Dashboard) // /dashboard, with Auth + + // Path prefix + middleware group + const admin = authed('/admin').middleware([AdminGuard]).group() + admin('/users').render(AdminUsers) // /admin/users, with Auth + AdminGuard + admin('/config').render(AdminConfig) // /admin/config, with Auth + AdminGuard + + // Prefetch inheritance + const api = route('/api') + .prefetch(function (ctx) { + return loadApiConfig() + }) + .group() + api('/users') + .prefetch(function (ctx) { + return loadUsers() + }) + .render(ApiUsers) + // /api/users prefetch: loadApiConfig() then loadUsers() (sequential) +}) +``` + +**Group isolation**: sibling builders do not affect each other. Config only flows downward. + +## Redirects + +```tsx +// Static redirect +route('/old').redirect('/new') + +// Dynamic redirect using params +route('/legacy/:id').redirect(function ({ params }) { + return `/modern/${params.id}` +}) + +// Dynamic redirect using URL +route('/short').redirect(function ({ url }) { + return `/long${url.search}` +}) +``` + +Redirect targets are **absolute paths** (not prefixed by parent groups). + +Redirects do **NOT** inherit: + +- middleware +- scroll behavior +- focusReset + +Redirects **DO** run during the precommit phase via `controller.redirect()`, so the URL never commits to the address bar. + +Static redirect cycles are detected at build time and throw. Callback redirects are not checked. + +## Prefetch Functions + +```ts +type PrefetchFunc = (context: PrefetchContext) => void | Promise + +interface PrefetchContext { + params: Record // matched route params + url: URL // full destination URL + controller: NavigationPrecommitController // for redirects +} +``` + +Prefetch runs during the Navigation API's precommit phase (before URL commits). Use it for: + +- Data preloading +- Module preloading +- Authentication checks with redirect + +When triggered by `` or `usePrefetch()` (outside a real navigation), the controller is a no-op stub. + +Multiple prefetch functions (from groups + route) chain sequentially. + +## Form Handling + +```ts +type FormHandler = (formData: FormData, event: NavigateEvent) => void | Promise +``` + +When a navigation includes `formData` and the matched route has a `formHandler`, it is called instead of the normal render flow. + +```tsx +route('/search') + .formHandler(async function (formData, event) { + const query = formData.get('q') + await performSearch(query) + }) + .render(SearchPage) +``` + +## Handler Type + +The `Handler` interface registered in the matcher: + +```ts +interface Handler { + component: ComponentType // route component to render + prefetch?: PrefetchFunc // precommit prefetch + middlewares?: ComponentType[] // middleware chain + scroll?: NavigationScrollBehavior // "after-transition" | "manual" + focusReset?: NavigationFocusReset // "after-transition" | "manual" + formHandler?: FormHandler // form submission handler +} +``` + +Consumers normally never construct `Handler` objects directly — `createRouter` builds them via the builder API.