@symbo.ls/router
v3.14.617
Published
Client-side router plugin for DOMQL. Handles route matching, navigation, scroll management, and state updates within DOMQL elements.
Downloads
4,849
Maintainers
Keywords
Readme
@domql/router
Client-side router plugin for DOMQL. Handles route matching, navigation, scroll management, and state updates within DOMQL elements.
Install
npm install @domql/routerBasic Usage
import { router } from '@domql/router'
// Navigate to a path
router('/about', element)
// With state and options
router('/dashboard', element, { userId: 1 }, { scrollToTop: true })
// Replace the current history entry instead of pushing a new one
router('/login', element, {}, { replace: true })Define routes on your DOMQL element:
const App = {
routes: {
'/': HomePage,
'/about': AboutPage,
'/contact': ContactPage,
'/*': NotFoundPage
}
}Dynamic Route Params
Match routes with :param segments. Enable with useParamsMatching: true.
const App = {
routes: {
'/': HomePage,
'/:id': UserPage,
'/:category/:slug': ArticlePage,
'/*': NotFoundPage
}
}
router('/users/42', element, {}, { useParamsMatching: true })
// state.params = { id: '42' }
router('/tech/my-article', element, {}, { useParamsMatching: true })
// state.params = { category: 'tech', slug: 'my-article' }Exact segments score higher than params, so /about will match a literal /about route before /:id.
Query String Parsing
Query parameters are automatically parsed and stored in state.
router('/search?q=hello&tag=a&tag=b', element)
// state.query = { q: 'hello', tag: ['a', 'b'] }Duplicate keys are collected into arrays.
Guards / Middleware
Run guard functions before navigation. Return true to allow, false to block, a string to redirect, or { route: '<key>' } to choose the page to show (see Choosing a route from data). A guard can return its answer directly or as a promise.
const authGuard = ({ element }) => {
if (!element.state.root.isLoggedIn) return '/login'
return true
}
const roleGuard = ({ params }) => {
if (params.section === 'admin') return false
return true
}
router('/dashboard', element, {}, {
guards: [authGuard, roleGuard]
})Guard functions receive a context object:
{
pathname, // full pathname
route, // matched route key
params, // dynamic route params
query, // parsed query string
hash, // URL hash
element, // DOMQL element
state // navigation state
}The guards run in order, and the first false, path or { route } ends the run: the guards after it do not run.
- A guard that returns its answer directly (not a promise) is not awaited. When every guard does so, the router decides and renders the page in the same run as the
router()call, as it does without guards. The first route then renders insidecreate(), so brender hydration adopts the page and not only the shell. - A guard that returns a promise is awaited. The guards after it run when it settles, and the page renders after them.
- A newer navigation of the same element wins over one that still waits on a guard. When the guard settles, the older navigation ends: it renders nothing, writes no history entry and follows no redirect. This includes a Back or Forward that waits on its guard: the History stays with the newer navigation. When a lazy route's page replaces its placeholder, that render completes the placeholder's navigation. It is not a newer navigation.
- A guard that throws, or a promise that rejects, rejects
router(), and the page does not change.
Choosing a route from data
The router matches an address by its shape. /:lang matches /en (a language's home) and /volunteer (a page made in an editor) alike. To choose between two routes of the same shape from data, a guard returns the key of the route to show:
routes: {
'/:lang': Home,
'/:page': EditorPage
}
const pageOrLanguage = ({ route, params, element }) => {
if (route !== '/:lang') return
const languages = element.context.designSystem.languages || []
return languages.includes(params.lang) ? undefined : { route: '/:page' }
}
// routerOptions: { guards: [pageOrLanguage] }- The address stays. The chosen route renders as if the address had matched it:
state.routeis the chosen key, andparamsare read from the address with the chosen pattern (/volunteerwith/:pagegives{ page: 'volunteer' }). - The guard can decide from data that is not in the browser yet: return a promise of
{ route }(it is awaited, as any guard's promise). - A call can choose too:
el.router('/volunteer', root, {}, { route: '/:page' }). Only the call's own options count;routein the app's router options is ignored. - A key that the routes do not have renders nothing, as an address that no route matches (
onNotFoundruns when you set it). - A lazy route chosen this way renders the same chosen route when its group loads.
- Routes can also change at run time: the router reads
element.routeson each navigation, so a route added to it (el.routes['/new'] = Page) matches from the next navigation on.
Guards on Back / Forward
Guards in the app's router options (config.js) also run on Back and Forward. By then the browser has already moved to the other history entry, so the router puts it back:
- A guard that returns
falsekeeps the page, and the address returns to the page on screen: the router moves the History back by the same number of entries (history.go), so no entry is added or lost. Thepopstatethat this move causes is ignored: no guard runs and nothing renders again. When the entry reached was not written by the router (rawhistory.pushState, a plain#fragmentlink), its distance is unknown. The router then rewrites that entry with the address of the page on screen. - A guard that returns a path rewrites the entry that was reached (
replace) and renders the path there. The address no longer shows the refused URL. The first render does the same: a deep link to a URL that a guard redirects shows the target URL. - A guard that refuses an in-app navigation (
router(),Link) does the same as before: no history write.
To know where each entry sits, the router adds one key to the state of each entry it writes: history.state.__smblsRouter = { key, index, chain }. The key is merged into a copy of the state you pass. Your keys stay, and your object is not changed. A state that is not a plain object is written as is. Only apps with guards or scrollRestoration: 'restore' get the key, and the router writes no extra entry for it.
404 Handling
Provide an onNotFound callback for unmatched routes:
router('/unknown', element, {}, {
onNotFound: ({ pathname, route, element }) => {
console.warn(`No route found for ${pathname}`)
}
})You can also define a /* wildcard route as a catch-all fallback.
Without either, the router renders no URL that it does not route:
- An in-app navigation to such a URL writes no history and renders nothing.
- Back or Forward to such a URL (an entry that other code wrote, or a route that a new deploy removed) puts the address back to the page on screen. The router rewrites that entry in place and does not jump back, so Back never gets stuck on it.
- With
/*the not-found page renders under the URL, andonNotFounddecides itself. Neither of them changes.
Custom Router Element
Use customRouterElement in your config.js to route pages into a specific element within the component tree instead of the root. This is useful for persistent layouts where only the content area changes between routes.
// symbols/config.js
export default {
router: {
customRouterElement: 'Folder.Content'
}
}The value is a dot-separated path resolved from the root element. For example, 'Folder.Content' means the router will find root.Folder.Content and render page content inside it.
Every navigation resolves it: the first render, Back and Forward, app.navigate, Link, and el.router(path, el.getRoot()). A call that passes another element than the root renders into that element. While the target element is not rendered, the root renders the page.
This allows you to define a layout once in the / (main) page and have sub-pages render inside a specific container:
// pages/main.js — defines the persistent layout
export const main = {
extends: 'Layout',
Folder: {
Content: {
// default content for '/' route
}
}
}
// pages/team.js — only defines content (rendered inside Folder.Content)
export const team = {
state: 'team',
extends: 'Grid',
childExtends: 'TeamItem',
childrenAs: 'state',
children: (el, s) => s.data,
}Keep the Page
By default the router swaps the page whenever the pathname changes, also when the new address matches the same page. Two options keep the page on screen and change only the address and the route state. Without them nothing changes.
// symbols/pages/index.js: every page again under a language prefix
export default {
'/texts': texts,
'/:lang/texts': texts
}keepSamePage
// a language switch: the words change in place, then the address follows
el.router('/en/texts', el.getRoot(), {}, { replace: true, keepSamePage: true, scrollToTop: false })- The page swaps only when the new address matches another page (another route value). When it matches the same page object, under another pathname, another route key (
/texts→/:lang/texts) or other params, the page stays mounted: its elements, their state, the focus and whatever is open stay. - The navigation still writes the history entry, updates the route state (
route,routePath,params,query,hash) and firesonRouteChanged, as a query-only navigation does. The scroll follows the usual options (scrollToTop, hash targets). - The page is not built again, so it must read what changes from the route state (
s.root.params,s.root.route). - No
onRouteExitand no view transition run for a kept page. - Set it per navigation (
el.router,app.navigate, a Link'srouterOptions) or for the whole app inconfig.js(router: { keepSamePage: true }). A call can turn it off withkeepSamePage: false.force: truestill swaps.
render: false
// the address and the route state change; the page on screen stays as it is
el.router('/en/texts', el.getRoot(), {}, { replace: true, render: false })- The navigation writes the history entry and the route state, and fires
onRouteChangedwithoptions.render === false. It mounts nothing, scrolls nothing, runs noonRouteExitand no view transition, and loads no lazy group.forcedoes not change this. - Use it when the page on screen already is the page of the new address: a language switch that changed the words in place, or an address that must lose a one-time token (an auth e-mail link) before the first render. Called before the first render, it writes the address, and the first render then renders the page as usual.
- When the new address matches the page on screen, the router records it: a later navigation to the same address keeps the page. When it matches another page, the page on screen stays, and the next navigation to that address renders the other page.
- Guards run as for any navigation. A guard that refuses writes nothing. A guard that redirects renders its target.
- Per navigation only: the app's
routeroptions cannot turn rendering off.
Lazy Routes
A project can keep code its visitors never download (an admin, an editor) in ONE complete context. Declare the group in context.js and name it on its routes; a bundler that splits dynamic imports (parcel under smbls build / smbls start) puts the group in its own chunk, which nothing requests until a route of the group opens.
// symbols/context.js
import * as components from './components/index.js'
import * as functions from './functions/index.js'
import pages from './pages/index.js'
export default {
components,
functions,
pages,
// The admin: its own chunk, loaded on its first route.
lazy: { admin: () => import('./admin/index.js') }
}The group is a folder laid out like the project: an entry module and its own components/, pages/, functions/, methods/ and snippets/, with their own index.js barrels.
// symbols/admin/index.js
import * as components from './components/index.js'
import * as functions from './functions/index.js'
import pages from './pages/index.js'
export default { components, functions, pages }Each route whose page lives in the group gets a PLACEHOLDER: a page with lazy: '<group>'. It renders while the group loads, so it is also the route's loading page — its own metadata, size and error note:
// symbols/pages/index.js
import { main } from './main.js'
import { adminLoading } from './adminLoading.js'
export default {
'/': main,
'/admin/*': adminLoading, // or one entry per admin route
'/*': notFound
}
// symbols/pages/adminLoading.js
export const adminLoading = {
lazy: 'admin',
extends: 'Page',
minHeight: '100dvh',
metadata: (el) => ({ title: el.call('polyglot', 'admin.title') }),
LoadError: {
if: (el, s) => s.lazyStatus === 'error',
role: 'alert',
text: '{{ admin.loadError | polyglot }}'
},
ReloadNote: {
if: (el, s) => s.lazyStatus === 'reload',
role: 'alert',
text: '{{ admin.reloadToLoad | polyglot }}'
}
}What happens on a navigation to /admin/users:
- The guards run as usual. A navigation they refuse never downloads the group.
- The URL and the history entry are written and the placeholder renders (with the usual scroll to the top). The group starts to load — once per app, whoever asks first.
- The group's pages, components, functions, methods and snippets are registered:
el.call('adminFn')resolves,AdminTable: {}renders the component,el.adminMethod()works. Every placeholder of the group in the route map is replaced IN PLACE by the group's page of the same route (or removed), and the group's other routes are added. A URL the group does not define falls through to/*. - The router renders the route again in the SAME history entry: no new entry, no scroll, no guards (they already passed) and no
onRouteExitfor the placeholder. A hash in the URL scrolls to its target now that it exists.
Details:
Status. The placeholder renders with its own state holding
lazyStatus:'loading', then'error'when the load fails,'reload'when only a page reload can load the group (see Retry), or'waiting'while the group'swhensays false. Read the app's state throughs.root. A placeholder whosestateis a path string (bound to app state) gets nolazyStatus. The framework writes no copy: the failure is logged, the note is yours.Retry. A failed load is forgotten. The next navigation to a route of the group (the same URL included) or the next
loadLazycall calls the group's loader again; a placeholder on screen flips back to'loading'and then renders its page.A retry loads again under smbls's Parcel. A browser and a bundler can both keep a failed dynamic import: parcel's production runtime hands the same rejected
import()back on every later call, and Chrome answers a secondimport()of a URL whose fetch failed with the same failure, without a request.smbls buildandsmbls startrun parcel with a runtime that lifts both: after a failed load the nextimport()of that file loads it again, under a fresh address (<chunk>.js?smbls-retry=<n>), and only when something asks — a failure never starts a load by itself. So alazygroup loads on the next navigation orloadLazyafter a failure, with no page reload. A project.parcelrcthat sets its ownruntimeskeeps them, and loses this.The attempt number. The router calls the loader with the attempt (
0,1, …):admin: (attempt) => …. A build whose loads the browser keeps after a failure (vite: nativeimport()of the same hashed URL) can use it to ask for a fresh address.When a retry still cannot load. When the loader hands back the import that already failed (the same promise, or a new promise that fails with the same error object), the router does not pretend to retry: the placeholder gets
lazyStatus: 'reload', the console says that a reload is needed, andloadLazyrejects with that error. Show a reload action for'reload', not a retry.'reload'is not final: a later attempt whose loader hands back a new import loads the group.Waiting for the app (
when). Declare a group as{ load, when }to load it only once the app is ready:lazy: { admin: { load: () => import('./admin/index.js'), // a Worker that serves the admin chunk only with a staff key the app asks for first when: (el) => el.call('staffReady') } }The router asks
when(el)before each load. A promise holds the load until it resolves (the placeholder shows'loading').falserequests nothing: the placeholder showslazyStatus: 'waiting',loadLazyrejects with an error whosecodeis'LAZY_WAITING', and the next navigation to a route of the group or the nextloadLazyaskswhenagain. Awhenthat throws or rejects is a failure ('error').smbls push(frank) callsloadonly;whenis a browser gate.onRouteChangedfires for the placeholder and again for the loaded page.options.lazytells them apart:{ group: 'admin', status: 'loading' }, then{ group: 'admin', status: 'loaded' }; the loaded render keeps the navigation's own options (initialRender, the popstateevent). A focus manager or analytics hook can skipstatus: 'loading'.Back/Forward and deep links work like any navigation: a popstate onto a route whose group has not loaded renders the placeholder, loads, and renders the page without writing history.
Timing.
el.router()/app.navigate()resolve once the placeholder is on screen. To land on the page itself,await el.call('loadLazy', 'admin')before navigating.Preload.
el.call('loadLazy', 'admin')loads a group ahead of its route — e.g. on hover of the admin link. It returns a promise that resolves once the group is registered (at once when there is nothing to load: a published payload already carries every group, so the call works unchanged on the platform), and rejects when the load fails (the next call calls the loader again). A placeholder on screen renders its page when anyone loads the group.Names are add-missing. A group name the app already has (its own, a shared library's or a built-in) keeps the app's value and is reported in the console during development.
frankrefuses the same collision atsmbls push; rename it.Built elements keep their shape. An element BUILT before its group loaded never takes the group's component later (as with any late registration — re-shaping a live element would drop its state and focus). The route's page always renders after its group, and an
if:-gated element takes the component on its first show; anything else that renders a group component must render afterloadLazyresolves. During development the load names every element that was built too early.Opt-in. Without
context.lazyalazykey on a route value is an ordinary prop and nothing above runs.Publishing.
smbls push(frank) merges every group into the canonical sections: the platform payload is the complete project, with nolazyand no placeholder, so the editor and served pages work as before.smbls fetchinto a fresh folder therefore writes the project eagerly; the split lives in your source tree.frank-audittreats the group folder as discovered (no FA006 / FA008 / FA001 / FA801 for the layout above).Prerendering. brender (
smbls build) builds a lazy route as what the render shows when it ends. Withoutbrender.lazyGroupsthe render does not wait for the group: the route is built as the group's page when the group loaded during the render (its loader settled in time — the context the build loads declareslazy, e.g. throughbrender.context), else as its placeholder. In the first case the page names the group and preloads its chunk (see below), so the browser renders the same page and adopts it. symbols.jsonbrender.lazyGroupsmakes the render wait: the route is always built as the group's real page —'load'for every group, or the group names (lazyGroups: ['texts']):{ "brender": { "context": "symbols/context.js", "lazyGroups": ["texts"] } }The build loads the group before it renders the route. It reads
lazyfrom the context modulebrender.contextnames (orsymbols/context.js), so a group must be declared there. Itswhenis asked in the build too: false builds the placeholder (a staff-only group stays out of the built pages). A built page that shows a group's page (either way) names the group (<meta name="brender-lazy" content="texts">) and preloads the group's chunk (<link rel="modulepreload">, after the import map, found by the chunk's source map;build.modulePreload: "strict"fails the build when it cannot find one). In the browser, the app reads that tag intocontext.lazyFirstView: its first navigation loads the group and renders its page, and the takeover adopts the built markup. The group'swhenstill gates that load: false shows the placeholder over the built page. Use it for PUBLIC groups: their pages leave the main bundle and still arrive built.The first view (
lazyFirstView).context.lazyFirstView—true, or an array of group names — makes the app's FIRST navigation onto a route of such a group load the group and render its page, instead of the placeholder while the group loads. Guards, URL and history are the navigation's own; the content stays empty until the group is in. A load that fails, or awhenthat says false, shows the placeholder with thatlazyStatus(the same navigation does not try again). Later navigations behave as described above. A pre-rendered page sets it for the groups whose page it shows.
Scroll Restoration
On back/forward the browser restores its own scroll offset (history.scrollRestoration = 'auto') before the router renders. An app that positions the page itself, such as a feed that focuses a view under a sticky header, then jumps to the old offset and corrects it, which shows as a flicker. Set scrollRestoration on router in your config.js to take the restoration over:
// symbols/config.js
export default {
router: {
scrollRestoration: 'restore' // 'restore' | 'manual' | 'auto'
}
}- Unset (the default): the framework never reads or writes
history.scrollRestoration. The browser default stays. 'restore': the router keeps the scroll offset of each history entry and puts the page back there on Back and Forward, after the route has rendered. When the page is not yet tall enough for the offset (its data is still loading), the page shows its top. When the page is tall enough, the router moves it to the offset in one step. AfterscrollRestoreTimeoutms (default1000) it goes as far down as the page reaches. The visitor's own scroll (wheel, touch, key, pointer) or a newer navigation stops the wait. A new navigation still scrolls to the top (scrollToTop). The offsets are kept in memory and insessionStorage, so a reload, or a return from another site, puts the page back too. An entry with no saved offset gets the usual popstate behaviour.'restore'setshistory.scrollRestorationto'manual'like the next value, so the browser does not restore the offset first.'manual': the browser stops restoring the offset. The app positions the page.'auto': sets the browser default explicitly.- It is applied once, when
create()builds the app, andapp.destroy()gives back the value found before the app. Two apps in one realm are safe in any destroy order: the newest live app decides, and the found value returns when the last one is destroyed. - The browser stores the mode on each history entry, and a pushed entry inherits it.
destroy()therefore rewrites the current entry only: entries pushed while the app lived keep'manual'. - Any other value logs a warning and changes nothing. A realm whose
Historyhas noscrollRestoration, or is not fully active (a detached iframe), is skipped without an error. - Calling
router()directly never toucheshistory.scrollRestoration. Do not write it from project code.
Page Transitions (View Transitions)
Set transition on router in your config.js to animate every page switch with the browser's View Transition API:
export default {
router: {
transition: true // true | { name, types, swapBudget, timeout } — unset (off) by default
}
}Per navigation, the same option overrides the app value: el.router(path, el.getRoot(), {}, { transition: false }), app.navigate(path, { transition: { types: ['back'] } }), or a Link's routerOptions.
- The router writes the history entry first, as always. Then it runs the rest of the navigation inside
document.startViewTransition: the route state, the page swap, the scroll (incl.scrollRestoration: 'restore'and hash targets) andonRouteChanged. The old snapshot is the page as it was. The new snapshot has everything the navigation changed, also what your shell updates from the route state oronRouteChanged(an active nav indicator).router()resolves after that step. - Back and Forward transition too.
name: aview-transition-namethe routed element (the element whosecontentthe router swaps) carries during the transition only. Use it to animate the page area as its own group while the chrome around it stays still.types: active view-transition types (for:active-view-transition-type()anddesignSystem.viewTransition[...].types). An engine without types runs the transition untyped.- No transition (the plain swap, in the same run and order as without the option): when the browser has no
startViewTransition, underprefers-reduced-motion: reduce, in a hidden tab, for the first route, for a navigation that keeps the page (query-only, same-path hash,keepSamePage,render: false), when the page that leaves is a lazy placeholder, withtransition: false, and for a navigation that starts while another one's swap runs (a page that rewrites its URL while it mounts). - A navigation that swaps while a transition runs ends it (
skipTransition). A transition that still waits for its old snapshot mounts nothing: the newer navigation owns the page, so a page is never mounted twice. onRouteExitruns first, then the transition.- A transition never holds the page. While it runs, its overlay takes every pointer event, and its animations advance only in the frames the main thread leaves free. Two bounds end it early (
skipTransition; the new page shows at once):swapBudget(ms, default100): the swap step ran longer than this (from the update callback toupdateCallbackDone, so effects that the mount flushes count too). The user already waited on a frozen page, so the router does not animate after it.timeout(ms, default1000): the transition still runs this long afterready.falseturns a bound off. A bound changes nothing when no transition runs.
Shared elements morph when both pages give them the same name with the viewTransitionName prop (viewTransitionClass groups several):
PageTitle: { tag: 'h1', viewTransitionName: 'pageTitle' }Define the snapshot animations in the design system (designSystem/viewTransition.js), with keyframes from designSystem/animation.js and timing tokens:
export default {
root: { old: 'fadeOut B defaultBezier', new: 'fadeIn B defaultBezier' }, // the page
pageTitle: { group: 'B defaultBezier' }, // a named element
page: { types: { back: { new: 'fadeIn C' } } } // only for type `back`
}A key is a view-transition name (root, *, or .class). The parts group, imagePair, old and new become ::view-transition-<part>(<name>). A part value is an animation shorthand or an object of flat props, resolved as on an element. The rules apply under prefers-reduced-motion: no-preference; under reduce the same pseudo-elements get animation: none. A scoped app (a module inside a shell) emits its own names, never root or *.
Options
| Option | Type | Default | Description |
|---|---|---|---|
| level | number | 0 | Route nesting level (which path segment to match) |
| pushState | boolean | true | Write the navigation to browser history (false writes nothing) |
| replace | boolean | false | Rewrite the current history entry (history.replaceState) instead of pushing a new one — redirects, canonical-URL rewrites, filter/query changes that must not stack Back entries. Ignored when pushState is false |
| initialRender | boolean | false | Whether this is the initial page render |
| scrollToTop | boolean | true | Scroll to top after navigation |
| scrollRestoration | 'restore' \| 'manual' \| 'auto' | unset | Create-time router option, not per call: 'restore' puts each history entry back where it was scrolled on back/forward; 'manual' / 'auto' set the browser's own restoration. See Scroll Restoration |
| scrollRestoreTimeout | number | 1000 | With scrollRestoration: 'restore': the longest wait, in ms, for the page to be tall enough for its saved offset |
| scrollToNode | boolean | false | Scroll within the element node |
| scrollNode | Element | the app's own document.scrollingElement | Node to scroll. The default is the viewport scroller of the document the app renders into (an app in an iframe scrolls the iframe's document); null scrolls nothing |
| scrollToOffset | number | 0 | Offset when scrolling to hash anchors |
| scrollToOptions | object | { behavior: 'smooth' } | Options passed to scrollTo() |
| useFragment | boolean | false | Use fragment tag for content |
| updateState | boolean | true | Update element state on navigation |
| contentElementKey | string | 'content' | Key for the content element slot |
| removeOldElement | boolean | false | Remove old content element before setting new |
| useParamsMatching | boolean | false | Enable dynamic :param route matching |
| keepSamePage | boolean | false | Keep the page on screen when the new address matches the same page (route value); only another page swaps. Per call or app-wide. See Keep the Page |
| render | boolean | true | false writes the address and the route state and keeps the page: no swap, scroll, exit or transition. Per call only. See Keep the Page |
| force | boolean | false | Swap the page even when the same page and route are on screen (a page definition changed in place). Never with render: false |
| guards | function[] | undefined | Array of guard/middleware functions. See Guards |
| route | string | unset | Per call: render this key of the routes for the address instead of the address match (the address stays). See Choosing a route from data |
| onNotFound | function | undefined | Callback when no route matches |
| exitTimeout | number | 300 | Longest wait, in ms, for an onRouteExit handler before the page swaps anyway. See Exit motion |
| transition | boolean \| { name?: string, types?: string[] } | unset (off) | Run the page swap inside document.startViewTransition. App-wide in router, or per call (false turns it off for one navigation). See Page Transitions |
| mountOptions | object | unset | Create options for building the page, over the app's own context.domqlOptions (the page is always created with those). { mountYield: 10 } builds the page in 10 ms chunks; smbls passes it for the first render. Once the page is built its later creates (a reconcile, an if:) use the app's options again. A page built in chunks finishes after router()'s synchronous part: the scroll, the hash target and onRouteChanged run after its last chunk, and router() resolves then. The first render skips its scroll-to-top at the origin only when its page is built in chunks |
| holdPage | (page) => Promise \| null | unset | The page's own hooks (onCreate … onRender) and the steps after the swap (scroll, hash target, onRouteChanged) wait for the promise it returns; null holds nothing. Per call |
| firstView | { committed, commit, page } | unset | What smbls passes for an app's first navigation (its first render in short tasks). Before commit settles, the page is built into the app root while the root is still outside the document, and the page's own hooks wait for commit; the router records the page as firstView.page. After the commit (the route waited on a guard or a lazy group), a page built in chunks into an empty slot is built outside the document and inserted once, at its last chunk. Per call; not for app code |
Exported Utilities
getActiveRoute(level, route)
Returns the active route segment at the given nesting level.
import { getActiveRoute } from '@domql/router'
getActiveRoute(0, '/users/42') // '/users'
getActiveRoute(1, '/users/42') // '/42'parseQuery(search)
Parses a query string into an object.
import { parseQuery } from '@domql/router'
parseQuery('?page=1&sort=name') // { page: '1', sort: 'name' }matchRoute(pathname, routes, level)
Matches a pathname against a routes object. Returns { key, content, params }.
import { matchRoute } from '@domql/router'
const routes = { '/': Home, '/:id': Detail, '/*': NotFound }
const result = matchRoute('/42', routes)
// { key: '/:id', content: Detail, params: { id: '42' } }parseRoutePattern(pattern)
Parses a route pattern string into segments, param definitions, and wildcard flag. Results are cached.
runGuards(guards, context)
Runs an array of guard functions in order (see Guards). Returns a promise of true, false, a redirect path string, or a { route } choice.
resolveRouterElement(root, path)
Resolves a customRouterElement path ('Folder.Content' or ['Folder', 'Content']) from the root element. Returns null while a part of the path is missing.
saveScrollPosition(window)
Records the scroll offset of the history entry on screen, for scrollRestoration: 'restore'. smbls calls it on pagehide and when the page is hidden. Call it yourself only when you use the router without smbls create().
loadLazy(name)
Loads a lazy group (see Lazy Routes). Call it on an element: el.call('loadLazy', 'admin'). Resolves once the group is registered, at once when there is nothing to load; rejects when the load fails (see Lazy Routes), or with code: 'LAZY_WAITING' while the group's when says false.
Events
The router triggers an onRouteChanged event on the element after navigation completes (for a lazy route: after the placeholder renders and again after the page does — options.lazy names the phase). Listen for it in your element definition:
const App = {
routes: { ... },
onRouteChanged: (element, state, context, options) => {
console.log('Route changed:', element.state.route)
}
}Exit motion (onRouteExit)
onRouteExit runs on the same element BEFORE the router swaps the page, with the page that is leaving. Return a promise and the router waits for it before it writes history, updates the route state and mounts the new page:
const App = {
routes: { ... },
onRouteExit: (element, state, context, exit) => {
// exit = { page, from, to, pathname, params, query, hash, signal, timeout }
const anim = exit.page.node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 150 })
exit.signal?.addEventListener('abort', () => anim.cancel())
// A cancelled animation rejects `finished` — treat it as a finished exit,
// or every cut-short exit logs an async event error.
return anim.finished.catch(() => {})
},
// …and the entrance, after the swap:
onRouteChanged: (element) => { /* animate element.content.node in */ }
}exit.pageis the leaving page element (element[contentElementKey]);from/toare the route keys;pathname,params,query,hashdescribe the new URL.- The wait is bounded by
exitTimeout(default300ms). On the timeoutexit.signalaborts and the swap goes ahead; a rejected promise is a finished exit. A slow or broken exit never blocks navigation. - No exit runs (and nothing is awaited) for the first route of an element, for a navigation that keeps the page (query-only, same-path hash,
keepSamePage,render: false), underprefers-reduced-motion: reduce, or in a tab that is not visible. Without a handler the router behaves exactly as before: the swap happens in the same synchronous run. - A navigation that starts while an exit runs takes over. The older one never mounts its page and writes no history entry; a newer navigation that leaves the same page joins the running exit instead of starting a second one.
- A handler that returns nothing (not a promise) runs, and the swap follows at once.
License
MIT
