single-page-app-router
v0.1.0
Published
## Installation
Downloads
59
Readme
single-page-app-router
Installation
npm i -S single-page-app-router
You can use it with any history solution, the most common being history I’ll showcase this one.
// <repo>/history.ts
import { createBrowserHistory } from 'history'
export const history = createBrowserHistory() // or Memory, or…
// <repo>/library/router.ts
import { RouterFactory } from 'single-page-app-router'
import { PathToRegexpAdapter } from 'single-page-app-router/adapter/PathToRegexp'
import { match } from 'path-to-regexp'
import { history } from '<repo>/history'
export const Router = RouterFactory({
adapter: PathToRegexpAdapter(match),
getPathname: () => history.location.pathname,
onHistoryChange: history.listen,
// Optional: you can provide a global route equality check
isSameRoute: (a, b) => isDeepEqual(a, b),
})
Enforcing a route shape
// Optional: You can force a general route shape.
// This is useful to force a stable discriminant.
// Let’s our discriminant is "name"
type RouteShape = { name: string }
export const Router = RouterFactory<RouteShape>(…)
Recipes
Top-Level Routes
import { Router } from '<repo>/router'
type YourRoute =
| { name: 'Home' }
| { name: 'Product', id: number }
| { name: 'NotFound', matchPathname?: string }
const router = Router<YourRoute>({
NotFound: () => ({ name: 'NotFound' })
})({
'/': () => ({ name: 'Home' }),
'/product/:id{/*}?': ({ params, pathname }) => {
pathname // "/product/:id{/*}"
const id = Number(params.id)
// For whatever reason, you can return `undefined`
// it will resolve to "not found"
// In our case, if the product id is not a number, let’s return `undefined`
return Number.isNaN(id) ? undefined : { name: 'Product', id }
},
})
router.route // YourRoute
router.onRouteChanged((newRoute, oldRoute) => {…})
router.destroy() // remove all `onRouteChanged` listeners.
Nested Routes
import { Router } from '<repo>/router'
type YourRoute =
| { name: 'Home' }
| { name: 'Product', id: number }
| { name: 'NotFound', matchPathname?: string }
const basePath = '/:locale'
const router = Router<YourRoute>({
NotFound: () => ({ name: 'NotFound' }),
})({
basePath,
routes: {
'{/}?': ({ params }) => ({ name: 'Home' }),
'/product/:id{/*}?': ({ params, pathname }) => {
pathname // "/:locale/product/:id{/*}?"
const id = Number(params.id)
return Number.isNaN(id) ? undefined : { name: 'Product', id }
},
}
})
router.route // YourRoute
router.onRouteChanged((newRoute, oldRoute) => {})
router.destroy() // remove all `onRouteChanged` listeners.
Framework integrations
React Hook
const useRouter = (router: Router) => {
const [route, setRoute] = useState(router.route)
useEffect(() => {
const unsubscribe = router.onRouteChanged((newRoute) => {
setRoute(newRoute)
})
return unsubscribe
}, [router])
return route
}
Svelte
import { readable } from 'svelte'
const RouterToSvelteStore = (router: Router) => {
return readable(router.route, (set) => {
const unsubscribe = router.onRouteChanged((newRoute) => {
set(newRoute)
})
return unsubscribe
})
}
Reference
Router
type Unsubscribe = () => void
export interface Router<Route extends object> {
route: Route
onRouteChanged: (
listener: (newRoute: Route, previousRoute: Route) => unknown,
) => Unsubscribe
destroy: () => void
}
RouteData<Path>
What is injected in the route callback.
export interface RouteData<BasePath extends string, Path extends string> {
params: PathParameters<`${BasePath}${Path}`>
pathname: string
}
const router = Router<MyRoute>(…)({
basePath: '/:locale',
routes: {
'{/}?': (data: RouteData<'/:locale', '{/}?'>) => {
data // { params: { locale: string }, pathname: '/:locale{/}?' }
},
'/product/:id': (data: RouteData<'/:locale', '/product/:id'>) => {
data // { params: { locale: string, id: string }, pathname: '/:locale/product/:id' }
},
}
})
Path Syntax
I based the library on URLPattern, which itself is based on path-to-regexp. Therefore, their syntax prevails.
The MDN website is an excellent place to start. Here are a few tips though:
/post/*
will match/post/
,/post/1
&/post/1/2
; but not/post
:warning: To match/post
=>post{/*}?
/post{/:id}?
matches/post
&/post/1
, not/post/1/2
- Regex groups like
/books/(\\d+)
can be used but break intellisense of path parameters - For nested routers, type the home as
{/}?
:wink:
Why yet-another X ?
Because I never encountered one that made sense to me:
[!Important] Routing and history are separate concerns. A history can be unique across the client-side app. Or you can nest them. I don’t care. Routing solves another problem anyway
You want routing? Fine: provide the history to watch changes, you'll get the active route in return.
You want some nested routing? Perfect, provide the history and a base path, you'll get the active route in return.
All in pure JS, testable with no framework, adaptable to every framework.
Testable: No jsdom needed, no {your framework}-library, no nothing. Aim at that 3ms test 😉.
Fully type-safe and type-driven for mad-typers. It comes with a double-function cost, but still worth it!
Now you have the treat of typed path parameters :stuck_out_tongue:
Contributing
Any contribution is welcome, fork and PR :grin:
# clone the repo
npm ci
npm run test