From 5ae03151501174d8903fa0c8ef1265ee7c6f0227 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sun, 20 Oct 2024 21:13:29 +0000 Subject: [PATCH] Add hash to router --- README.md | 29 +++++++++++++++++++---- index.d.ts | 1 + index.js | 58 ++++++++++++++++++++++++--------------------- package.json | 4 ++-- test/router.test.ts | 52 +++++++++++++++++++++++++++++++++++++--- test/ssr.test.ts | 2 ++ 6 files changed, 109 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ab4c7fc..f17b20a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A tiny URL router for [Nano Stores](https://github.com/nanostores/nanostores) state manager. -- **Small.** 684 bytes (minified and brotlied). Zero dependencies. +- **Small.** 712 bytes (minified and brotlied). Zero dependencies. - Good **TypeScript** support. - Framework agnostic. Can be used with **React**, **Preact**, **Vue**, **Svelte**, **Angular**, **Solid.js**, and vanilla JS. @@ -120,12 +120,14 @@ router.get() //=> { // path: '/posts/general', // route: 'list', // params: { category: 'general' }, -// search: { sort: 'name' } +// search: { sort: 'name' }, +// hash: '' // } ``` -To disable the automatic parsing of search params in routes you need to set `search` option. -Router will now treat search query like `?a=1&b=2` as a string. Parameters order will be critical. +To disable the automatic parsing of search params in routes you need +to set `search` option. Router will now treat search query like `?a=1&b=2` +as a string. Parameters order will be critical. ```js createRouter({ home: '/posts?page=general' }, { search: true }) @@ -135,7 +137,24 @@ router.get() //=> { // path: '/posts?page=general', // route: 'list', // params: { }, -// search: { } +// search: { }, +// hash: '' +// } +``` + +### Hash Routing + +Router’s value has current `location.hash` and router updates its value +on hash changes. + +```js +location.href = '/posts/general#dialog' +router.get() //=> { +// path: '/posts/general', +// route: 'list', +// params: { category: 'general' }, +// search: {}, +// hash: '#dialog' // } ``` diff --git a/index.d.ts b/index.d.ts index 9814657..c5b17bc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -90,6 +90,7 @@ export type Page< PageName extends keyof Config = any > = PageName extends any ? { + hash: string params: ParamsFromConfig[PageName] path: string route: PageName diff --git a/index.js b/index.js index 9cd9319..3092d50 100644 --- a/index.js +++ b/index.js @@ -16,34 +16,36 @@ export function createRouter(routes, opts = {}) { .replace(/\/\\:(\w+)\\\?/g, '(?:/(?<$1>(?<=/)[^/]+))?') .replace(/\/\\:(\w+)/g, '/(?<$1>[^/]+)') - return [ - name, - RegExp('^' + pattern + '$', 'i'), - null, - value - ]; + return [name, RegExp('^' + pattern + '$', 'i'), null, value] }) let prev - let parse = path => { - path = path.replace(/\/($|\?)/, '$1') || '/' - if (prev === path) return false - prev = path + let parse = href => { + let url = new URL(href.replace(/#$/, ''), 'http://a') + let cache = url.pathname + url.search + url.hash + if (prev === cache) return false + prev = cache - let url = new URL(path, 'http://a') - if (!opts.search) path = url.pathname + let path = opts.search ? url.pathname + url.search : url.pathname + path = path.replace(/\/($|\?)/, '$1') || '/' for (let [route, pattern, callback] of router.routes) { let match = path.match(pattern) if (match) { return { - // If route has callback for params decoding use it. Otherwise decode params from named capture groups - params: callback ? callback(...match.slice(1)) : Object.keys({...match.groups}).reduce((pars, key) => { - // match === undefined when nothing captured in regexp group - // and we swap it with empty string for backward compatibility - pars[key] = match.groups[key] ? decodeURIComponent(match.groups[key]) : ''; - return pars - }, {}), + hash: url.hash, + // If route has callback for params decoding use it. + // Otherwise decode params from named capture groups + params: callback + ? callback(...match.slice(1)) + : Object.keys({ ...match.groups }).reduce((pars, key) => { + // match === undefined when nothing captured in regexp group + // and we swap it with empty string for backward compatibility + pars[key] = match.groups[key] + ? decodeURIComponent(match.groups[key]) + : '' + return pars + }, {}), path, route, search: Object.fromEntries(url.searchParams) @@ -69,9 +71,9 @@ export function createRouter(routes, opts = {}) { !event.defaultPrevented // Click was not cancelled ) { event.preventDefault() - let changed = location.hash !== link.hash - router.open(link.pathname + link.search) - if (changed) { + let hashChanged = location.hash !== link.hash + router.open(link.href) + if (hashChanged) { location.hash = link.hash if (link.hash === '' || link.hash === '#') { window.dispatchEvent(new HashChangeEvent('hashchange')) @@ -85,21 +87,23 @@ export function createRouter(routes, opts = {}) { delete router.set } - let popstate = () => { - let page = parse(location.pathname + location.search) + let change = () => { + let page = parse(location.href) if (page !== false) set(page) } if (typeof window !== 'undefined' && typeof location !== 'undefined') { onMount(router, () => { - let page = parse(location.pathname + location.search) + let page = parse(location.href) if (page !== false) set(page) if (opts.links !== false) document.body.addEventListener('click', click) - window.addEventListener('popstate', popstate) + window.addEventListener('popstate', change) + window.addEventListener('hashchange', change) return () => { prev = undefined document.body.removeEventListener('click', click) - window.removeEventListener('popstate', popstate) + window.removeEventListener('popstate', change) + window.removeEventListener('hashchange', change) } }) } else { diff --git a/package.json b/package.json index a809028..cc1a332 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@nanostores/router", "version": "0.15.1", - "description": "A tiny (684 bytes) router for Nano Stores state manager", + "description": "A tiny (712 bytes) router for Nano Stores state manager", "keywords": [ "nano", "router", @@ -85,7 +85,7 @@ "import": { "./index.js": "{ createRouter }" }, - "limit": "684 B" + "limit": "712 B" } ], "clean-publish": { diff --git a/test/router.test.ts b/test/router.test.ts index 0c52ecf..8f4e716 100644 --- a/test/router.test.ts +++ b/test/router.test.ts @@ -80,6 +80,7 @@ afterEach(() => { test('parses current location', () => { changePath('/posts/guides/10') deepStrictEqual(router.get(), { + hash: '', params: { categoryId: 'guides', id: '10' @@ -93,6 +94,7 @@ test('parses current location', () => { test('ignores last slash', () => { changePath('/posts/guides/10/') deepStrictEqual(router.get(), { + hash: '', params: { categoryId: 'guides', id: '10' @@ -111,6 +113,7 @@ test('processes 404', () => { test('escapes RegExp symbols in routes', () => { changePath('/[secret]/9') deepStrictEqual(router.get(), { + hash: '', params: { id: '9' }, @@ -123,6 +126,7 @@ test('escapes RegExp symbols in routes', () => { test('converts URL-encoded symbols', () => { changePath('/posts/a%23b/10') deepStrictEqual(router.get(), { + hash: '', params: { categoryId: 'a#b', id: '10' @@ -133,9 +137,10 @@ test('converts URL-encoded symbols', () => { }) }) -test('ignores hash and search', () => { +test('ignores hash and search in pattern matching', () => { changePath('/posts/?id=1#top') deepStrictEqual(router.get(), { + hash: '#top', params: {}, path: '/posts', route: 'posts', @@ -146,6 +151,7 @@ test('ignores hash and search', () => { test('ignores case', () => { changePath('/POSTS') deepStrictEqual(router.get(), { + hash: '', params: {}, path: '/POSTS', route: 'posts', @@ -156,6 +162,7 @@ test('ignores case', () => { test('parameters can be optional', () => { changePath('/profile/') deepStrictEqual(router.get(), { + hash: '', params: { id: '', tab: '' @@ -167,6 +174,7 @@ test('parameters can be optional', () => { changePath('/profile/10/') deepStrictEqual(router.get(), { + hash: '', params: { id: '10', tab: '' @@ -178,6 +186,7 @@ test('parameters can be optional', () => { changePath('/profile/10/contacts') deepStrictEqual(router.get(), { + hash: '', params: { id: '10', tab: 'contacts' @@ -206,6 +215,7 @@ test('detects URL changes', () => { changePath('/') deepStrictEqual(router.get(), { + hash: '', params: {}, path: '/', route: 'home', @@ -231,12 +241,34 @@ test('ignores the same URL in popstate', () => { deepStrictEqual(events, []) }) +test('tracks hash changes', () => { + changePath('/') + let hashes: (string | undefined)[] = [] + router.listen(page => { + hashes.push(page?.hash) + }) + + changePath('/#') + deepStrictEqual(hashes, []) + + changePath('/#1') + deepStrictEqual(hashes, ['#1']) + + createTag(document.body, 'a', { href: '/#2' }).click() + deepStrictEqual(hashes, ['#1', '#2']) + + location.hash = '#3' + window.dispatchEvent(new HashChangeEvent('hashchange')) + deepStrictEqual(hashes, ['#1', '#2', '#3']) +}) + test('detects clicks', () => { changePath('/') let events = listen() createTag(document.body, 'a', { href: '/posts?a=1' }).click() deepStrictEqual(router.get(), { + hash: '', params: {}, path: '/posts', route: 'posts', @@ -260,6 +292,7 @@ test('disables clicks detects on request', () => { createTag(document.body, 'a', { href: '/posts' }).click() deepStrictEqual(router.get(), { + hash: '', params: {}, path: '/', route: 'home', @@ -383,6 +416,7 @@ test('opens URLs manually', () => { router.open('/posts/?a=1') equal(location.href, 'http://localhost/posts/?a=1') deepStrictEqual(router.get(), { + hash: '', params: {}, path: '/posts', route: 'posts', @@ -402,6 +436,7 @@ test('ignores the same URL in manual URL', () => { test('allows RegExp routes with callback', () => { changePath('/posts/draft/10/') deepStrictEqual(router.get(), { + hash: '', params: { id: '10', type: 'draft' }, path: '/posts/draft/10', route: 'draft', @@ -412,6 +447,7 @@ test('allows RegExp routes with callback', () => { test('allows RegExp routes without callback', () => { changePath('/named/draft/10/') deepStrictEqual(router.get(), { + hash: '', params: { id: '10', type: 'draft' }, path: '/named/draft/10', route: 'named', @@ -484,6 +520,7 @@ test('opens URLs manually by route name, pushing new stare', () => { equal(location.href, 'http://localhost/posts/guides/10') deepStrictEqual(router.get(), { + hash: '', params: { categoryId: 'guides', id: '10' @@ -496,6 +533,7 @@ test('opens URLs manually by route name, pushing new stare', () => { openPage(router, 'post', { categoryId: 'guides', id: 11 }) equal(location.href, 'http://localhost/posts/guides/11') deepStrictEqual(router.get(), { + hash: '', params: { categoryId: 'guides', id: '11' @@ -531,6 +569,7 @@ test('opens URLs manually by route name, replacing state', () => { equal(history.length - start, 2) equal(location.href, 'http://localhost/posts/guides/10') deepStrictEqual(router.get(), { + hash: '', params: { categoryId: 'guides', id: '10' @@ -543,6 +582,7 @@ test('opens URLs manually by route name, replacing state', () => { redirectPage(router, 'post', { categoryId: 'guides', id: 11 }) equal(location.href, 'http://localhost/posts/guides/11') deepStrictEqual(router.get(), { + hash: '', params: { categoryId: 'guides', id: '11' @@ -585,7 +625,8 @@ test('supports link with hash in URL with same path', () => { link.click() equal(location.hash, '#hash') - deepStrictEqual(events, []) + deepStrictEqual(events, ['/posts']) + equal(router.get()?.hash, '#hash') }) test('supports link with hash in URL and different path', () => { @@ -624,7 +665,7 @@ test('generates artificial hashchange event for empty hash', () => { window.removeEventListener('hashchange', onHashChange) equal(location.hash, '') - deepStrictEqual(events, []) + deepStrictEqual(events, ['/']) equal(hashChangeCalled, 1) }) @@ -643,6 +684,7 @@ test('uses search query on request', () => { listen(otherRouter) deepStrictEqual(otherRouter.get(), { + hash: '', params: {}, path: '/p?page=a', route: 'a', @@ -652,6 +694,7 @@ test('uses search query on request', () => { let link = createTag(document.body, 'a', { href: '/p?page=b' }) link.click() deepStrictEqual(otherRouter.get(), { + hash: '', params: {}, path: '/p?page=b', route: 'b', @@ -660,6 +703,7 @@ test('uses search query on request', () => { changePath('/p?page=a') deepStrictEqual(otherRouter.get(), { + hash: '', params: {}, path: '/p?page=a', route: 'a', @@ -668,6 +712,7 @@ test('uses search query on request', () => { changePath('/p/?page=b') deepStrictEqual(otherRouter.get(), { + hash: '', params: {}, path: '/p?page=b', route: 'b', @@ -686,6 +731,7 @@ test('supports dot in URL', () => { listen(otherRouter) deepStrictEqual(otherRouter.get(), { + hash: '', params: {}, path: '/page.txt', route: 'text', diff --git a/test/ssr.test.ts b/test/ssr.test.ts index d8afd3a..1e16e8f 100644 --- a/test/ssr.test.ts +++ b/test/ssr.test.ts @@ -15,6 +15,7 @@ afterEach(() => { test('opens home by default', () => { deepStrictEqual(router.get(), { + hash: '', params: {}, path: '/', route: 'home', @@ -28,6 +29,7 @@ test('opens custom page', () => { router.open('/posts?q=2') deepStrictEqual(router.get(), { + hash: '', params: {}, path: '/posts', route: 'posts',