Framework-agnostic routing core built on history and path-to-regexp: cancelable async navigation, an in-memory view stack and route guards.
English | 简体中文
Every committed navigation stores its resolved view in the router's in-memory viewStack. POP navigations land on the cached view through listen — nothing is re-matched or re-resolved.
import {create, listen} from '@native-router/core';
import {createBrowserHistory} from 'history';
const router = create(routes, createBrowserHistory(), resolveView);
const unlisten = listen(router, (view) => {
// Back/forward lands here instantly with the cached view
mount(view);
});viewStack is the SPA-navigation counterpart of the browser's bfcache. The browser snapshots whole documents so cross-document back/forward restores instantly; the router snapshots resolved views so same-document back/forward (pushState/POP) does too. The two layers are complementary and never overlap: a same-document navigation never enters the bfcache, and a bfcache restore does not fire popstate. Together with your data layer they stack as bfcache > viewStack > queryCache, outermost first — any restore short-circuits every inner layer with zero requests, so freshness is compensated at the edges (e.g. refetch-on-focus in the query layer).
Snapshots can outlive their validity — after a logout or an account switch, the previous account's resolved views are exactly what a back POP must not restore. invalidate(router) drops every snapshot at once: the currently rendered view is untouched (no re-resolve, no re-render), and the next back/forward re-runs the guards and loaders of the landed entry through the same lazy path as out-of-window entries.
import {invalidate} from '@native-router/core';
// After the session identity changed: keep rendering the current view,
// but never restore a snapshot of the previous account on back/forward.
invalidate(router);The session stack is serialized into history.state as a bounded tail window (maxStackDepth, default 100) and restored on create. Warm the window once after a refresh with initHistoryStack, and every in-window back/forward renders from cache with zero requests. Entries outside the window fall back to a single lazy re-resolve.
const router = create(routes, createBrowserHistory(), resolveView);
// After a refresh the stack was restored from the history.state window;
// re-resolve every reachable entry so in-window back/forward are zero-request
await initHistoryStack(router);resolveEntry runs the route guards (redirect/beforeLoad) and returns the terminal location together with its view task, so a link can prefetch exactly what a click would commit.
import {resolveEntry, commit, toLocation} from '@native-router/core';
const entry = await resolveEntry(router, toLocation(router, '/users/1'));
// entry.location — the terminal location, guards applied
// entry.task — the view task of the terminal target
const view = await entry.task; // prefetch / preview
commit(router, entry.task, entry.location); // commit like a click- Framework-agnostic: bring your own
resolveView, the view type (V) is yours — a string, a vdom, anything - Route matching via path-to-regexp: declaration order, layout routes without
path, index/fallback children withpath: '', strict trailing slashes, case-sensitive, nested params merged deep over shallow - Route guards: static
redirectand asyncbeforeLoadon every route level, run shallow → deep; more than 10 chained redirects reject withRedirectLoopError - Cancelable async navigation: a new resolve supersedes the in-flight one (
currentGuard);cancel()aborts it; a history POP cancels it too. A superseded or cancellednavigate()promise never settles — don'tawaita navigation that might be superseded. Superseding or cancelling also aborts the chain'sAbortSignal: guards (beforeLoadctx) and view loaders (ResolveViewContext) receive it asctx.signal, so their in-flight requests stop instead of only having results dropped;preloadresolutions are shared and therefore never aborted - Navigation API:
navigate,refresh,go/forward/back,commit/commitReplace,createHref,getParams,match,toLocation,resolve,resolveTo invalidate(router): drop the session view snapshots in one call — the current view stays rendered (no re-resolve, no re-render) and the next back/forward re-resolves through the guards; the typical call site is right after a logout/account switch, so a POP cannot render the previous account's data or bypass guards that already ran- Search validation via Standard Schema: a
searchschema on any route level (zod/valibot/arktype, no hard dependency), parsed withparseSearch/parseSearchSync; failures throwSearchError preload(router, to, {ttl}): resolve a target through the guards ahead of time, sharing one task across concurrent callers (in-flight dedup) with a TTL, default 30s; consumed entries are dropped on commiterrorHandlerhook turns resolve failures into fallback views- Errors:
NativeRouterError,NotFoundError,RedirectLoopError,SearchError - Tree-shakable:
sideEffects: false
- Routes match in declaration order and the first match wins — there is no sorting by specificity.
- A route without
pathis a layout: it matches the empty prefix and its children are matched against the full remaining path. - A leaf child with
path: ''matches whatever is left under its parent. Declared after its concrete siblings it serves as the parent's index route (and as the fallback for paths unmatched under the parent). - Trailing slashes are significant:
/users/does not match/users. - Matching is case-sensitive.
- Params of nested levels are merged deep over shallow (
mergeMatchedParams): for/:id+/posts/:id, the deeperidwins.
Declare a search validator on a route level and parse location.search with it in your resolveView. Any Standard Schema validator works — zod, valibot and arktype all implement the interface — so the core keeps zero extra runtime dependencies.
import {create, parseSearch} from '@native-router/core';
import {z} from 'zod';
const listSearch = z.object({page: z.coerce.number().default(1)});
const router = create(
{path: '', children: [{path: '/list', search: listSearch}]},
createBrowserHistory(),
// Your resolveView consumes route.search itself: parse the location
// search, then resolve the view from the parsed output
async (matched, {location}) =>
renderList(await parseSearch(matched.at(-1)!.route.search!, location.search))
);parseSearchInput(search)degrades a query string into a plain object — single-valued keys are strings, keys repeated in the query are arrays — which is also the input every schema validatesparseSearch(schema, search)resolves the schema output (async validators are awaited);parseSearchSyncis the render/guard-time flavor and rejects async validators with a clear error- A rejected validation throws
SearchError(aNativeRouterError) carrying the rawsearchand the reportedissues— route it through yourerrorHandlerlike any other resolve failure
npm i @native-router/coreimport {create, listen, navigate} from '@native-router/core';
import {createBrowserHistory} from 'history';
const router = create(
{
path: '', // layout level: children match the full remaining path
children: [{path: '/'}, {path: '/users/:id'}]
},
createBrowserHistory(),
// Resolve the matched levels into a view of your own
async (matched, {location}) => renderApp(matched, location),
{baseUrl: '', errorHandler: (e) => renderError(e)}
);
const unlisten = listen(router, (view) => {
// Called on every navigation; POP hits the cached view directly
mount(view);
});
await navigate(router, '/users/1'); // guards run, then commit pushes the viewAny extra route fields (e.g. component, data) pass through to your resolveView untouched — that is how @native-router/react builds its conventions on top of the core.
@native-router/core (this package) and @native-router/react live in two independent repositories; clone them side by side. The react repo's vitest config aliases @native-router/core to ../core/src, so its tests exercise the latest core source without any install-level linking.
pnpm install
pnpm test # core tests
pnpm build # build core distReact's type check and production build resolve core from the npm registry, so publish core first when react needs to consume unpublished core APIs.