@plumile/router
v0.1.38
Published
A modern, type-safe React router with code splitting, data preloading, and Suspense integration
Maintainers
Readme
@plumile/router
A modern, type-safe React router built with TypeScript that supports code splitting, data preloading, and React Suspense integration.
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
npm install @plumile/routerPeer Dependencies
This package expects react and react-dom to be available in your project
(React 18 or 19).
npm install react react-domModule 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 viatypesfield).
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
- Relay integration walkthrough: Relay guide
- Examples: Examples
- Migration strategies: Migration guide
- DevTools extension usage: DevTools documentation
Quick Start
1. Define Your Routes
import { Route, getResourcePage } from '@plumile/router';
const routes: Route<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
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
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
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(); |
| 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(routes: Route[])
Creates a router instance with the given route configuration.
Parameters:
routes: Array of route definitions
Returns:
context: Router context object for the React Context Providercleanup: Function to clean up router listeners
RouterRenderer
Renders the matched route component with Suspense support.
Props:
fallback?: ReactNode - Fallback UI while loading componentsenableTransition?: boolean - Enable React 18 transitionspending?: ReactNode - UI to show during transitions
Link
Navigation component that handles client-side routing.
Props:
to: string - Destination pathexact?: boolean - Exact path matching for active stateactiveClassName?: string - CSS class when link is activeclassName?: string - Base CSS classpreload?: boolean - Preload route on hoverreplace?: boolean - Replace current history entry
RoutingContext
React context that provides router functionality to components.
Route Configuration
Route<TPrepared, TVariables>
Route definition interface.
Properties:
path?: string - URL path patternchildren?: Route[] | Redirect[] - Nested routesresourcePage?: ResourcePage - Lazy-loaded componentprepare?: Function to preload datarender?: Custom render function
Redirect
Redirect configuration.
Properties:
path?: string - Source pathto: string - Destination pathstatus?: 301 | 302 - HTTP status code
Resource Management
getResourcePage(moduleId: string, loader: ResourcePageLoader)
Creates a resource for lazy-loading components.
Parameters:
moduleId: Unique identifier for cachingloader: Function that returns dynamic import
Returns:
ResourcePageinstance
ResourcePage
Manages lazy-loaded components with Suspense integration.
Methods:
load(): Promise - Load the componentget(): Component | undefined - Get loaded componentread(): Component - Read with Suspense (throws Promise if loading)
History Management
BrowserHistory
Browser history implementation.
Methods:
push(location): Navigate to new locationset(location): Replace current locationsubscribe(listener): Listen for navigation changes
Utilities
getMatchedRoute(routes, location)
Finds the matching route for a given location.
prepareMatch(match)
Prepares route data and components for rendering.
r<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. - All URL parameters (simple key=value and filter operators like
price.gt=10) are parsed into a singlefiltersobject. - Equality is implicit:
field=valuemaps to internal operatoreq. - The current active schema is exposed as
entry.activeQuerySchemafor tooling. - Serialization is centralized via
buildCombinedSearch(used by bothnavigateandLink).
Defining a Schema
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.
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
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:
- Ordering simple query params (from schema order).
- Appending filter operator segments (
field.operator=value). - Using implicit equality (
field=value). - 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
<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):
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,
});
// => '?page=1&price.gt=10'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 :
import {
createRouter,
createDevtoolsBridgeInstrumentation,
} from '@plumile/router';
const devtools = createDevtoolsBridgeInstrumentation();
const { context } = createRouter(routes, { instrumentations: [devtools] });Aucun bridge n’est publié en production. Vous pouvez chaîner plusieurs instrumentations (par exemple la console) :
import {
createConsoleLoggerInstrumentation,
createDevtoolsBridgeInstrumentation,
} from '@plumile/router';
const instrumentations = [
createDevtoolsBridgeInstrumentation(),
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).
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).
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
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:
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:
// 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:
const qs = window.location.search;with:
const query = useQuery(); // raw key=value aggregation (simple); prefer useFilters() for structured access.Migration Guide (Legacy DSL → Unified Filters + Typed Hook Update)
- Remove legacy imports of
q,parseTypedQuery,buildSearch. - Define a schema with
defineSchemafrom@plumile/filter-queryand attach asquerySchemaon the route needing filters. - Replace (removed)
useTypedQuery()usages withuseFilters(querySchema)(schema argument now mandatory – breaking change vs earlier experimental auto‑discovery version). - Update navigation:
navigate({ filters: { page: { eq: 2 } } })(unchanged pattern). - Update links:
<Link to="/x" filters={{ page: { eq: 2 } }} />. - Replace any previous
merge([{ field, op, value }])descriptor array calls withmerge({ field: { op: value } })object patches. - Page defaults / normalization: rely on built-in clamp (
page < 1 -> 1). - Remove any type tests referencing the DSL; rely on schema inference from
defineSchema. - If you manually used
buildCombinedSearch({ schema }), rename the option toquerySchema.
Removed & Changed APIs
Removed in favor of the unified model:
q.*descriptor DSL (usedefineSchema)parseTypedQuerybuildSearch(usebuildCombinedSearch)useTypedQuery
Changed (breaking):
useFilters()→useFilters(schema)(schema argument required; no dynamic discovery)mergeaction 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.
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
defaultValueoverride in options and omits key when value equals default (withomitIfDefault: 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
const route: Route<{ 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
const route: Route<any, any> = {
path: '/protected',
render: ({ children, prepared }) => {
if (!userIsAuthenticated()) {
return <Redirect to="/login" />;
}
return <ProtectedLayout>{children}</ProtectedLayout>;
},
};Programmatic Navigation
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
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:
import { Route, r } from '@plumile/router';
interface UserPageData {
user: User;
posts: Post[];
}
interface UserPageParams {
id: string;
}
const userRoute = r<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+
License
Licensed under the terms specified in the package's LICENSE file.
