npm package: @plumile/router
Description: A modern, type-safe React router with code splitting, data preloading, and Suspense integration
Author: Olivier Hardy
Latest version: 0.1.87

# @plumile/router

A modern, type-safe React router built with TypeScript that supports code splitting, data preloading, and React Suspense integration.

## Status

Recommended for external use. This package is one of the main public entry
points of the repository.

## Features

- **Type-safe routing** with full TypeScript support
- **Code splitting** with dynamic imports and lazy loading
- **Data preloading** for faster navigation
- **React Suspense** integration for smooth loading states
- **Browser history** management with push/replace state
- **Nested routing** support
- **Route-based redirects**
- **Active link detection** with exact/partial matching

## Installation

```bash
npm install @plumile/router
```

## Peer Dependencies

This package expects `react` and `react-dom` to be available in your project
(React 18 or 19).

```bash
npm install react react-dom
```

## Module Entry Points

- `@plumile/router`: primary ESM entry exporting runtime APIs and types.
- `@plumile/router/lib/esm/*`: direct file imports when you need to tree-shake specific helpers.
- `@plumile/router/lib/types/*`: TypeScript declaration files (consumed automatically via `types` field).

This package is ESM-only. If your tooling expects CommonJS, enable ESM support (e.g. Vite, Next.js, or webpack with `type: 'module'`).

## Documentation & Guides

- Getting started: [User guide](https://gitlab.com/plumile/js/-/blob/main/docs/index.md)
- Relay integration walkthrough: [Relay guide](https://gitlab.com/plumile/js/-/blob/main/docs/router-relay.md)
- Examples: [Examples](https://gitlab.com/plumile/js/-/blob/main/packages/router/EXAMPLES.md)
- Migration strategies: [Migration guide](https://gitlab.com/plumile/js/-/blob/main/packages/router/MIGRATION.md)
- DevTools extension usage: [DevTools documentation](https://gitlab.com/plumile/js/-/blob/main/docs/router-devtools-extension.md)

## Quick Start

### 1. Define Your Routes

```typescript
import { Route, getResourcePage } from '@plumile/router';

const routes: Route<any, any, any>[] = [
  {
    path: '/',
    resourcePage: getResourcePage('Home', () => import('./pages/Home')),
  },
  {
    path: '/about',
    resourcePage: getResourcePage('About', () => import('./pages/About')),
  },
  {
    path: '/users',
    children: [
      {
        path: '/:id',
        resourcePage: getResourcePage(
          'UserProfile',
          () => import('./pages/UserProfile'),
        ),
        prepare: ({ variables }) => {
          // Preload user data
          return { userId: variables.id };
        },
      },
    ],
  },
];
```

### 2. Create the Router

```typescript
import { createRouter } from '@plumile/router';

export const router = createRouter(routes);
const { context, cleanup } = router;
// Call cleanup() when tearing the app down (tests, SSR shell disposal, etc.).
```

### 3. Provide Router Context

```tsx
import React from 'react';
import { RoutingContext, RouterRenderer } from '@plumile/router';
import { router } from './router';

const { context } = router;

function App() {
  return (
    <RoutingContext.Provider value={context}>
      <RouterRenderer fallback={<div>Loading...</div>} />
    </RoutingContext.Provider>
  );
}
```

### 4. Navigation with Links

```tsx
import { Link } from '@plumile/router';

function Navigation() {
  return (
    <nav>
      <Link to="/" exact activeClassName="active">
        Home
      </Link>
      <Link to="/about" activeClassName="active">
        About
      </Link>
      <Link to="/users/123" activeClassName="active">
        User Profile
      </Link>
    </nav>
  );
}
```

## Hooks Overview

| Hook                           | Signature                                  | Purpose                                                                    | Example                                                                                              |
| ------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `useNavigate()`                | `() => Navigate`                           | Imperative navigation with type-safe params and filters.                   | `const navigate = useNavigate(); navigate({ pathname: '/products', filters: { page: { eq: 2 } } });` |
| `useFilters(schema)`           | `(schema) => [filters, actions]`           | Read and mutate filters inferred from a schema.                            | `const [filters, actions] = useFilters(productFilters); actions.set('price', 'gt', 10);`             |
| `useQuery()`                   | `() => Record<string, string \| string[]>` | Access the raw query aggregation (legacy/simple use).                      | `const query = useQuery();`                                                                          |
| `useLocation()`                | `() => Location`                           | Read the current location object.                                          | `const location = useLocation();`                                                                    |
| `usePathname()`                | `() => string`                             | Read the current pathname.                                                 | `const pathname = usePathname();`                                                                    |
| `useSearchParams()`            | `() => SearchParamsActions`                | Read and update URLSearchParams.                                           | `const { params, setParam } = useSearchParams();`                                                    |
| `useQueryState(key, options?)` | `(key, options?) => [value, setValue]`     | Two-way binding for a single query parameter with default/replace options. | `const [page, setPage] = useQueryState('page', { defaultValue: 1 });`                                |
| `useFilterDiagnostics()`       | `() => Diagnostic[]`                       | Surface parsing issues (unknown fields/operators) for UI or logging.       | `const diagnostics = useFilterDiagnostics();`                                                        |
| `useAllQuery(options?)`        | `(options?) => QueryLike`                  | Merge filters and raw query, helpful during migrations.                    | `const all = useAllQuery();`                                                                         |

## API Reference

### Core Components

#### `createRouter<TContext>(routes: Route<TContext, any, any>[], options?)`

Creates a router instance with the given route configuration.

**Parameters:**

- `routes`: Array of route definitions
- `options?`: Optional configuration
  - `context?`: Static context value or lazy initializer
  - `getContext?`: Resolve a fresh context value per navigation
  - `instrumentations?`: Instrumentations invoked on router events

**Returns:**

- `context`: Router context object for the React Context Provider
- `cleanup`: Function to clean up router listeners

#### `RouterRenderer`

Renders the matched route component with Suspense support.

**Props:**

- `fallback?`: ReactNode - Fallback UI while loading components
- `enableTransition?`: boolean - Enable React 18 transitions
- `pending?`: ReactNode - UI to show during transitions

#### `Link`

Navigation component that handles client-side routing.

**Props:**

- `to`: string | HistoryLocation - Destination path (supports `search`/`hash`)
- `filters?`: object - Filters object serialized with the active query schema
- `query?`: object - Raw query params merged as non-schema keys
- `exact?`: boolean - Exact path matching for active state
- `activeClassName?`: string - CSS class when link is active
- `className?`: string - Base CSS class
- `preloadOnMouseEnter?`: boolean - Preload route on hover
- `preloadOnMouseDown?`: boolean - Preload route on mouse down
- `href?`: string - Explicit href override
- `target?`: string - Target attribute for the link
- `onClick?`: (event) => void - Click handler

#### `RoutingContext`

React context that provides router functionality to components.

### Route Configuration

#### `Route<TContext, TPrepared, TVariables>`

Route definition interface.

**Properties:**

- `path?`: string - URL path pattern
- `children?`: Route[] | Redirect[] - Nested routes
- `resourcePage?`: ResourcePage - Lazy-loaded component
- `prepare?`: Function to prepare route data before rendering. May return a value or a Promise.
- `render?`: Custom render function. May return a React node, `undefined`, or a Promise.

### Route Prepare Lifecycle

`prepare` is now a first-class Suspense primitive.

Behavior:

- `prepare` may be synchronous or asynchronous.
- The router wraps its result in an internal prepared resource.
- Route components and `render` callbacks only receive resolved `prepared` values.
- If `prepare` is still pending, the router suspends through React Suspense.
- If `prepare` rejects, the error is rethrown to the nearest Error Boundary.

Example with synchronous prepare:

```ts
const routes = [
  {
    path: '/users/:id',
    resourcePage: getResourcePage('UserPage', () => import('./UserPage')),
    prepare: ({ variables }) => {
      return { userId: variables.id };
    },
  },
];
```

Example with asynchronous prepare:

```ts
const routes = [
  {
    path: '/projects/:id',
    resourcePage: getResourcePage('ProjectPage', () => import('./ProjectPage')),
    prepare: async ({ variables, context }) => {
      const project = await context.api.fetchProject(variables.id);
      return { project };
    },
  },
];
```

In both cases:

- `render` receives `prepared` only after resolution;
- the route component receives `prepared` only after resolution;
- no `Promise` is ever exposed as `prepared` to userland consumers.

#### `Redirect`

Redirect configuration.

**Properties:**

- `path?`: string - Source path
- `to`: string - Destination path
- `status?`: 301 | 302 - HTTP status code

### Resource Management

#### `getResourcePage(moduleId: string, loader: ResourcePageLoader)`

Creates a resource for lazy-loading components.

**Parameters:**

- `moduleId`: Unique identifier for caching
- `loader`: Function that returns dynamic import

**Returns:**

- `ResourcePage` instance

#### `ResourcePage`

Manages lazy-loaded components with Suspense integration.

**Methods:**

- `load()`: Promise - Load the component
- `get()`: Component | undefined - Get loaded component
- `read()`: Component - Read with Suspense (throws Promise if loading)

### History Management

#### `BrowserHistory`

Browser history implementation.

**Methods:**

- `push(location)`: Navigate to new location
- `set(location)`: Replace current location
- `subscribe(listener)`: Listen for navigation changes

### Utilities

#### `getMatchedRoute(routes, location)`

Finds the matching route for a given location.

#### `prepareMatch(match, query?, instrumentation?, context?)`

Prepares route data and components for rendering.

`prepareMatch()` is synchronous at the API level, but it eagerly creates route resources for:

- code loading through `resourcePage`;
- data preparation through `prepare`.

If a route `prepare` is asynchronous, `prepareMatch()` returns a prepared route whose prepared value is backed by a Suspense-aware resource.

#### `prepareMatchAsync(match, query?, instrumentation?, context?)`

Resolves all prepared route values eagerly and returns a fully resolved prepared match.

Use this helper when you need route prepared data outside the normal React render flow, for example:

- tests that need resolved prepared values;
- non-React tooling;
- future SSR-like workflows.

#### `r<TContext, TPrepared, TVariables>(route)`

Type helper for strongly-typed route definitions.

## Unified Query & Filter Model

The router now unifies page-like query parameters and structured filters under a single model powered by `@plumile/filter-query`.

Key points:

- A route can declare a `querySchema` (filter schema) on its deepest branch.

## Preloading Semantics

The router exposes two distinct preload strategies:

- `preloadCode()`: load route code only. This triggers `resourcePage.load()` and never executes `prepare`.
- `preload()`: load both route code and prepared data. This triggers `resourcePage.load()` and eagerly starts each route `prepare`.

Use `preloadCode()` when you only want to warm the code path.

Use `preload()` when you want the next navigation to reuse both code and route-level prepared data.

- All URL parameters (simple key=value and filter operators like `price.gt=10`) are parsed into a single `filters` object.
- Equality is implicit: `field=value` maps to internal operator `eq`.
- The current active schema is exposed as `entry.activeQuerySchema` for tooling.
- Serialization is centralized via `buildCombinedSearch` (used by both `navigate` and `Link`).

### Defining a Schema

```ts
import { r } from '@plumile/router';
import { defineSchema, numberField, stringField } from '@plumile/filter-query';

const productFilters = defineSchema({
  page: numberField(),
  price: numberField(),
  title: stringField(),
});

const routes = [
  r({
    path: '/products',
    querySchema: productFilters,
    prepare: ({ filters }) => ({ page: filters.page?.eq ?? 1 }),
    render: () => null,
  }),
];
```

### Accessing Filters (Strongly Typed)

`useFilters` now requires the schema as a mandatory argument (dynamic discovery was removed). It returns a tuple `[filters, actions]` where `filters` is fully inferred from the provided schema and `actions` are strongly typed mutation helpers.

```tsx
import { useFilters } from '@plumile/router';
import { productFilters } from './schemas'; // assume you exported the schema above

function List() {
  const [filters, { set, remove, merge, clear }] = useFilters(productFilters);
  // All operators/values are type‑checked against the schema
  set('price', 'gt', 10); // => ?price.gt=10
  set('page', 'eq', 2); // => ?page=2 (page normalization will clamp <1 to 1)
  remove('price', 'gt'); // remove only that operator
  merge({ price: { between: [10, 20] }, title: { contains: 'ultra' } });
  clear(); // remove all active filters
  return null;
}
```

### Programmatic Navigation

```ts
context.navigate({ pathname: '/products', filters: { page: { eq: 3 } } });
```

### Page Normalization

The router clamps `page.eq < 1` to `1` synchronously and performs a history replace so the back stack is not polluted.

### Serialization Rules

`buildCombinedSearch` produces a leading `?` (or empty string) by:

1. Ordering simple query params (from schema order).
2. Appending filter operator segments (`field.operator=value`).
3. Using implicit equality (`field=value`).
4. Maintaining reference stability (no unnecessary object churn).

### Migration Note

The legacy query DSL (`q.*`, `parseTypedQuery`, `buildSearch`) has been removed. Replace any usage with a filter schema (`defineSchema`) and rely on `useFilters`, `navigate({ filters })`, and/or `buildCombinedSearch`.

### Performance & Stability

Filters cache: repeated navigations with semantically identical search strings reuse the same filter object reference, enabling cheap React renders.

### Example Link

```tsx
<Link to="/products" filters={{ page: { eq: 1 }, price: { gt: 10 } }}>
  Deals
</Link>
```

Renders: `/products?page=1&price.gt=10`.

### Manual Serialization Example

If you need to build a URL outside of navigation (e.g. constructing a sitemap entry):

```ts
import { buildCombinedSearch } from '@plumile/router';
// or: import buildCombinedSearch from '@plumile/router/lib/esm/routing/tools/buildCombinedSearch.js';

const search = buildCombinedSearch({
  filters: { page: { eq: 1 }, price: { gt: 10 } },
  querySchema: productFilters,
  query: { ref: 'promo' },
});
// => '?page=1&price.gt=10&ref=promo'
```

Guideline: If you need to derive lightweight projections (e.g. `const { page } = typed`), you can still destructure; but avoid spreading into a new object if you rely on reference equality downstream.

#### Inspection & Instrumentation

Activez l’instrumentation en développement pour exposer un bridge DevTools :

```ts
import {
  createRouter,
  createDevtoolsBridgeInstrumentation,
} from '@plumile/router';

const devtools = createDevtoolsBridgeInstrumentation();
const { context } = createRouter(routes, { instrumentations: [devtools] });
```

Vous pouvez aussi publier des marks et measures standard dans la Performance Timeline du navigateur :

```ts
import {
  createPerformanceTimelineInstrumentation,
  createRouter,
} from '@plumile/router';

const performanceTimeline = createPerformanceTimelineInstrumentation();
const { context } = createRouter(routes, {
  instrumentations: [performanceTimeline],
});
```

Exemple complet avec un routeur applicatif :

```ts
import {
  createPerformanceTimelineInstrumentation,
  createRouter,
  r,
} from '@plumile/router';

const routes = [
  r({
    path: '/',
    render: () => {
      return <div>Home</div>;
    },
  }),
];

const { context } = createRouter(routes, {
  instrumentations: [createPerformanceTimelineInstrumentation()],
});
```

Exemple recommandé en développement uniquement :

```ts
import {
  createPerformanceTimelineInstrumentation,
  createRouter,
} from '@plumile/router';

const instrumentations = [];

if (import.meta.env.DEV) {
  instrumentations.push(createPerformanceTimelineInstrumentation());
}

const { context } = createRouter(routes, { instrumentations });
```

Aucun bridge n’est publié en production. Vous pouvez chaîner plusieurs instrumentations (par exemple la console) :

```ts
import {
  createConsoleLoggerInstrumentation,
  createDevtoolsBridgeInstrumentation,
  createPerformanceTimelineInstrumentation,
} from '@plumile/router';

const instrumentations = [
  createDevtoolsBridgeInstrumentation(),
  createPerformanceTimelineInstrumentation(),
  createConsoleLoggerInstrumentation({ label: 'router' }),
];

createRouter(routes, { instrumentations });
```

Pour l’inspection visuelle (timeline, filtres, prepared data), installez la Plumile Router DevTools extension (voir [docs/router-devtools-extension.md](../../docs/router-devtools-extension.md)).
Pour des timings exploitables directement dans Chrome DevTools Performance, utilisez `createPerformanceTimelineInstrumentation()`.
Dans Chrome DevTools Performance, cela permet notamment de voir les événements `history`, `preload`, `snapshot` et les mesures `router:prepare`.

### Unified Query Schema & useFilters (Integration with @plumile/filter-query)

The router uses a single unified schema (`querySchema`) defined with `@plumile/filter-query` to describe allowed key/operator pairs. Plain `field=value` maps to the implicit equality operator (`eq`). Each filter segment is encoded as `field.operator=value` (with `eq` omitted).

```ts
import { r, useFilters } from '@plumile/router';
import { defineSchema, numberField, stringField } from '@plumile/filter-query';

const querySchema = defineSchema({
  page: numberField(),      // page.eq=2 => ?page=2 (page normalization clamps <1 to 1)
  price: numberField(),     // price.gt=10 => ?price.gt=10
  name: stringField(),      // name.contains=ultra => ?name.contains=ultra
});

export const routes = [
  r({
    path: '/items',
    querySchema,
    render: () => <Items />,
  }),
];

function Items() {
  const [filters, { set, remove, clear, merge }] = useFilters(querySchema);
  set('price', 'gt', 10);                 // => ?price.gt=10
  set('page', 'eq', 2);                   // => ?page=2
  remove('price', 'gt');                  // remove only that operator
  merge({ price: { between: [10, 20] } }); // descriptor-array form removed; use object patch
  clear();
  return null;
}
```

`navigate({ filters })` is available for programmatic updates. Serialization order: schema field keys first, then any extra non‑schema keys (if present). Operators follow deterministic ordering from `@plumile/filter-query`.

#### Typed Actions Cheat‑Sheet

```ts
const [filters, a] = useFilters(querySchema);
a.set('price', 'in', [10, 20, 30]); // array operators allowed when schema supports it
a.set('title', 'contains', 'ultra');
a.remove('price', 'in'); // remove only that operator
a.merge({ page: { eq: 3 }, price: { gt: 50 } }); // deep merge per field
a.clear(); // wipe everything (noop if already empty)
```

Navigation is skipped automatically if the structural filters object doesn't change (shallow compare) to avoid redundant history entries.

Diagnostics (unknown field/operator, invalid values, etc.) are accessible via `useFilterDiagnostics()`.

### ESLint Rule: no-direct-window-location-search

To encourage consistent usage of the query hooks, a custom rule is provided inside the router package to flag raw `window.location.search` access.

Add to your flat ESLint config:

```js
import noDirectWindowLocationSearch from '@plumile/router/lib/eslint-rules/no-direct-window-location-search.js';

export default [
  {
    plugins: {
      '@plumile-router/dx': {
        rules: {
          'no-direct-window-location-search': noDirectWindowLocationSearch,
        },
      },
    },
    rules: {
      '@plumile-router/dx/no-direct-window-location-search': 'warn',
    },
  },
];
```

Optional configuration:

```js
// allow some files (e.g. legacy bootstrap) to keep direct access
rules: {
  '@plumile-router/dx/no-direct-window-location-search': [
    'warn',
    { allowInFiles: ['legacy-entry.ts'] },
  ],
},
```

When triggered, replace patterns like:

```ts
const qs = window.location.search;
```

with:

```ts
const query = useQuery(); // raw key=value aggregation (simple); prefer useFilters() for structured access.
```

### Migration Guide (Legacy DSL → Unified Filters + Typed Hook Update)

1. Remove legacy imports of `q`, `parseTypedQuery`, `buildSearch`.
2. Define a schema with `defineSchema` from `@plumile/filter-query` and attach as `querySchema` on the route needing filters.
3. Replace (removed) `useTypedQuery()` usages with `useFilters(querySchema)` (schema argument now mandatory – breaking change vs earlier experimental auto‑discovery version).
4. Update navigation: `navigate({ filters: { page: { eq: 2 } } })` (unchanged pattern).
5. Update links: `<Link to="/x" filters={{ page: { eq: 2 } }} />`.
6. Replace any previous `merge([{ field, op, value }])` descriptor array calls with `merge({ field: { op: value } })` object patches.
7. Page defaults / normalization: rely on built-in clamp (`page < 1 -> 1`).
8. Remove any type tests referencing the DSL; rely on schema inference from `defineSchema`.
9. If you manually used `buildCombinedSearch({ schema })`, rename the option to `querySchema`.

### Removed & Changed APIs

Removed in favor of the unified model:

- `q.*` descriptor DSL (use `defineSchema`)
- `parseTypedQuery`
- `buildSearch` (use `buildCombinedSearch`)
- `useTypedQuery`

Changed (breaking):

- `useFilters()` → `useFilters(schema)` (schema argument required; no dynamic discovery)
- `merge` action signature: descriptor array → object patch (`merge({ field: { operator: value } })`)
- `buildCombinedSearch({ schema })` → `buildCombinedSearch({ querySchema })`

Use `@plumile/filter-query` for schema definitions and `useFilters(schema)` / `buildCombinedSearch` for runtime access & serialization.

### useQueryState Hook

`useQueryState(key, opts?)` creates a controlled binding between a single query parameter and component state.

```tsx
const [page, setPage] = useQueryState<number>('page');
// Increment page without pushing a new history entry
setPage(page! + 1, { replace: true });
```

Behavior:

- Reads from filters (implicit equality) and falls back to raw query key when not covered by schema.
- Allows `defaultValue` override in options and omits key when value equals default (with `omitIfDefault: true`).
- Pass `{ raw: true }` to force raw (string) source for incremental migrations.
- Uses unified serialization ordering (schema keys then extras; operators deterministic).

Options:
`{ defaultValue?, omitIfDefault?: boolean = true, replace?: boolean, raw?: boolean }`

### Data Preloading

```typescript
const route: Route<any, { user: User }, { id: string }> = {
  path: '/users/:id',
  prepare: async ({ variables }) => {
    const user = await fetchUser(variables.id);
    return { user };
  },
  render: ({ prepared, children }) => {
    if (!prepared) return null;
    return <UserLayout user={prepared.user}>{children}</UserLayout>;
  },
};
```

### Custom Route Rendering

```typescript
const route: Route<any, any, any> = {
  path: '/protected',
  render: ({ children, prepared }) => {
    if (!userIsAuthenticated()) {
      return <Redirect to="/login" />;
    }
    return <ProtectedLayout>{children}</ProtectedLayout>;
  },
};
```

### Programmatic Navigation

```tsx
import { useContext } from 'react';
import { RoutingContext } from '@plumile/router';

function MyComponent() {
  const router = useContext(RoutingContext);

  const handleClick = () => {
    router.history.push({ pathname: '/new-path' });
  };

  return <button onClick={handleClick}>Navigate</button>;
}
```

### Route Preloading

```tsx
import { useContext } from 'react';
import { RoutingContext } from '@plumile/router';

function MyComponent() {
  const router = useContext(RoutingContext);

  const handleHover = () => {
    // Preload code only
    router.preloadCode({ pathname: '/users/123' });

    // Preload code and data
    router.preload({ pathname: '/users/123' });
  };

  return (
    <Link to="/users/123" onMouseEnter={handleHover}>
      User Profile
    </Link>
  );
}
```

## TypeScript Support

The router is built with TypeScript and provides full type safety:

```typescript
import { Route, r } from '@plumile/router';

interface UserPageData {
  user: User;
  posts: Post[];
}

interface UserPageParams {
  id: string;
}

const userRoute = r<any, UserPageData, UserPageParams>({
  path: '/users/:id',
  prepare: ({ variables }) => {
    // variables.id is typed as string
    return fetchUserData(variables.id);
  },
  render: ({ prepared }) => {
    // prepared is typed as UserPageData | undefined
    if (!prepared) return null;
    return <UserPage user={prepared.user} posts={prepared.posts} />;
  },
});
```

## Browser Support

- Modern browsers that support ES2021
- React 18+
- Node.js 21+

## Validation Notes

- runtime navigation, resource preparation, query handling, and hook behavior are
  covered by behavioral tests in the package workspace
- type inference is also protected by dedicated type tests

## Limitations

- ESM-only package
- focused on client-side React routing
- intentionally decoupled from app-specific UI and product concerns

## License

Licensed under the terms specified in the package's LICENSE file.