npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/router

Basic 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 inside create(), 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.route is the chosen key, and params are read from the address with the chosen pattern (/volunteer with /:page gives { 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; route in the app's router options is ignored.
  • A key that the routes do not have renders nothing, as an address that no route matches (onNotFound runs 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.routes on 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 false keeps 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. The popstate that this move causes is ignored: no guard runs and nothing renders again. When the entry reached was not written by the router (raw history.pushState, a plain #fragment link), 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, and onNotFound decides 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 fires onRouteChanged, 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 onRouteExit and no view transition run for a kept page.
  • Set it per navigation (el.router, app.navigate, a Link's routerOptions) or for the whole app in config.js (router: { keepSamePage: true }). A call can turn it off with keepSamePage: false. force: true still 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 onRouteChanged with options.render === false. It mounts nothing, scrolls nothing, runs no onRouteExit and no view transition, and loads no lazy group. force does 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 router options 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:

  1. The guards run as usual. A navigation they refuse never downloads the group.
  2. 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.
  3. 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 /*.
  4. The router renders the route again in the SAME history entry: no new entry, no scroll, no guards (they already passed) and no onRouteExit for 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's when says false. Read the app's state through s.root. A placeholder whose state is a path string (bound to app state) gets no lazyStatus. 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 loadLazy call 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 second import() of a URL whose fetch failed with the same failure, without a request. smbls build and smbls start run parcel with a runtime that lifts both: after a failed load the next import() 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 a lazy group loads on the next navigation or loadLazy after a failure, with no page reload. A project .parcelrc that sets its own runtimes keeps 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: native import() 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, and loadLazy rejects 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'). false requests nothing: the placeholder shows lazyStatus: 'waiting', loadLazy rejects with an error whose code is 'LAZY_WAITING', and the next navigation to a route of the group or the next loadLazy asks when again. A when that throws or rejects is a failure ('error'). smbls push (frank) calls load only; when is a browser gate.

  • onRouteChanged fires for the placeholder and again for the loaded page. options.lazy tells them apart: { group: 'admin', status: 'loading' }, then { group: 'admin', status: 'loaded' }; the loaded render keeps the navigation's own options (initialRender, the popstate event). A focus manager or analytics hook can skip status: '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. frank refuses the same collision at smbls 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 after loadLazy resolves. During development the load names every element that was built too early.

  • Opt-in. Without context.lazy a lazy key 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 no lazy and no placeholder, so the editor and served pages work as before. smbls fetch into a fresh folder therefore writes the project eagerly; the split lives in your source tree. frank-audit treats 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. Without brender.lazyGroups the 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 declares lazy, e.g. through brender.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.json brender.lazyGroups makes 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 lazy from the context module brender.context names (or symbols/context.js), so a group must be declared there. Its when is 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 into context.lazyFirstView: its first navigation loads the group and renders its page, and the takeover adopts the built markup. The group's when still 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 a when that says false, shows the placeholder with that lazyStatus (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. After scrollRestoreTimeout ms (default 1000) 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 in sessionStorage, 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' sets history.scrollRestoration to '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, and app.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 History has no scrollRestoration, or is not fully active (a detached iframe), is skipped without an error.
  • Calling router() directly never touches history.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) and onRouteChanged. 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 or onRouteChanged (an active nav indicator). router() resolves after that step.
  • Back and Forward transition too.
  • name: a view-transition-name the routed element (the element whose content the 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() and designSystem.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, under prefers-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, with transition: 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.
  • onRouteExit runs 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, default 100): the swap step ran longer than this (from the update callback to updateCallbackDone, 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, default 1000): the transition still runs this long after ready.
    • false turns 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.page is the leaving page element (element[contentElementKey]); from / to are the route keys; pathname, params, query, hash describe the new URL.
  • The wait is bounded by exitTimeout (default 300 ms). On the timeout exit.signal aborts 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), under prefers-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