@plumile/router
v0.1.84
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, 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(); |
| 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 definitionsoptions?: Optional configurationcontext?: Static context value or lazy initializergetContext?: Resolve a fresh context value per navigationinstrumentations?: Instrumentations invoked on router events
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 | HistoryLocation - Destination path (supportssearch/hash)filters?: object - Filters object serialized with the active query schemaquery?: object - Raw query params merged as non-schema keysexact?: boolean - Exact path matching for active stateactiveClassName?: string - CSS class when link is activeclassName?: string - Base CSS classpreloadOnMouseEnter?: boolean - Preload route on hoverpreloadOnMouseDown?: boolean - Preload route on mouse downhref?: string - Explicit href overridetarget?: string - Target attribute for the linkonClick?: (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 patternchildren?: Route[] | Redirect[] - Nested routesresourcePage?: ResourcePage - Lazy-loaded componentprepare?: 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:
preparemay be synchronous or asynchronous.- The router wraps its result in an internal prepared resource.
- Route components and
rendercallbacks only receive resolvedpreparedvalues. - If
prepareis still pending, the router suspends through React Suspense. - If
preparerejects, the error is rethrown to the nearest Error Boundary.
Example with synchronous prepare:
const routes = [
{
path: '/users/:id',
resourcePage: getResourcePage('UserPage', () => import('./UserPage')),
prepare: ({ variables }) => {
return { userId: variables.id };
},
},
];Example with asynchronous prepare:
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:
renderreceivespreparedonly after resolution;- the route component receives
preparedonly after resolution; - no
Promiseis ever exposed aspreparedto userland consumers.
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, 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 triggersresourcePage.load()and never executesprepare.preload(): load both route code and prepared data. This triggersresourcePage.load()and eagerly starts each routeprepare.
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 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,
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 :
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 :
import {
createPerformanceTimelineInstrumentation,
createRouter,
} from '@plumile/router';
const performanceTimeline = createPerformanceTimelineInstrumentation();
const { context } = createRouter(routes, {
instrumentations: [performanceTimeline],
});Exemple complet avec un routeur applicatif :
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 :
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) :
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).
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).
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<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
const route: Route<any, 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<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+
License
Licensed under the terms specified in the package's LICENSE file.
