From 21bca554d42dd09d5ff20945ca9d7e9d947e74f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sun, 12 Apr 2026 20:34:22 +0200 Subject: [PATCH 1/2] feat: add lambda-router agent skill for library consumers Add an Agent Skills-compatible skill (.agents/skills/lambda-router/) that teaches LLM coding agents how to use @studiolambda/router v2.0.0. Includes SKILL.md with quick start, route definition, components, hooks overview, middleware, SSR/testing, and standalone matcher usage. Three reference files provide detailed API docs for the route builder, all 16 hooks, and advanced patterns (prefetch, forms, redirects, layouts, auth guards, module federation, cache invalidation). --- .agents/skills/lambda-router/SKILL.md | 291 +++++++++++++ .../references/advanced-patterns.md | 383 +++++++++++++++++ .../references/hooks-reference.md | 387 ++++++++++++++++++ .../lambda-router/references/route-builder.md | 175 ++++++++ 4 files changed, 1236 insertions(+) create mode 100644 .agents/skills/lambda-router/SKILL.md create mode 100644 .agents/skills/lambda-router/references/advanced-patterns.md create mode 100644 .agents/skills/lambda-router/references/hooks-reference.md create mode 100644 .agents/skills/lambda-router/references/route-builder.md diff --git a/.agents/skills/lambda-router/SKILL.md b/.agents/skills/lambda-router/SKILL.md new file mode 100644 index 0000000..066e500 --- /dev/null +++ b/.agents/skills/lambda-router/SKILL.md @@ -0,0 +1,291 @@ +--- +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..6969453 --- /dev/null +++ b/.agents/skills/lambda-router/references/advanced-patterns.md @@ -0,0 +1,383 @@ +# 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..3c9f6ea --- /dev/null +++ b/.agents/skills/lambda-router/references/hooks-reference.md @@ -0,0 +1,387 @@ +# 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..77b0312 --- /dev/null +++ b/.agents/skills/lambda-router/references/route-builder.md @@ -0,0 +1,175 @@ +# 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. From 67ce386305778d870f0aa724b60a8da0c87c672a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sun, 12 Apr 2026 20:36:00 +0200 Subject: [PATCH 2/2] style: format skill files with oxfmt --- .agents/skills/lambda-router/SKILL.md | 127 +++++++++--------- .../references/advanced-patterns.md | 100 +++++++------- .../references/hooks-reference.md | 90 +++++++------ .../lambda-router/references/route-builder.md | 77 ++++++----- 4 files changed, 205 insertions(+), 189 deletions(-) diff --git a/.agents/skills/lambda-router/SKILL.md b/.agents/skills/lambda-router/SKILL.md index 066e500..36af26d 100644 --- a/.agents/skills/lambda-router/SKILL.md +++ b/.agents/skills/lambda-router/SKILL.md @@ -9,7 +9,7 @@ description: > middleware, prefetch, lazy loading, SSR with createMemoryNavigation, search params, and form handling. metadata: - version: "2.0.0" + version: '2.0.0' --- # Lambda Router @@ -28,15 +28,15 @@ Links don't need `onClick` or `preventDefault` — the Navigation API intercepts ## Quick Start ```tsx -import { lazy, Suspense } from "react" -import { createRouter, Router, Link } from "@studiolambda/router/react" +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 Home = lazy(() => import('./pages/Home')) +const User = lazy(() => import('./pages/User')) const router = createRouter((route) => { - route("/").render(Home) - route("/user/:id").render(User) + route('/').render(Home) + route('/user/:id').render(User) }) function App() { @@ -59,40 +59,41 @@ Build a `Matcher` with the declarative builder API. See [route-builder. ```tsx const router = createRouter((route) => { // Static route - route("/").render(Home) + route('/').render(Home) // Dynamic params - route("/user/:id").render(User) + route('/user/:id').render(User) // Wildcard (catches remaining segments) - route("/files/*path").render(FileViewer) + route('/files/*path').render(FileViewer) // Redirect (static) - route("/old").redirect("/new") + route('/old').redirect('/new') // Redirect (dynamic with params) - route("/old-user/:id").redirect(({ params }) => `/user/${params.id}`) + 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") + 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) + 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 + 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 @@ -106,10 +107,10 @@ 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 + matcher={router} // Matcher (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)} @@ -147,21 +148,21 @@ Active links get `data-active` and `aria-current="page"` attributes automaticall 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 | +| 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 @@ -176,7 +177,10 @@ function UserPage() { function LogoutButton() { const navigate = useNavigate() return ( - ) @@ -185,13 +189,8 @@ function LogoutButton() { // Search params function SearchPage() { const [searchParams, setSearchParams] = useSearchParams() - const query = searchParams.get("q") ?? "" - return ( - setSearchParams({ q: e.target.value })} - /> - ) + const query = searchParams.get('q') ?? '' + return setSearchParams({ q: e.target.value })} /> } // Pending indicator @@ -211,7 +210,7 @@ function Layout({ children }: { children: React.ReactNode }) { Middleware components receive `{ children }` and wrap the route component. They can suspend, conditionally render, or add context. ```tsx -import { type PropsWithChildren, use } from "react" +import { type PropsWithChildren, use } from 'react' // Auth guard using Suspense function Auth({ children }: PropsWithChildren) { @@ -233,7 +232,7 @@ function DashboardLayout({ children }: PropsWithChildren) { // Apply to routes const router = createRouter((route) => { const authed = route().middleware([Auth, DashboardLayout]).group() - authed("/dashboard").render(Dashboard) + authed('/dashboard').render(Dashboard) }) ``` @@ -244,18 +243,22 @@ Middlewares nest outermost-first: `[Auth, Layout]` means Auth wraps Layout wraps Use `createMemoryNavigation` for non-browser environments: ```tsx -import { createMemoryNavigation, Router } from "@studiolambda/router/react" +import { createMemoryNavigation, Router } from '@studiolambda/router/react' // SSR -const navigation = createMemoryNavigation({ url: "https://example.com/page" }) +const navigation = createMemoryNavigation({ url: 'https://example.com/page' }) function ServerApp() { return } // Testing helper -function renderWithRouter(ui: React.ReactNode, { url = "/" } = {}) { +function renderWithRouter(ui: React.ReactNode, { url = '/' } = {}) { const nav = createMemoryNavigation({ url: `http://localhost${url}` }) - return render({ui}) + return render( + + {ui} + + ) } ``` @@ -266,17 +269,17 @@ function renderWithRouter(ui: React.ReactNode, { url = "/" } = {}) { The core matcher can be used independently of React: ```ts -import { createMatcher } from "@studiolambda/router" +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 +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`) diff --git a/.agents/skills/lambda-router/references/advanced-patterns.md b/.agents/skills/lambda-router/references/advanced-patterns.md index 6969453..b43d86b 100644 --- a/.agents/skills/lambda-router/references/advanced-patterns.md +++ b/.agents/skills/lambda-router/references/advanced-patterns.md @@ -22,15 +22,15 @@ 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" +import { lazy } from 'react' +import { createRouter, Router } from '@studiolambda/router/react' -const Dashboard = lazy(() => import("./pages/Dashboard")) -const Settings = lazy(() => import("./pages/Settings")) +const Dashboard = lazy(() => import('./pages/Dashboard')) +const Settings = lazy(() => import('./pages/Settings')) const router = createRouter((route) => { - route("/dashboard").render(Dashboard) - route("/settings").render(Settings) + route('/dashboard').render(Dashboard) + route('/settings').render(Settings) }) function App() { @@ -60,7 +60,7 @@ The `fallback` prop is passed to the internal `` boundary. ### Via `usePrefetch` (manual) ```tsx -import { usePrefetch } from "@studiolambda/router/react" +import { usePrefetch } from '@studiolambda/router/react' function SearchResults({ results }) { const prefetch = usePrefetch() @@ -69,8 +69,7 @@ function SearchResults({ results }) { prefetch(`/item/${result.id}`)} - > + onMouseEnter={() => prefetch(`/item/${result.id}`)}> {result.title} )) @@ -80,12 +79,12 @@ function SearchResults({ results }) { ### Via `usePrefetchEffect` (ref-based) ```tsx -import { useRef } from "react" -import { usePrefetchEffect } from "@studiolambda/router/react" +import { useRef } from 'react' +import { usePrefetchEffect } from '@studiolambda/router/react' function ProductCard({ href }: { href: string }) { const ref = useRef(null) - usePrefetchEffect(ref, { href, on: "viewport" }) + usePrefetchEffect(ref, { href, on: 'viewport' }) return
...
} @@ -95,11 +94,11 @@ function ProductCard({ href }: { href: string }) { ```tsx const router = createRouter((route) => { - route("/user/:id") + route('/user/:id') .prefetch(async ({ params, url }) => { // Preload the user data before the route renders await queryClient.prefetchQuery({ - queryKey: ["user", params.id], + queryKey: ['user', params.id], queryFn: () => fetchUser(params.id), }) }) @@ -114,8 +113,8 @@ const router = createRouter((route) => { 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" +import { useTransition } from 'react' +import { Router } from '@studiolambda/router/react' function App() { const transition = useTransition() @@ -140,10 +139,10 @@ Routes can handle form submissions via the Navigation API's form interception: ```tsx const router = createRouter((route) => { - route("/contact") + route('/contact') .formHandler(async (formData, event) => { - const name = formData.get("name") - const email = formData.get("email") + const name = formData.get('name') + const email = formData.get('email') await submitContactForm({ name, email }) }) .render(ContactPage) @@ -171,13 +170,13 @@ When a form submits to a route with a `formHandler`, the handler is called inste ```tsx const router = createRouter((route) => { // Carry params to the new location - route("/old-user/:id").redirect(({ params }) => `/user/${params.id}`) + route('/old-user/:id').redirect(({ params }) => `/user/${params.id}`) // Preserve query string - route("/search-old").redirect(({ url }) => `/search${url.search}`) + route('/search-old').redirect(({ url }) => `/search${url.search}`) // Conditional redirect (based on param) - route("/v1/:resource").redirect(({ params }) => `/v2/${params.resource}`) + route('/v1/:resource').redirect(({ params }) => `/v2/${params.resource}`) }) ``` @@ -212,12 +211,12 @@ function SidebarLayout({ children }: PropsWithChildren) { const router = createRouter((route) => { const app = route().middleware([AppLayout]).group() - app("/").render(Home) - app("/about").render(About) + app('/').render(Home) + app('/about').render(About) const withSidebar = app().middleware([SidebarLayout]).group() - withSidebar("/dashboard").render(Dashboard) - withSidebar("/settings").render(Settings) + withSidebar('/dashboard').render(Dashboard) + withSidebar('/settings').render(Settings) }) ``` @@ -230,7 +229,7 @@ Nesting: `AppLayout` > `SidebarLayout` > route component. Middleware can use React 19's `use()` to suspend while checking auth: ```tsx -import { type PropsWithChildren, use } from "react" +import { type PropsWithChildren, use } from 'react' // Cache the auth promise so it's not re-created on each render let authPromise: Promise | undefined @@ -248,7 +247,7 @@ function RequireAuth({ children }: PropsWithChildren) { const router = createRouter((route) => { const authed = route().middleware([RequireAuth]).group() - authed("/dashboard").render(Dashboard) + authed('/dashboard').render(Dashboard) }) ``` @@ -261,7 +260,7 @@ The nearest `` boundary (the Router's `fallback`) shows while the auth Use `useNavigationSignal()` to abort in-flight requests when the user navigates away: ```tsx -import { useNavigationSignal } from "@studiolambda/router/react" +import { useNavigationSignal } from '@studiolambda/router/react' function DataPage() { const signal = useNavigationSignal() @@ -270,11 +269,11 @@ function DataPage() { useEffect(() => { if (!signal) return - fetch("/api/data", { signal }) + fetch('/api/data', { signal }) .then((res) => res.json()) .then(setData) .catch((error) => { - if (error.name !== "AbortError") throw error + if (error.name !== 'AbortError') throw error }) }, [signal]) @@ -293,7 +292,7 @@ Pass the `route` factory to remote modules for decentralized route registration: ```tsx // host app const router = createRouter((route) => { - route("/").render(Home) + route('/').render(Home) // remote module registers its own routes registerDashboardRoutes(route) @@ -301,9 +300,9 @@ const router = createRouter((route) => { // remote module export function registerDashboardRoutes(route: RouteFactory) { - const dashboard = route("/dashboard").middleware([Auth]).group() - dashboard("/").render(DashboardHome) - dashboard("/analytics").render(Analytics) + const dashboard = route('/dashboard').middleware([Auth]).group() + dashboard('/').render(DashboardHome) + dashboard('/analytics').render(Analytics) } ``` @@ -314,17 +313,18 @@ export function registerDashboardRoutes(route: RouteFactory) { After events like logout, clear the prefetch cache to force re-prefetch: ```tsx -import { clearPrefetchCache } from "@studiolambda/router/react" +import { clearPrefetchCache } from '@studiolambda/router/react' function LogoutButton() { const navigate = useNavigate() return ( - ) @@ -338,17 +338,13 @@ function LogoutButton() { Build a custom navigation link using `useActiveLinkProps`: ```tsx -import { useActiveLinkProps } from "@studiolambda/router/react" +import { useActiveLinkProps } from '@studiolambda/router/react' function NavLink({ href, children }: { href: string; children: React.ReactNode }) { const { isActive, props } = useActiveLinkProps(href, { exact: false }) return ( - + {children} ) @@ -366,17 +362,13 @@ Control per-route scroll restoration and focus reset: ```tsx const router = createRouter((route) => { // Default: browser handles scroll after transition - route("/page").render(Page) + route('/page').render(Page) // Manual scroll: your component calls window.scrollTo() or similar - route("/infinite-list") - .scroll("manual") - .render(InfiniteList) + route('/infinite-list').scroll('manual').render(InfiniteList) // Manual focus: your component manages focus - route("/form") - .focusReset("manual") - .render(FormPage) + route('/form').focusReset('manual').render(FormPage) }) ``` diff --git a/.agents/skills/lambda-router/references/hooks-reference.md b/.agents/skills/lambda-router/references/hooks-reference.md index 3c9f6ea..8eb2c3b 100644 --- a/.agents/skills/lambda-router/references/hooks-reference.md +++ b/.agents/skills/lambda-router/references/hooks-reference.md @@ -34,7 +34,7 @@ Returns dynamic route parameters extracted from the URL pattern. ```tsx // Route: /user/:id function UserPage() { - const { id } = useParams() // { id: "42" } for /user/42 + const { id } = useParams() // { id: "42" } for /user/42 return

User {id}

} ``` @@ -53,7 +53,7 @@ Returns the current URL pathname (without query string or hash). ```tsx function Breadcrumb() { - const pathname = usePathname() // "/user/42" + const pathname = usePathname() // "/user/42" return {pathname} } ``` @@ -71,7 +71,7 @@ type SearchParamsUpdater = | ((current: URLSearchParams) => URLSearchParams | Record) interface SetSearchParamsOptions { - history?: NavigationHistoryBehavior // default: "replace" + history?: NavigationHistoryBehavior // default: "replace" } ``` @@ -80,7 +80,7 @@ Reads search params from React state (concurrent-safe, not from mutable `current ```tsx function FilterPage() { const [searchParams, setSearchParams] = useSearchParams() - const sort = searchParams.get("sort") ?? "newest" + const sort = searchParams.get('sort') ?? 'newest' function updateSort(value: string) { // Record form (replaces all params) @@ -97,7 +97,7 @@ function FilterPage() { function pushToHistory() { // Push instead of replace - setSearchParams({ sort: "oldest" }, { history: "push" }) + setSearchParams({ sort: 'oldest' }, { history: 'push' }) } } ``` @@ -118,7 +118,7 @@ function LogoutButton() { function handleLogout() { clearSession() - navigate("/login", { history: "replace" }) + navigate('/login', { history: 'replace' }) } return @@ -148,11 +148,14 @@ Returns the type of the current navigation: `"push"`, `"replace"`, `"reload"`, o ```tsx function PageTracker() { const type = useNavigationType() - useEffect(function () { - if (type === "push") { - analytics.trackPageView() - } - }, [type]) + useEffect( + function () { + if (type === 'push') { + analytics.trackPageView() + } + }, + [type] + ) } ``` @@ -171,15 +174,20 @@ 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]) + 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] + ) } ``` @@ -207,7 +215,7 @@ function GlobalLoader() { ```ts interface UseBackResult { back: (options?: NavigationOptions) => NavigationResult - canGoBack: boolean // reactive, updates on currententrychange + canGoBack: boolean // reactive, updates on currententrychange } function useBack(): UseBackResult @@ -217,7 +225,11 @@ function useBack(): UseBackResult function BackButton() { const { back, canGoBack } = useBack() return ( - ) @@ -231,7 +243,7 @@ function BackButton() { ```ts interface UseForwardResult { forward: (options?: NavigationOptions) => NavigationResult - canGoForward: boolean // reactive + canGoForward: boolean // reactive } function useForward(): UseForwardResult @@ -245,7 +257,7 @@ Mirror of `useBack` for forward navigation. ```ts interface PrefetchOptions { - matcher?: Matcher // override context matcher + matcher?: Matcher // override context matcher } function usePrefetch(options?: PrefetchOptions): (url: string) => void | Promise | undefined @@ -259,7 +271,10 @@ function PreloadButton({ href }: { href: string }) { const prefetch = usePrefetch() return ( - ) @@ -273,12 +288,12 @@ Use `clearPrefetchCache(matcher)` after logout or cache invalidation to allow re ## usePrefetchEffect ```ts -type PrefetchStrategy = "viewport" | "hover" +type PrefetchStrategy = 'viewport' | 'hover' interface PrefetchEffectOptions { href?: string on?: PrefetchStrategy - once?: boolean // default: true + once?: boolean // default: true matcher?: Matcher } @@ -294,7 +309,7 @@ Attach prefetch to a DOM element. Used internally by ``, but available for ```tsx function Card({ href }: { href: string }) { const ref = useRef(null) - usePrefetchEffect(ref, { href, on: "viewport" }) + usePrefetchEffect(ref, { href, on: 'viewport' }) return
...
} ``` @@ -308,10 +323,9 @@ interface NextMatchOptions { matcher?: Matcher } -function useNextMatch(options?: NextMatchOptions): ( - destination: string | null, - notFound: ComponentType -) => Resolved +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. @@ -327,9 +341,7 @@ interface PrecommitHandlerOptions { url: URL } -function useNavigationHandlers( - transition?: ReturnType -): { +function useNavigationHandlers(transition?: ReturnType): { createPrecommitHandler: (options: PrecommitHandlerOptions) => (() => Promise) | undefined createHandler: (callback: () => void) => () => Promise } @@ -359,12 +371,12 @@ Subscribes to navigation lifecycle events. All callbacks wrapped in `useEffectEv ```ts interface ActiveLinkOptions { - exact?: boolean // default: true + exact?: boolean // default: true } interface ActiveLinkProps { - "data-active"?: true - "aria-current"?: "page" + 'data-active'?: true + 'aria-current'?: 'page' } function useActiveLinkProps( @@ -379,7 +391,7 @@ Computes active link state by comparing `href` pathname against current pathname 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 index 77b0312..a6ff46d 100644 --- a/.agents/skills/lambda-router/references/route-builder.md +++ b/.agents/skills/lambda-router/references/route-builder.md @@ -16,7 +16,7 @@ `createRouter(callback)` creates a `Matcher` using a declarative builder. The callback receives a `RouteFactory` function. ```tsx -import { createRouter } from "@studiolambda/router/react" +import { createRouter } from '@studiolambda/router/react' const matcher = createRouter(function (route) { // define routes here @@ -35,6 +35,7 @@ type RouteFactory = (path?: string) => RouteBuilder - `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) @@ -43,21 +44,21 @@ Path patterns support: ### 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 | +| 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 | +| 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. @@ -73,18 +74,24 @@ Groups create scoped route factories. Child routes inherit: 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 + 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 + 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() }) + const api = route('/api') + .prefetch(function (ctx) { + return loadApiConfig() + }) .group() - api("/users").prefetch(function (ctx) { return loadUsers() }).render(ApiUsers) + api('/users') + .prefetch(function (ctx) { + return loadUsers() + }) + .render(ApiUsers) // /api/users prefetch: loadApiConfig() then loadUsers() (sequential) }) ``` @@ -95,15 +102,15 @@ const router = createRouter(function (route) { ```tsx // Static redirect -route("/old").redirect("/new") +route('/old').redirect('/new') // Dynamic redirect using params -route("/legacy/:id").redirect(function ({ params }) { +route('/legacy/:id').redirect(function ({ params }) { return `/modern/${params.id}` }) // Dynamic redirect using URL -route("/short").redirect(function ({ url }) { +route('/short').redirect(function ({ url }) { return `/long${url.search}` }) ``` @@ -111,6 +118,7 @@ route("/short").redirect(function ({ url }) { Redirect targets are **absolute paths** (not prefixed by parent groups). Redirects do **NOT** inherit: + - middleware - scroll behavior - focusReset @@ -125,13 +133,14 @@ Static redirect cycles are detected at build time and throw. Callback redirects type PrefetchFunc = (context: PrefetchContext) => void | Promise interface PrefetchContext { - params: Record // matched route params - url: URL // full destination URL - controller: NavigationPrecommitController // for redirects + 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 @@ -149,9 +158,9 @@ 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") +route('/search') .formHandler(async function (formData, event) { - const query = formData.get("q") + const query = formData.get('q') await performSearch(query) }) .render(SearchPage) @@ -163,12 +172,12 @@ 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 + 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 } ```