From 9da6f145f46cb6dbca0a33162d02d3c09a6b8b10 Mon Sep 17 00:00:00 2001 From: jakub Date: Tue, 25 Jan 2022 00:07:40 +0000 Subject: [PATCH 1/9] base skeleton for path routing --- lib/index.d.ts | 21 ++++++++- lib/router.js | 102 +++++++++++++++++++++++++++++++---------- test/browser/router.js | 2 +- 3 files changed, 97 insertions(+), 28 deletions(-) diff --git a/lib/index.d.ts b/lib/index.d.ts index 27f861d1..082d8b9b 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -71,6 +71,23 @@ export interface PanelLifecycleContext { unbindFromComponent?(component: Component): void; } +/** Object mapping string hash-route expressions to handler functions */ +export interface HashRouteDefinition { + [route: string]: Function | string; +} + +/** Path + hash routing support */ +export interface RouteDefinition { + /** Root path where component lives, defaults to '/' */ + basePath?: string; + paths: Array<{ + /** String describing relative path to the basename */ + pathName: string; + /** Any hash sub-paths and their handlers */ + hashRoutes: HashRouteDefinition; + }>; +} + export interface ConfigOptions { /** Function transforming state object to virtual dom tree */ template(scope?: StateT): VNode; @@ -99,8 +116,8 @@ export interface ConfigOptions; - /** Object mapping string route expressions to handler functions */ - routes?: {[route: string]: Function}; + /** Single-page path routing and/or hash routing configuration */ + routes?: RouteDefinition | HashRouteDefinition; /** Whether to apply updates to DOM immediately, instead of batching to one update per frame */ updateSync?: boolean; diff --git a/lib/router.js b/lib/router.js index 7cfa52fd..800faa4e 100644 --- a/lib/router.js +++ b/lib/router.js @@ -9,7 +9,38 @@ function decodedFragmentsEqual(currFragment, newFragment) { return decodeURIComponent(currFragment) === decodeURIComponent(newFragment); } -// just the necessary bits of Backbone router+history +// https://github.com/jashkenas/backbone/blob/d682061a/backbone.js#L1476-L1479 +// Cached regular expressions for matching named param parts and splatted +// parts of route strings. +const optionalParam = /\((.*?)\)/g; +const namedParam = /(\(\?)?:\w+/g; +const splatParam = /\*\w+/g; +const escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; // eslint-disable-line no-useless-escape +function compileRouteExpression(routeExpr) { + // https://github.com/jashkenas/backbone/blob/d682061a/backbone.js#L1537-L1547 + const expr = routeExpr + .replace(escapeRegExp, `\\$&`) + .replace(optionalParam, `(?:$1)?`) + .replace(namedParam, (match, optional) => (optional ? match : `([^/?]+)`)) + .replace(splatParam, `([^?]*?)`); + return new RegExp(`^` + expr + `(?:\\?([\\s\\S]*))?$`); +} + +function getCompiledHashRoutes(routeDefs) { + return Object.keys(routeDefs).map((hashRouteExpr) => { + let handler = routeDefs[hashRouteExpr]; + if (typeof handler === `string`) { + // reference to another handler rather than its own function + handler = routeDefs[handler]; + } + + return { + expr: compileRouteExpression(hashRouteExpr), + handler, + }; + }); +} + export default class Router { constructor(app, options = {}) { // allow injecting window dep @@ -18,31 +49,30 @@ export default class Router { this.app = app; const routeDefs = this.app.getConfig(`routes`); - // https://github.com/jashkenas/backbone/blob/d682061a/backbone.js#L1476-L1479 - // Cached regular expressions for matching named param parts and splatted - // parts of route strings. - const optionalParam = /\((.*?)\)/g; - const namedParam = /(\(\?)?:\w+/g; - const splatParam = /\*\w+/g; - const escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; // eslint-disable-line no-useless-escape - this.compiledRoutes = Object.keys(routeDefs).map((routeExpr) => { - // https://github.com/jashkenas/backbone/blob/d682061a/backbone.js#L1537-L1547 - let expr = routeExpr - .replace(escapeRegExp, `\\$&`) - .replace(optionalParam, `(?:$1)?`) - .replace(namedParam, (match, optional) => (optional ? match : `([^/?]+)`)) - .replace(splatParam, `([^?]*?)`); - expr = new RegExp(`^` + expr + `(?:\\?([\\s\\S]*))?$`); - - // hook up route handler function - let handler = routeDefs[routeExpr]; - if (typeof handler === `string`) { - // reference to another handler rather than its own function - handler = routeDefs[handler]; + // by default assume we are at the root path + this.basePathExpr = new RegExp(`/$`); + + // check if using new path-routing configuration + if (`paths` in routeDefs && Array.isArray(routeDefs.paths)) { + if (routeDefs.basePath) { + this.basePathExpr = compileRouteExpression(routeDefs.basePath); } - return {expr, handler}; - }); + this.compiledRoutes = routeDefs.paths.map((path) => { + return { + pathExpr: compileRouteExpression(path.pathName), + hashRoutes: getCompiledHashRoutes(path.hashRoutes), + }; + }); + } else { + this.compiledRoutes = [ + { + // match any path if none are provided + pathExpr: compileRouteExpression(`*`), + hashRoutes: getCompiledHashRoutes(routeDefs), + }, + ]; + } this.registerListeners(options.historyMethod || `pushState`); } @@ -68,14 +98,32 @@ export default class Router { this.window.history[this.historyMethod] = this.origChangeStateMethod; } + // alias for navigateToHash to maintain backwards compatibility. navigate(fragment, stateUpdate = {}) { + return this.navigateToHash(fragment, stateUpdate); + } + + navigateToHash(fragment, stateUpdate = {}) { + const currentPath = this.getRelativePathFromWindow(); + for (const {pathExpr, hashRoutes} of this.compiledRoutes) { + const matches = pathExpr.exec(currentPath); + if (matches) { + this.invokeHashRouteHandler(fragment, hashRoutes, stateUpdate); + } + } + + // no route matched + console.error(`No path route found matching ${currentPath}`); + } + + invokeHashRouteHandler(fragment, compiledHashRoutes, stateUpdate = {}) { fragment = stripHash(fragment); if (decodedFragmentsEqual(this.app.state.$fragment, fragment) && !Object.keys(stateUpdate).length) { return; } stateUpdate.$fragment = fragment; - for (const route of this.compiledRoutes) { + for (const route of compiledHashRoutes) { const matches = route.expr.exec(fragment); if (matches) { // extract params @@ -113,4 +161,8 @@ export default class Router { this.window.history[historyMethod](null, null, `#${fragment}`); } } + + getRelativePathFromWindow() { + return this.window.location.pathname.replace(this.basePathExpr, ``) || `/`; + } } diff --git a/test/browser/router.js b/test/browser/router.js index 8731d919..36b2eb65 100644 --- a/test/browser/router.js +++ b/test/browser/router.js @@ -30,7 +30,7 @@ export class RouterApp extends Component { } customElements.define(`router-app`, RouterApp); -describe(`Router`, function () { +describe.only(`Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; window.location = `#`; From d4e1a809ff89ea2b119356d70cff18870f5778db Mon Sep 17 00:00:00 2001 From: Jakub Grzegorzewski Date: Tue, 25 Jan 2022 11:41:35 -0600 Subject: [PATCH 2/9] pass hash route test suite --- lib/router.js | 9 +++++---- test/browser/router.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/router.js b/lib/router.js index 800faa4e..b31389bc 100644 --- a/lib/router.js +++ b/lib/router.js @@ -68,7 +68,7 @@ export default class Router { this.compiledRoutes = [ { // match any path if none are provided - pathExpr: compileRouteExpression(`*`), + pathExpr: new RegExp(`.*`), hashRoutes: getCompiledHashRoutes(routeDefs), }, ]; @@ -98,17 +98,18 @@ export default class Router { this.window.history[this.historyMethod] = this.origChangeStateMethod; } - // alias for navigateToHash to maintain backwards compatibility. + // alias for hashNavigate to maintain backwards compatibility. navigate(fragment, stateUpdate = {}) { - return this.navigateToHash(fragment, stateUpdate); + return this.hashNavigate(fragment, stateUpdate); } - navigateToHash(fragment, stateUpdate = {}) { + hashNavigate(fragment, stateUpdate = {}) { const currentPath = this.getRelativePathFromWindow(); for (const {pathExpr, hashRoutes} of this.compiledRoutes) { const matches = pathExpr.exec(currentPath); if (matches) { this.invokeHashRouteHandler(fragment, hashRoutes, stateUpdate); + return; } } diff --git a/test/browser/router.js b/test/browser/router.js index 36b2eb65..6cee020d 100644 --- a/test/browser/router.js +++ b/test/browser/router.js @@ -34,7 +34,7 @@ describe.only(`Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; window.location = `#`; - + window.history.replaceState(null, null, `/`); this.routerApp = document.createElement(`router-app`); document.body.appendChild(this.routerApp); From 22939244ecee9de35755c17368f86a61d803e9ec Mon Sep 17 00:00:00 2001 From: Jakub Grzegorzewski Date: Wed, 26 Jan 2022 13:09:54 -0600 Subject: [PATCH 3/9] some baseline tests passing --- lib/component.js | 30 ++++++++++++-- lib/router.js | 91 ++++++++++++++++++++++++++++++++---------- test/browser/router.js | 60 +++++++++++++++++++++++++++- 3 files changed, 154 insertions(+), 27 deletions(-) diff --git a/lib/component.js b/lib/component.js index ed5684d8..dd21bc64 100644 --- a/lib/component.js +++ b/lib/component.js @@ -2,7 +2,7 @@ import cuid from 'cuid'; import WebComponent from 'webcomponent'; import {EMPTY_DIV, DOMPatcher, h} from './dom-patcher'; -import Router from './router'; +import Router, {isPathRouteConfig} from './router'; import * as hookHelpers from './component-utils/hook-helpers'; import {getNow} from './component-utils/perf'; @@ -149,6 +149,20 @@ class Component extends WebComponent { this.$panelRoot.router.navigate(...arguments); } + /** + * Executes the route handler matching the given URL fragment, and updates + * the URL, as though the user had navigated explicitly to that address. + * @param {string} path - path to navigate to + * @param {string} [fragment=``] - optional URL fragment to navigate to + * @param {object} [stateUpdate={}] - update to apply to state object when + * routing + * @example + * myApp.pathNavigate('/animals', 'wombat/54', {color: 'blue'}); + */ + pathNavigate() { + this.$panelRoot.router.pathNavigate(...arguments); + } + /** * Helper function which will queue a function to be run once the component has been * initialized and added to the DOM. If the component has already had its connectedCallback @@ -423,7 +437,10 @@ class Component extends WebComponent { Object.assign(this.state, this.getJSONAttribute(`data-state`), this._stateFromAttributes()); - if (Object.keys(this.getConfig(`routes`)).length) { + if (isPathRouteConfig(this.getConfig(`routes`))) { + this.router = new Router(this, {historyMethod: this.historyMethod}); + this.pathNavigate(this.router.getRelativePathFromWindow(), window.location.hash); + } else if (Object.keys(this.getConfig(`routes`)).length) { this.router = new Router(this, {historyMethod: this.historyMethod}); this.navigate(window.location.hash); } @@ -778,6 +795,7 @@ class Component extends WebComponent { } else { // update DOM, router, descendants etc. const updateHash = `$fragment` in stateUpdate && stateUpdate.$fragment !== this[store].$fragment; + const updatePath = `$path` in stateUpdate && stateUpdate.$path !== this[store].$path; const cascadeFromRoot = cascade && !this.isPanelRoot; const updateOptions = {cascade, store}; const rootOptions = {exclude: this, cascade, store}; @@ -791,8 +809,12 @@ class Component extends WebComponent { if (cascadeFromRoot) { this.$panelRoot.updateSelfAndChildren(stateUpdate, rootOptions); } - if (updateHash) { - this.router.replaceHash(this[store].$fragment); + + if (updateHash || updatePath) { + this.router.replacePath({ + path: updatePath ? this[store].$path : null, + fragment: updateHash ? this[store].$fragment : null, + }); } this.runHook(`postUpdate`, updateOptions, stateUpdate); diff --git a/lib/router.js b/lib/router.js index b31389bc..fc2c8542 100644 --- a/lib/router.js +++ b/lib/router.js @@ -16,14 +16,15 @@ const optionalParam = /\((.*?)\)/g; const namedParam = /(\(\?)?:\w+/g; const splatParam = /\*\w+/g; const escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; // eslint-disable-line no-useless-escape -function compileRouteExpression(routeExpr) { +function compileRouteExpression(routeExpr, end = true) { // https://github.com/jashkenas/backbone/blob/d682061a/backbone.js#L1537-L1547 const expr = routeExpr .replace(escapeRegExp, `\\$&`) .replace(optionalParam, `(?:$1)?`) .replace(namedParam, (match, optional) => (optional ? match : `([^/?]+)`)) .replace(splatParam, `([^?]*?)`); - return new RegExp(`^` + expr + `(?:\\?([\\s\\S]*))?$`); + + return new RegExp(`^` + expr + `(?:\\?([\\s\\S]*))?${end ? `$` : ``}`); } function getCompiledHashRoutes(routeDefs) { @@ -41,6 +42,22 @@ function getCompiledHashRoutes(routeDefs) { }); } +function extractParamsFromMatches(matches) { + // https://github.com/jashkenas/backbone/blob/d682061a/backbone.js#L1553-L1558 + const params = matches.slice(1); + return params.map((param, i) => { + // Don't decode the search params. + if (i === params.length - 1) { + return param || null; + } + return param ? decodeURIComponent(param) : null; + }); +} + +export function isPathRouteConfig(routeDefs) { + return `paths` in routeDefs && Array.isArray(routeDefs.paths); +} + export default class Router { constructor(app, options = {}) { // allow injecting window dep @@ -50,12 +67,11 @@ export default class Router { const routeDefs = this.app.getConfig(`routes`); // by default assume we are at the root path - this.basePathExpr = new RegExp(`/$`); + this.basePathExpr = new RegExp(`^/$`); - // check if using new path-routing configuration - if (`paths` in routeDefs && Array.isArray(routeDefs.paths)) { + if (isPathRouteConfig(routeDefs)) { if (routeDefs.basePath) { - this.basePathExpr = compileRouteExpression(routeDefs.basePath); + this.basePathExpr = compileRouteExpression(routeDefs.basePath, false); } this.compiledRoutes = routeDefs.paths.map((path) => { @@ -78,15 +94,18 @@ export default class Router { } registerListeners(historyMethod) { - this.navigateToHash = () => this.navigate(this.window.location.hash); - this.window.addEventListener(`popstate`, this.navigateToHash); + this.handleNavigation = () => { + this.pathNavigate(this.getRelativePathFromWindow(), this.window.location.hash); + }; + + this.window.addEventListener(`popstate`, this.handleNavigation); this.historyMethod = historyMethod; this.origChangeStateMethod = this.window.history[this.historyMethod]; this.window.history[this.historyMethod] = (...args) => { this.origChangeStateMethod.apply(this.window.history, args); - this.navigateToHash(); + this.handleNavigation(); // fire "pushstate" or "replacestate" event so external action can be taken on url change // these events are meant to be congruent with native "popstate" event this.app.dispatchEvent(new CustomEvent(this.historyMethod.toLowerCase())); @@ -94,7 +113,7 @@ export default class Router { } unregisterListeners() { - this.window.removeEventListener(`popstate`, this.navigateToHash); + this.window.removeEventListener(`popstate`, this.handleNavigation); this.window.history[this.historyMethod] = this.origChangeStateMethod; } @@ -117,7 +136,25 @@ export default class Router { console.error(`No path route found matching ${currentPath}`); } - invokeHashRouteHandler(fragment, compiledHashRoutes, stateUpdate = {}) { + pathNavigate(path, fragment = ``, stateUpdate = {}) { + const basePathMatch = this.window.location.pathname.match(this.basePathExpr); + if (!basePathMatch) { + console.error(`Provided basePath does not match location: ${this.window.location.pathname}`); + return; + } + + stateUpdate.$path = path; + for (const {pathExpr, hashRoutes} of this.compiledRoutes) { + const matches = pathExpr.exec(path); + if (matches) { + const params = extractParamsFromMatches(matches); + this.invokeHashRouteHandler(fragment, hashRoutes, stateUpdate, params); + return; + } + } + } + + invokeHashRouteHandler(fragment, compiledHashRoutes, stateUpdate = {}, pathParams = []) { fragment = stripHash(fragment); if (decodedFragmentsEqual(this.app.state.$fragment, fragment) && !Object.keys(stateUpdate).length) { return; @@ -127,22 +164,13 @@ export default class Router { for (const route of compiledHashRoutes) { const matches = route.expr.exec(fragment); if (matches) { - // extract params - // https://github.com/jashkenas/backbone/blob/d682061a/backbone.js#L1553-L1558 - let params = matches.slice(1); - params = params.map((param, i) => { - // Don't decode the search params. - if (i === params.length - 1) { - return param || null; - } - return param ? decodeURIComponent(param) : null; - }); + const params = extractParamsFromMatches(matches); const routeHandler = route.handler; if (!routeHandler) { throw `No route handler defined for #${fragment}`; } - const routeStateUpdate = routeHandler.call(this.app, stateUpdate, ...params); + const routeStateUpdate = routeHandler.call(this.app, stateUpdate, ...pathParams, ...params); if (routeStateUpdate) { // don't update if route handler returned a falsey result this.app.update(Object.assign({}, stateUpdate, routeStateUpdate)); @@ -163,6 +191,25 @@ export default class Router { } } + replacePath({path = null, historyMethod = null, fragment = null} = {}) { + historyMethod = historyMethod || this.historyMethod; + + const basePathMatch = this.window.location.pathname.match(this.basePathExpr); + const fullPath = `${basePathMatch[0]}${path}`.replace(/\/$/, ``); // remove trailing slash + + let url = ``; + if (path && !decodedFragmentsEqual(this.window.location.pathname, fullPath)) { + url += fullPath; + } + if (fragment && !decodedFragmentsEqual(stripHash(this.window.location.hash), stripHash(fragment))) { + url += `#${stripHash(fragment)}`; + } + + if (url) { + this.window.history[historyMethod](null, null, url); + } + } + getRelativePathFromWindow() { return this.window.location.pathname.replace(this.basePathExpr, ``) || `/`; } diff --git a/test/browser/router.js b/test/browser/router.js index 6cee020d..ccb08dd1 100644 --- a/test/browser/router.js +++ b/test/browser/router.js @@ -30,7 +30,39 @@ export class RouterApp extends Component { } customElements.define(`router-app`, RouterApp); -describe.only(`Router`, function () { +export class PathRouterApp extends Component { + get config() { + return { + defaultState: { + text: `Hello world`, + additionalText: ``, + }, + routes: { + basePath: `/foo/:id`, + paths: [ + { + pathName: `/`, + hashRoutes: { + '': () => ({text: `Default route!`}), + bar: () => ({text: `Bar route!`}), + }, + }, + { + pathName: `/widget/:id`, + hashRoutes: { + '*anyHash': (stateUpdate, id) => Object.assign({text: `Widget ${id}`}, stateUpdate), + }, + }, + ], + }, + template: (state) => h(`p`, [`${state.text}${state.additionalText}`]), + }; + } +} + +customElements.define(`path-router-app`, PathRouterApp); + +describe.only(`hash-only Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; window.location = `#`; @@ -201,3 +233,29 @@ describe.only(`Router`, function () { }); }); }); + +describe.only(`path + hash Router`, function () { + beforeEach(async function () { + document.body.innerHTML = ``; + window.location = `#`; + window.history.replaceState(null, null, `/foo/123`); + this.routerApp = document.createElement(`path-router-app`); + document.body.appendChild(this.routerApp); + + await nextAnimationFrame(); + }); + + it(`runs index route handler when at root`, function () { + expect(this.routerApp.textContent).to.equal(`Default route!`); + }); + + it(`reacts to hash route changes at root`, async function () { + window.location.hash = `#bar`; + await retryable(() => expect(this.routerApp.textContent).to.equal(`Bar route!`)); + }); + + it(`passes params to route handlers`, async function () { + window.history.pushState(null, null, `/foo/123/widget/15`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15`)); + }); +}); From feadd48e862ad240812f042fb6b28568a84e2f60 Mon Sep 17 00:00:00 2001 From: Jakub Grzegorzewski Date: Wed, 26 Jan 2022 18:57:50 -0600 Subject: [PATCH 4/9] separate path and hash params --- lib/router.js | 23 ++++++++++++++++++----- test/browser/router.js | 32 ++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/lib/router.js b/lib/router.js index fc2c8542..aeac4504 100644 --- a/lib/router.js +++ b/lib/router.js @@ -1,3 +1,5 @@ +import {join} from 'path'; + function stripHash(fragment) { return fragment.replace(/^#*/, ``); } @@ -69,7 +71,8 @@ export default class Router { // by default assume we are at the root path this.basePathExpr = new RegExp(`^/$`); - if (isPathRouteConfig(routeDefs)) { + this.isPathRouteConfig = isPathRouteConfig(routeDefs); + if (this.isPathRouteConfig) { if (routeDefs.basePath) { this.basePathExpr = compileRouteExpression(routeDefs.basePath, false); } @@ -164,13 +167,22 @@ export default class Router { for (const route of compiledHashRoutes) { const matches = route.expr.exec(fragment); if (matches) { - const params = extractParamsFromMatches(matches); + const hashParams = extractParamsFromMatches(matches); const routeHandler = route.handler; if (!routeHandler) { throw `No route handler defined for #${fragment}`; } - const routeStateUpdate = routeHandler.call(this.app, stateUpdate, ...pathParams, ...params); + + let routeStateUpdate; + // for path routing config, our handler should expect separated path and hash params + if (this.isPathRouteConfig) { + const paramsObject = {path: pathParams, hash: hashParams}; + routeStateUpdate = routeHandler.call(this.app, stateUpdate, paramsObject); + } else { + routeStateUpdate = routeHandler.call(this.app, stateUpdate, ...hashParams); + } + if (routeStateUpdate) { // don't update if route handler returned a falsey result this.app.update(Object.assign({}, stateUpdate, routeStateUpdate)); @@ -195,7 +207,8 @@ export default class Router { historyMethod = historyMethod || this.historyMethod; const basePathMatch = this.window.location.pathname.match(this.basePathExpr); - const fullPath = `${basePathMatch[0]}${path}`.replace(/\/$/, ``); // remove trailing slash + // const fullPath = `${basePathMatch[0]}${path}`.replace(/\/$/, ``); // remove trailing slash, can we do this better? + const fullPath = join(basePathMatch[0], path || ``); let url = ``; if (path && !decodedFragmentsEqual(this.window.location.pathname, fullPath)) { @@ -211,6 +224,6 @@ export default class Router { } getRelativePathFromWindow() { - return this.window.location.pathname.replace(this.basePathExpr, ``) || `/`; + return this.window.location.pathname.replace(this.basePathExpr, ``).replace(/^\/?/, `/`); } } diff --git a/test/browser/router.js b/test/browser/router.js index ccb08dd1..4197cbe9 100644 --- a/test/browser/router.js +++ b/test/browser/router.js @@ -38,7 +38,7 @@ export class PathRouterApp extends Component { additionalText: ``, }, routes: { - basePath: `/foo/:id`, + basePath: `/foo/:id(/)`, paths: [ { pathName: `/`, @@ -48,9 +48,11 @@ export class PathRouterApp extends Component { }, }, { - pathName: `/widget/:id`, + pathName: `/widget/:id(/)`, hashRoutes: { - '*anyHash': (stateUpdate, id) => Object.assign({text: `Widget ${id}`}, stateUpdate), + '': (stateUpdate, {path}) => Object.assign({text: `Widget ${path[0]}`}, stateUpdate), + 'param/:param': (stateUpdate, {path, hash}) => + Object.assign({text: `Widget ${path[0]} with param ${hash[0]}`}, stateUpdate), }, }, ], @@ -62,7 +64,7 @@ export class PathRouterApp extends Component { customElements.define(`path-router-app`, PathRouterApp); -describe.only(`hash-only Router`, function () { +describe(`hash-only Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; window.location = `#`; @@ -234,7 +236,7 @@ describe.only(`hash-only Router`, function () { }); }); -describe.only(`path + hash Router`, function () { +describe(`path + hash Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; window.location = `#`; @@ -249,13 +251,31 @@ describe.only(`path + hash Router`, function () { expect(this.routerApp.textContent).to.equal(`Default route!`); }); + it(`runs index route handler when at root with trailing slash`, async function () { + window.history.pushState(null, null, `/foo/123/`); + await nextAnimationFrame(); + expect(this.routerApp.textContent).to.equal(`Default route!`); + }); + it(`reacts to hash route changes at root`, async function () { window.location.hash = `#bar`; await retryable(() => expect(this.routerApp.textContent).to.equal(`Bar route!`)); }); - it(`passes params to route handlers`, async function () { + it(`passes path params to route handlers`, async function () { window.history.pushState(null, null, `/foo/123/widget/15`); await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15`)); }); + + it(`passes path params to route handlers with trailing slash`, async function () { + window.history.pushState(null, null, `/foo/123/widget/15/`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15`)); + }); + + it(`passes path and hash params to route handlers`, async function () { + window.history.pushState(null, null, `/foo/123/widget/15#param/foobar`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15 with param foobar`)); + }); + + // TODO: trailing slash cases }); From 0f236eb493cebb55d640cb4a3f2b139657baf0e2 Mon Sep 17 00:00:00 2001 From: Jakub Grzegorzewski Date: Tue, 1 Feb 2022 13:30:23 -0600 Subject: [PATCH 5/9] thorough testing --- lib/component.js | 2 +- lib/router.js | 27 ++---- test/browser/router.js | 207 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 197 insertions(+), 39 deletions(-) diff --git a/lib/component.js b/lib/component.js index dd21bc64..143f7950 100644 --- a/lib/component.js +++ b/lib/component.js @@ -811,7 +811,7 @@ class Component extends WebComponent { } if (updateHash || updatePath) { - this.router.replacePath({ + this.router.replaceLocation({ path: updatePath ? this[store].$path : null, fragment: updateHash ? this[store].$fragment : null, }); diff --git a/lib/router.js b/lib/router.js index aeac4504..6f4f9db3 100644 --- a/lib/router.js +++ b/lib/router.js @@ -4,10 +4,10 @@ function stripHash(fragment) { return fragment.replace(/^#*/, ``); } -function decodedFragmentsEqual(currFragment, newFragment) { - // decodeURIComponent since hash fragments are encoded while being - // written to url, making `#bar baz` and `#bar%20baz` effectively the same. - // This can result in hash update loops if the client passes in decoded hash. +function decodedStringsEqual(currFragment, newFragment) { + // decodeURIComponent since URI components are encoded while being + // written to url, making `bar baz` and `bar%20baz` effectively the same. + // This can result in update loops if the client passes in decoded path or hash. return decodeURIComponent(currFragment) === decodeURIComponent(newFragment); } @@ -159,7 +159,7 @@ export default class Router { invokeHashRouteHandler(fragment, compiledHashRoutes, stateUpdate = {}, pathParams = []) { fragment = stripHash(fragment); - if (decodedFragmentsEqual(this.app.state.$fragment, fragment) && !Object.keys(stateUpdate).length) { + if (decodedStringsEqual(this.app.state.$fragment, fragment) && !Object.keys(stateUpdate).length) { return; } @@ -177,8 +177,7 @@ export default class Router { let routeStateUpdate; // for path routing config, our handler should expect separated path and hash params if (this.isPathRouteConfig) { - const paramsObject = {path: pathParams, hash: hashParams}; - routeStateUpdate = routeHandler.call(this.app, stateUpdate, paramsObject); + routeStateUpdate = routeHandler.call(this.app, stateUpdate, pathParams, hashParams); } else { routeStateUpdate = routeHandler.call(this.app, stateUpdate, ...hashParams); } @@ -195,15 +194,7 @@ export default class Router { console.error(`No route found matching #${fragment}`); } - replaceHash(fragment, {historyMethod = null} = {}) { - historyMethod = historyMethod || this.historyMethod; - fragment = stripHash(fragment); - if (!decodedFragmentsEqual(stripHash(this.window.location.hash), fragment)) { - this.window.history[historyMethod](null, null, `#${fragment}`); - } - } - - replacePath({path = null, historyMethod = null, fragment = null} = {}) { + replaceLocation({path = null, historyMethod = null, fragment = null} = {}) { historyMethod = historyMethod || this.historyMethod; const basePathMatch = this.window.location.pathname.match(this.basePathExpr); @@ -211,10 +202,10 @@ export default class Router { const fullPath = join(basePathMatch[0], path || ``); let url = ``; - if (path && !decodedFragmentsEqual(this.window.location.pathname, fullPath)) { + if (path && !decodedStringsEqual(this.window.location.pathname, fullPath)) { url += fullPath; } - if (fragment && !decodedFragmentsEqual(stripHash(this.window.location.hash), stripHash(fragment))) { + if (fragment && !decodedStringsEqual(stripHash(this.window.location.hash), stripHash(fragment))) { url += `#${stripHash(fragment)}`; } diff --git a/test/browser/router.js b/test/browser/router.js index 4197cbe9..ce8cf93b 100644 --- a/test/browser/router.js +++ b/test/browser/router.js @@ -50,9 +50,39 @@ export class PathRouterApp extends Component { { pathName: `/widget/:id(/)`, hashRoutes: { - '': (stateUpdate, {path}) => Object.assign({text: `Widget ${path[0]}`}, stateUpdate), - 'param/:param': (stateUpdate, {path, hash}) => - Object.assign({text: `Widget ${path[0]} with param ${hash[0]}`}, stateUpdate), + '': (stateUpdate, [id]) => Object.assign({text: `Widget ${id}`}, stateUpdate), + 'param/:param': (stateUpdate, [id], [param]) => + Object.assign({text: `Widget ${id} with param ${param}`}, stateUpdate), + 'optional/:required(/:optional)': (stateUpdate, [id], [required, optional]) => ({ + text: optional + ? `ID: ${id}, two params: ${required} and ${optional}` + : `ID: ${id}, one param: ${required}`, + }), + }, + }, + { + pathName: `/widget/:id/*restOfPath`, + hashRoutes: { + '': (stateUpdate, [id, restOfPath]) => Object.assign({text: `Widget ${id} ${restOfPath}`}), + }, + }, + { + pathName: `/optional/:required(/:optional)`, + hashRoutes: { + '': (stateUpdate, [required, optional]) => ({ + text: optional ? `Two params: ${required} and ${optional}` : `One param: ${required}`, + }), + ':required(/:optional)': (stateUpdate, [requiredPath, optionalPath], [requiredHash, optionalHash]) => ({ + text: `From path: ${requiredPath}${ + optionalPath ? `, ${optionalPath}` : `` + }. From hash: ${requiredHash}${optionalHash ? `, ${optionalHash}` : ``}.`, + }), + }, + }, + { + pathName: `/numeric/:num`, + hashRoutes: { + '': (stateUpdate, [num]) => (isNaN(num) ? false : {text: `Number: ${num}`}), }, }, ], @@ -186,57 +216,53 @@ describe(`hash-only Router`, function () { }); }); - describe(`replaceHash()`, function () { + describe(`replaceLocation()`, function () { it(`updates the URL`, function () { expect(window.location.hash).not.to.equal(`#foo`); - this.routerApp.router.replaceHash(`foo`); + this.routerApp.router.replaceLocation({fragment: `foo`}); expect(window.location.hash).to.equal(`#foo`); }); it(`does not update the URL if hash is the same`, function () { const historyLength = window.history.length; - this.routerApp.router.replaceHash(`foo`); + this.routerApp.router.replaceLocation({fragment: `foo`}); expect(window.history.length).to.equal(historyLength + 1); - this.routerApp.router.replaceHash(`foo`); + this.routerApp.router.replaceLocation({fragment: `foo`}); expect(window.history.length).to.equal(historyLength + 1); // ensure window.location.hash is properly URI-decoded for comparison, // otherwise `widget/bar baz` !== `widget/bar%20baz` may be compared // resulting in possible circular redirect loop - this.routerApp.router.replaceHash(`widget/bar baz`); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); expect(window.history.length).to.equal(historyLength + 2); - this.routerApp.router.replaceHash(`widget/bar baz`); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); expect(window.history.length).to.equal(historyLength + 2); - this.routerApp.router.replaceHash(`widget/bar%20baz`); + this.routerApp.router.replaceLocation({fragment: `widget/bar%20baz`}); expect(window.history.length).to.equal(historyLength + 2); }); it(`uses pushState by default and adds a history entry`, function () { const historyLength = window.history.length; - this.routerApp.router.replaceHash(`foo`); + this.routerApp.router.replaceLocation({fragment: `foo`}); expect(window.history.length).to.equal(historyLength + 1); - this.routerApp.router.replaceHash(`widget/bar baz`); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); expect(window.history.length).to.equal(historyLength + 2); }); it(`can use replaceState to avoid adding a history entry`, function () { const historyLength = window.history.length; - this.routerApp.router.replaceHash(`foo`, { - historyMethod: `replaceState`, - }); + this.routerApp.router.replaceLocation({fragment: `foo`, historyMethod: `replaceState`}); expect(window.history.length).to.equal(historyLength); - this.routerApp.router.replaceHash(`widget/bar baz`, { - historyMethod: `replaceState`, - }); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`, historyMethod: `replaceState`}); expect(window.history.length).to.equal(historyLength); }); }); }); -describe(`path + hash Router`, function () { +describe.only(`path + hash Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; window.location = `#`; @@ -277,5 +303,146 @@ describe(`path + hash Router`, function () { await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15 with param foobar`)); }); - // TODO: trailing slash cases + it(`supports splat params`, async function () { + window.history.pushState(null, null, `/foo/123/widget/15/project/10/view/app`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15 project/10/view/app`)); + }); + + it(`supports optional path params`, async function () { + window.history.pushState(null, null, `/foo/123/optional/wombat`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`One param: wombat`)); + + window.history.pushState(null, null, `/foo/123/optional/wombat/32`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Two params: wombat and 32`)); + }); + + it(`supports path params with optional hash params`, async function () { + window.history.pushState(null, null, `/foo/123/widget/21#optional/wombat`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`ID: 21, one param: wombat`)); + + window.history.pushState(null, null, `/foo/123/widget/21#optional/wombat/32`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`ID: 21, two params: wombat and 32`)); + }); + + it(`supports optional path params with optional hash params`, async function () { + window.history.pushState(null, null, `/foo/123/optional/wombat#bar`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat. From hash: bar.`)); + + window.history.pushState(null, null, `/foo/123/optional/wombat/32#bar`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat, 32. From hash: bar.`)); + + window.history.pushState(null, null, `/foo/123/optional/wombat#bar/21`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat. From hash: bar, 21.`)); + + window.history.pushState(null, null, `/foo/123/optional/wombat/32#bar/21`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat, 32. From hash: bar, 21.`)); + }); + + describe(`pathNavigate()`, function () { + it(`switches to the manually specified route`, async function () { + this.routerApp.router.pathNavigate(`/widget/30`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 30`)); + }); + + it(`updates the URL`, function () { + expect(window.location.pathname).not.to.equal(`/foo/123/widget/30`); + this.routerApp.router.pathNavigate(`/widget/30`); + expect(window.location.pathname).to.equal(`/foo/123/widget/30`); + }); + + it(`does not update the URL if hash is the same`, function () { + const historyLength = window.history.length; + + this.routerApp.router.pathNavigate(`/widget/30`); + expect(window.history.length).to.equal(historyLength + 1); + this.routerApp.router.pathNavigate(`/widget/30`); + expect(window.history.length).to.equal(historyLength + 1); + + // ensure window.location.hash is properly URI-decoded for comparison, + // otherwise `widget/bar baz` !== `widget/bar%20baz` may be compared + // resulting in possible circular redirect loop + this.routerApp.router.pathNavigate(`/widget/bar baz`); + expect(window.history.length).to.equal(historyLength + 2); + this.routerApp.router.pathNavigate(`/widget/bar baz`); + expect(window.history.length).to.equal(historyLength + 2); + this.routerApp.router.pathNavigate(`/widget/bar%20baz`); + expect(window.history.length).to.equal(historyLength + 2); + }); + + it(`supports passing state updates to the route handler`, async function () { + this.routerApp.router.pathNavigate(`/widget/5`, ``, { + additionalText: ` and more!`, + }); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 5 and more!`)); + }); + + it(`supports passing in fragments`, async function () { + this.routerApp.router.pathNavigate(`/widget/5`, `param/foo`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 5 with param foo`)); + }); + + it(`does not apply updates when the route handler returns a falsey result`, async function () { + this.routerApp.router.pathNavigate(`/numeric/42`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`Number: 42`)); + + this.routerApp.router.pathNavigate(`/numeric/notanumber`); + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); + expect(this.routerApp.textContent).to.equal(`Number: 42`); + }); + }); + + describe(`replaceLocation()`, function () { + it(`updates the URL`, function () { + expect(window.location.pathname).not.to.equal(`/foo/123/widget/30`); + this.routerApp.router.replaceLocation({path: `/widget/30`}); + expect(window.location.pathname).to.equal(`/foo/123/widget/30`); + }); + + it(`does not update the URL if path is the same`, function () { + const historyLength = window.history.length; + + this.routerApp.router.replaceLocation({path: `/widget/30`}); + expect(window.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({path: `/widget/30`}); + expect(window.history.length).to.equal(historyLength + 1); + + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(window.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(window.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({path: `/widget/bar%20baz`}); + expect(window.history.length).to.equal(historyLength + 2); + }); + + it(`uses pushState by default and adds a history entry`, function () { + const historyLength = window.history.length; + + this.routerApp.router.replaceLocation({path: `/widget/15`}); + expect(window.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(window.history.length).to.equal(historyLength + 2); + }); + + it(`adds a history entry when updating hash fragment`, function () { + const historyLength = window.history.length; + + this.routerApp.router.replaceLocation({path: `/widget/15`}); + expect(window.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(window.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`, fragment: `param/foobar`}); + expect(window.history.length).to.equal(historyLength + 3); + }); + + it(`can use replaceState to avoid adding a history entry`, async function () { + const historyLength = window.history.length; + + this.routerApp.router.replaceLocation({path: `/`, historyMethod: `replaceState`}); + expect(window.history.length).to.equal(historyLength); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`, historyMethod: `replaceState`}); + expect(window.history.length).to.equal(historyLength); + }); + }); }); From 52bc251f34134f84e88fbe2ac0fe517976250463 Mon Sep 17 00:00:00 2001 From: Jakub Grzegorzewski Date: Tue, 1 Feb 2022 14:13:22 -0600 Subject: [PATCH 6/9] slightly better types --- lib/index.d.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/index.d.ts b/lib/index.d.ts index 082d8b9b..71cede40 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -71,20 +71,37 @@ export interface PanelLifecycleContext { unbindFromComponent?(component: Component): void; } +/** + * Handler that takes a state update and any named parameters from a matched route expression. + * Can optionally return a partial component state to automatically update the component. + * */ +export type HashRouteHandler = (stateUpdate: object, ...params: string[]) => object | null | undefined; + /** Object mapping string hash-route expressions to handler functions */ export interface HashRouteDefinition { - [route: string]: Function | string; + [route: string]: HashRouteHandler | string; } +/** + * Handler that takes a state update and named parameters from a matched path and hash expression. + */ +export type PathRouteHandler = ( + stateUpdate: object, + pathParams: string[], + hashParams: string[], +) => object | null | undefined; + /** Path + hash routing support */ export interface RouteDefinition { - /** Root path where component lives, defaults to '/' */ + /** Root path expression where component lives, defaults to '/' */ basePath?: string; paths: Array<{ - /** String describing relative path to the basename */ + /** Path expression relative to the basePath */ pathName: string; - /** Any hash sub-paths and their handlers */ - hashRoutes: HashRouteDefinition; + /** Any hash routes and their handlers */ + hashRoutes: { + [route: string]: PathRouteHandler | string; + }; }>; } From f6bb44000fba4ff729dd199e3143c00d9adf6f0c Mon Sep 17 00:00:00 2001 From: Jakub Grzegorzewski Date: Tue, 1 Feb 2022 14:33:17 -0600 Subject: [PATCH 7/9] jsdoc + lint --- docs/Component.html | 425 +++++++++++++++++- docs/StateController.html | 4 +- docs/StateStore.html | 4 +- docs/component-utils_hook-helpers.js.html | 4 +- docs/component-utils_index.js.html | 4 +- docs/component-utils_perf.js.html | 69 +++ docs/component-utils_state-controller.js.html | 4 +- docs/component-utils_state-store.js.html | 4 +- docs/component.js.html | 209 ++++++++- docs/global.html | 89 +++- docs/index.html | 4 +- docs/index.js.html | 4 +- docs/isorender_dom-shims.js.html | 94 +++- docs/module-component-utils.html | 4 +- docs/module-isorender_dom-shims.html | 4 +- docs/module-panel.html | 4 +- lib/index.d.ts | 2 +- 17 files changed, 858 insertions(+), 74 deletions(-) create mode 100644 docs/component-utils_perf.js.html diff --git a/docs/Component.html b/docs/Component.html index 99a654c0..c8e906d8 100644 --- a/docs/Component.html +++ b/docs/Component.html @@ -23,7 +23,7 @@
@@ -109,7 +109,7 @@

new Componen
Source:
@@ -696,7 +696,7 @@

Properties
Source:
@@ -783,7 +783,7 @@
Type:
Source:
@@ -868,7 +868,7 @@

_syncAttrs<
Source:
@@ -999,7 +999,7 @@

Parameters:
Source:
@@ -1130,7 +1130,7 @@
Parameters:
Source:
@@ -1212,7 +1212,7 @@

attrsSource:
@@ -1422,7 +1422,7 @@
Parameters:
Source:
@@ -1581,7 +1581,7 @@
Parameters:
Source:
@@ -1752,7 +1752,7 @@
Parameters:
Source:
@@ -1798,6 +1798,161 @@
Example
+

getContext(contextName) → {object}

+ + + + + +
+ Returns the default context of the highest (ie. closest to the document root) ancestor component +that has configured a default context for the context name. If no ancestor context is found, it will +return the component's own default context. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
contextName + + +string + + + + name of context
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + +
Returns:
+ + +
+ context object +
+ + + +
+
+ Type +
+
+ +object + + +
+
+ + + + + + + + + + @@ -1955,7 +2110,7 @@
Parameters:
Source:
@@ -2097,7 +2252,7 @@
Parameters:
Source:
@@ -2238,7 +2393,7 @@
Parameters:
Source:
@@ -2287,6 +2442,238 @@
Example
+

pathNavigate(path, fragmentopt, stateUpdateopt)

+ + + + + +
+ Executes the route handler matching the given URL fragment, and updates +the URL, as though the user had navigated explicitly to that address. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
path + + +string + + + + + + + + + + + + path to navigate to
fragment + + +string + + + + + + <optional>
+ + + + + +
+ + `` + + optional URL fragment to navigate to
stateUpdate + + +object + + + + + + <optional>
+ + + + + +
+ + {} + + update to apply to state object when +routing
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Example
+ +
myApp.pathNavigate('/animals', 'wombat/54', {color: 'blue'});
+ + + + + + + +

setConfig(key, val)

@@ -2406,7 +2793,7 @@
Parameters:
Source:
@@ -2545,7 +2932,7 @@
Parameters:
Source:
@@ -2732,7 +3119,7 @@
Parameters:
Source:
@@ -2892,7 +3279,7 @@
Parameters:
Source:
@@ -2941,7 +3328,7 @@
Example

- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/StateController.html b/docs/StateController.html index d81f8fbd..b43ce726 100644 --- a/docs/StateController.html +++ b/docs/StateController.html @@ -23,7 +23,7 @@
@@ -167,7 +167,7 @@

new St
- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/StateStore.html b/docs/StateStore.html index 353980a7..9a43ae3f 100644 --- a/docs/StateStore.html +++ b/docs/StateStore.html @@ -23,7 +23,7 @@
@@ -163,7 +163,7 @@

new StateSt
- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/component-utils_hook-helpers.js.html b/docs/component-utils_hook-helpers.js.html index eca5ef24..3cff3783 100644 --- a/docs/component-utils_hook-helpers.js.html +++ b/docs/component-utils_hook-helpers.js.html @@ -23,7 +23,7 @@
@@ -61,7 +61,7 @@

component-utils/hook-helpers.js


- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/component-utils_index.js.html b/docs/component-utils_index.js.html index 0b91c8bd..6e3c8850 100644 --- a/docs/component-utils_index.js.html +++ b/docs/component-utils_index.js.html @@ -23,7 +23,7 @@
@@ -72,7 +72,7 @@

component-utils/index.js


- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/component-utils_perf.js.html b/docs/component-utils_perf.js.html new file mode 100644 index 00000000..2d899f1a --- /dev/null +++ b/docs/component-utils_perf.js.html @@ -0,0 +1,69 @@ + + + + + component-utils/perf.js - Documentation + + + + + + + + + + + + + + + + + +
+ +

component-utils/perf.js

+ + + + + + + +
+
+
/**
+ * Attempt to use a high resolution timestamp when in the browswer environment, but fallback to Date.now
+ * When the performance API is not available.
+ */
+export function getNow() {
+  if (typeof performance !== `undefined`) {
+    return performance.now();
+  }
+  return Date.now();
+}
+
+
+
+ + + + +
+ +
+ +
+ Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. +
+ + + + + diff --git a/docs/component-utils_state-controller.js.html b/docs/component-utils_state-controller.js.html index 8bf21124..7696fcf7 100644 --- a/docs/component-utils_state-controller.js.html +++ b/docs/component-utils_state-controller.js.html @@ -23,7 +23,7 @@
@@ -89,7 +89,7 @@

component-utils/state-controller.js


- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/component-utils_state-store.js.html b/docs/component-utils_state-store.js.html index 44c82276..a5e00c5f 100644 --- a/docs/component-utils_state-store.js.html +++ b/docs/component-utils_state-store.js.html @@ -23,7 +23,7 @@
@@ -80,7 +80,7 @@

component-utils/state-store.js


- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/component.js.html b/docs/component.js.html index be23d223..ca5f5c51 100644 --- a/docs/component.js.html +++ b/docs/component.js.html @@ -23,7 +23,7 @@
@@ -42,8 +42,9 @@

component.js

import WebComponent from 'webcomponent'; import {EMPTY_DIV, DOMPatcher, h} from './dom-patcher'; -import Router from './router'; +import Router, {isPathRouteConfig} from './router'; import * as hookHelpers from './component-utils/hook-helpers'; +import {getNow} from './component-utils/perf'; const DOCUMENT_FRAGMENT_NODE = 11; const ATTR_TYPE_DEFAULTS = { @@ -188,6 +189,20 @@

component.js

this.$panelRoot.router.navigate(...arguments); } + /** + * Executes the route handler matching the given URL fragment, and updates + * the URL, as though the user had navigated explicitly to that address. + * @param {string} path - path to navigate to + * @param {string} [fragment=``] - optional URL fragment to navigate to + * @param {object} [stateUpdate={}] - update to apply to state object when + * routing + * @example + * myApp.pathNavigate('/animals', 'wombat/54', {color: 'blue'}); + */ + pathNavigate() { + this.$panelRoot.router.pathNavigate(...arguments); + } + /** * Helper function which will queue a function to be run once the component has been * initialized and added to the DOM. If the component has already had its connectedCallback @@ -283,6 +298,8 @@

component.js

* myWidget.update({name: 'Bob'}); */ update(stateUpdate = {}) { + this.timings.lastUpdateAt = getNow(); + const stateUpdateResult = typeof stateUpdate === `function` ? stateUpdate(this.state) : stateUpdate; return this._updateStore(stateUpdateResult, { store: `state`, @@ -306,6 +323,9 @@

component.js

constructor() { super(); + this.timings = { + createdAt: getNow(), + }; this.panelID = cuid(); @@ -319,6 +339,8 @@

component.js

{}, { css: ``, + defaultContexts: {}, + contexts: [], helpers: {}, routes: {}, template: () => { @@ -326,10 +348,13 @@

component.js

}, updateSync: false, useShadowDom: false, + slowThreshold: 20, }, this.config, ); + this._contexts = new Set(this.getConfig(`contexts`)); + // initialize shared state store, either in `appState` or default to `state` // appState and isStateShared of child components will be overwritten by parent/root // when the component is connected to the hierarchy @@ -353,6 +378,41 @@

component.js

} else { this.el = this; } + + this.postRenderCallback = (elapsedMs) => { + this.timings.lastRenderAt = getNow(); + if (elapsedMs > this.getConfig(`slowThreshold`)) { + const shouldBroadcast = + !this.lastSlowRender || // SHOULD because we've never slow rendered + this.lastSlowRender.time - getNow() > 3000 || // SHOULD because last time was more than three seconds ago + elapsedMs > (this.slowestRenderMs || 0); // SHOULD because this time is slower + + if (shouldBroadcast) { + const comparedToLast = this.lastSlowRender + ? { + // bit of a hack to get the number to only 2 digits of precision + comparedToLast: +((elapsedMs - this.lastSlowRender.elapsedMs) / this.lastSlowRender.elapsedMs).toFixed( + 2, + ), + comparedToSlowest: +((elapsedMs - this.slowestRenderMs) / this.slowestRenderMs).toFixed(2), + } + : undefined; + + this.lastSlowRender = { + time: getNow(), + elapsedMs, + }; + this.slowestRenderMs = Math.max(this.slowestRenderMs || 0, elapsedMs); + this.dispatchEvent( + new CustomEvent(`slowRender`, { + detail: Object.assign(comparedToLast || {}, {elapsedMs, component: this.toString()}), + bubbles: true, + composed: true, + }), + ); + } + } + }; } connectedCallback() { @@ -366,6 +426,16 @@

component.js

return; } this.initializing = true; + this.timings.initializingStartedAt = getNow(); + + for (const attrsSchemaKey of Object.keys(this._attrsSchema)) { + if ( + !Object.prototype.hasOwnProperty.call(this._attrs, attrsSchemaKey) && + this._attrsSchema[attrsSchemaKey].required + ) { + throw new Error(`${this}: is missing required attr '${attrsSchemaKey}'`); + } + } this.$panelChildren = new Set(); @@ -407,13 +477,25 @@

component.js

Object.assign(this.state, this.getJSONAttribute(`data-state`), this._stateFromAttributes()); - if (Object.keys(this.getConfig(`routes`)).length) { + if (isPathRouteConfig(this.getConfig(`routes`))) { + this.router = new Router(this, {historyMethod: this.historyMethod}); + this.pathNavigate(this.router.getRelativePathFromWindow(), window.location.hash); + } else if (Object.keys(this.getConfig(`routes`)).length) { this.router = new Router(this, {historyMethod: this.historyMethod}); this.navigate(window.location.hash); } + for (const contextName of this.getConfig(`contexts`)) { + const context = this.getContext(contextName); + // Context classes can implement an optional `bindToComponent` callback that executes each time the component is connected to the DOM + if (context.bindToComponent) { + context.bindToComponent(this); + } + } + this.domPatcher = new DOMPatcher(this.state, this._render.bind(this), { updateMode: this.getConfig(`updateSync`) ? `sync` : `async`, + postRenderCallback: this.postRenderCallback, }); this.el.appendChild(this.domPatcher.el); @@ -428,6 +510,17 @@

component.js

this.initialized = true; this.initializing = false; + this.timings.initializingCompletedAt = getNow(); + this.dispatchEvent( + new CustomEvent(`componentInitialized`, { + detail: { + elapsedMs: this.timings.initializingCompletedAt - this.timings.initializingStartedAt, + component: this.toString(), + }, + bubbles: true, + composed: true, + }), + ); } disconnectedCallback() { @@ -446,6 +539,14 @@

component.js

this._disconnectedQueue = this._disconnectedQueue.filter((fn) => !fn.removeAfterExec); + for (const contextName of this.getConfig(`contexts`)) { + const context = this.getContext(contextName); + // Context classes can implement an optional `unbindFromComponent` callback that executes each time the component is disconnected from the DOM + if (context.unbindFromComponent) { + context.unbindFromComponent(this); + } + } + if (this.router) { this.router.unregisterListeners(); } @@ -501,6 +602,7 @@

component.js

} attributeChangedCallback(attr, oldVal, newVal) { + this.timings.lastAttributeChangedAt = getNow(); this._updateAttr(attr); if (attr === `style-override`) { @@ -619,9 +721,14 @@

component.js

); } + if (attrSchema.default && attrSchema.required) { + throw new Error(`${this}: attr '${attr}' cannot have both required and default`); + } + const attrSchemaObj = { type: attrType, default: attrSchema.hasOwnProperty(`default`) ? attrSchema.default : ATTR_TYPE_DEFAULTS[attrType], + required: attrSchema.hasOwnProperty(`required`) ? attrSchema.required : false, }; // convert enum to a set for perf @@ -656,12 +763,21 @@

component.js

const attrType = attrSchema.type; let attrValue = null; + if (attrSchema.deprecatedMsg) { + console.warn(`${this}: attr '${attr}' is deprecated. ${attrSchema.deprecatedMsg}`); + } + if (!this.hasAttribute(attr)) { - if (attrType === `boolean` && attrSchema.default) { - console.warn( - `${this}: attr '${attr}' is defaulted to true. Default-true boolean attributes are deprecated and will be removed in a future release`, + if (attrType === `boolean` && (attrSchema.default || attrSchema.required)) { + throw new Error( + `${this}: boolean attr '${attr}' cannot have required or default, since its value is derived from whether dom element has the attribute, not its value`, ); } + + if (attrSchema.required) { + // Early return because a required attribute has no explicit value + return; + } attrValue = attrSchema.default; } else if (attrType === `string`) { attrValue = this.getAttribute(attr); @@ -684,10 +800,6 @@

component.js

} this._attrs[attr] = attrValue; - - if (attrSchema.deprecatedMsg) { - console.warn(`${this}: attr '${attr}' is deprecated. ${attrSchema.deprecatedMsg}`); - } } } @@ -723,6 +835,7 @@

component.js

} else { // update DOM, router, descendants etc. const updateHash = `$fragment` in stateUpdate && stateUpdate.$fragment !== this[store].$fragment; + const updatePath = `$path` in stateUpdate && stateUpdate.$path !== this[store].$path; const cascadeFromRoot = cascade && !this.isPanelRoot; const updateOptions = {cascade, store}; const rootOptions = {exclude: this, cascade, store}; @@ -736,8 +849,12 @@

component.js

if (cascadeFromRoot) { this.$panelRoot.updateSelfAndChildren(stateUpdate, rootOptions); } - if (updateHash) { - this.router.replaceHash(this[store].$fragment); + + if (updateHash || updatePath) { + this.router.replaceLocation({ + path: updatePath ? this[store].$path : null, + fragment: updateHash ? this[store].$fragment : null, + }); } this.runHook(`postUpdate`, updateOptions, stateUpdate); @@ -770,6 +887,72 @@

component.js

} } } + + _findNearestContextAncestor() { + if (!this.isConnected) { + throw new Error(`Cannot determine context before component is connected to the DOM`); + } + + let node = this.parentNode; + while (node) { + if (node._getAvailableContexts) { + return node; + } + if (node.nodeType === DOCUMENT_FRAGMENT_NODE) { + // handle shadow-root + node = node.host; + } else { + node = node.parentNode; + } + } + return null; + } + + _findAndMergeContextsFromAncestors() { + const contextAncestor = this._findNearestContextAncestor(); + const defaultContexts = Object.assign({}, this.getConfig(`defaultContexts`)); + + if (contextAncestor) { + // ancestor contexts must override locally defined defaults + return Object.assign(defaultContexts, contextAncestor._getAvailableContexts()); + } + + return defaultContexts; + } + + _getAvailableContexts() { + if (!this._cachedContexts) { + this._cachedContexts = this._findAndMergeContextsFromAncestors(); + } + return this._cachedContexts; + } + + /** + * Returns the default context of the highest (ie. closest to the document root) ancestor component + * that has configured a default context for the context name. If no ancestor context is found, it will + * return the component's own default context. + * + * @param {string} contextName - name of context + * @returns {object} context object + */ + getContext(contextName) { + if (!contextName) { + throw new Error(`@contextName is null or empty`); + } + + if (!this._contexts.has(contextName)) { + throw new Error(`@contextName must be declared in the "contexts" config array`); + } + + const availableContexts = this._getAvailableContexts(); + if (!(contextName in availableContexts)) { + throw new Error( + `A "${contextName}" context is not available. Check that this component or a DOM ancestor has provided this context in its "defaultContexts" Panel config.`, + ); + } + + return availableContexts[contextName]; + } } export default Component; @@ -785,7 +968,7 @@

component.js


- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/global.html b/docs/global.html index d478a02b..6a3b54b8 100644 --- a/docs/global.html +++ b/docs/global.html @@ -23,7 +23,7 @@
@@ -199,6 +199,89 @@
Example
+ + + + +

getNow()

+ + + + + +
+ Attempt to use a high resolution timestamp when in the browswer environment, but fallback to Date.now +When the performance API is not available. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + @@ -369,7 +452,7 @@
Properties:
Source:
@@ -401,7 +484,7 @@
Properties:

- Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
diff --git a/docs/index.html b/docs/index.html index cb29b6d8..f7a78ad7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -23,7 +23,7 @@
@@ -62,7 +62,7 @@

Home

Classes

  • diff --git a/docs/index.js.html b/docs/index.js.html index e8e1cb85..9b027c6b 100644 --- a/docs/index.js.html +++ b/docs/index.js.html @@ -23,7 +23,7 @@
    @@ -87,7 +87,7 @@

    index.js


    - Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
    diff --git a/docs/isorender_dom-shims.js.html b/docs/isorender_dom-shims.js.html index b1651c4c..55b523c6 100644 --- a/docs/isorender_dom-shims.js.html +++ b/docs/isorender_dom-shims.js.html @@ -23,7 +23,7 @@
    @@ -67,32 +67,89 @@

    isorender/dom-shims.js

    // is already there global.requestAnimationFrame = global.requestAnimationFrame || requestAnimationFrame; +// run a callback for every node of the DOM (sub)tree rooted at the given root node +function walkDomTree(root, callback) { + // basic breadth-first tree traversal (non-recursive) + const breadthQueue = [root]; + while (breadthQueue.length > 0) { + const node = breadthQueue.shift(); + callback(node); + for (const child of Array.from(node.childNodes || [])) { + breadthQueue.push(child); + } + if (node.shadowRoot) { + for (const child of Array.from(node.shadowRoot.childNodes || [])) { + breadthQueue.push(child); + } + } + } +} + // patch DOM insertion functions to call connectedCallback on Custom Elements [`appendChild`, `insertBefore`, `replaceChild`].forEach((funcName) => { const origFunc = Element.prototype[funcName]; - Element.prototype[funcName] = function() { + Element.prototype[funcName] = function () { const child = origFunc.apply(this, arguments); - requestAnimationFrame(() => { - if (!child.initialized && child.connectedCallback) { - child.connectedCallback(); - } - }); + if (this.isConnected) { + walkDomTree(child, function (node) { + if (!node.isConnected) { + node.isConnected = true; + if (node.connectedCallback) { + node.connectedCallback(); + } + } + }); + } }; }); +// patch removeChild to call disconnectedCallback +const origRemoveChild = Element.prototype.removeChild; +Element.prototype.removeChild = function (child) { + origRemoveChild.call(this, child); + if (this.isConnected) { + walkDomTree(child, function (node) { + if (node.isConnected) { + node.isConnected = false; + if (node.disconnectedCallback) { + node.disconnectedCallback(); + } + } + }); + } + return child; +}; + +Node.DOCUMENT_FRAGMENT_NODE = 11; + +// html-element does not provide hasAttribute +Element.prototype.hasAttribute = function (name) { + return this.getAttribute(name) !== null; +}; + // html-element only provides Element (with a lot of the HTMLElement API baked in). // Use HTMLElement as our Web Components-ready extension. class HTMLElement extends Element { setAttribute(name, value) { const oldValue = this.getAttribute(name); super.setAttribute(...arguments); - if (this.attributeChangedCallback && this.__attrIsObserved(name)) { - this.attributeChangedCallback(name, oldValue, value); - } + this.__onAttrChanged(name, oldValue, value); + } + + removeAttribute(name) { + const oldValue = this.getAttribute(name); + super.removeAttribute(...arguments); + this.__onAttrChanged(name, oldValue, null); } - hasAttribute(name) { - return !!this.attributes.find((attr) => attr.name === name); + attachShadow() { + this.shadowRoot = document.createElement(`shadow-root`); + this.shadowRoot.nodeType = Node.DOCUMENT_FRAGMENT_NODE; + this.shadowRoot.host = this; + if (this.isConnected) { + this.shadowRoot.isConnected = true; + } + return this.shadowRoot; } __attrIsObserved(name) { @@ -102,8 +159,10 @@

    isorender/dom-shims.js

    return this.__observedAttrs.includes(name); } - attachShadow() { - return document.createElement(`shadow-root`); + __onAttrChanged(name, oldValue, newValue) { + if (this.attributeChangedCallback && this.__attrIsObserved(name)) { + this.attributeChangedCallback && this.attributeChangedCallback(name, oldValue, newValue); + } } } @@ -114,7 +173,7 @@

    isorender/dom-shims.js

    const customElementsRegistry = (global._customElementsRegistry = global._customElementsRegistry || {}); const originalCreateElement = Document.prototype.createElement; -Document.prototype.createElement = function(tagName) { +Document.prototype.createElement = function (tagName) { tagName = tagName.toLowerCase(); const customElClass = customElementsRegistry[tagName]; let el; @@ -124,6 +183,9 @@

    isorender/dom-shims.js

    } else { el = originalCreateElement(...arguments); } + if (tagName === `body`) { + el.isConnected = true; + } return el; }; @@ -153,7 +215,7 @@

    isorender/dom-shims.js


    - Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
    diff --git a/docs/module-component-utils.html b/docs/module-component-utils.html index 2bb3cb4d..2ed815e1 100644 --- a/docs/module-component-utils.html +++ b/docs/module-component-utils.html @@ -23,7 +23,7 @@
    @@ -296,7 +296,7 @@

    (static) S
    - Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
    diff --git a/docs/module-isorender_dom-shims.html b/docs/module-isorender_dom-shims.html index 7040327a..ec8457e0 100644 --- a/docs/module-isorender_dom-shims.html +++ b/docs/module-isorender_dom-shims.html @@ -23,7 +23,7 @@
    @@ -177,7 +177,7 @@
    Example

    - Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
    diff --git a/docs/module-panel.html b/docs/module-panel.html index 21e48401..152376de 100644 --- a/docs/module-panel.html +++ b/docs/module-panel.html @@ -23,7 +23,7 @@
    @@ -232,7 +232,7 @@

    (static) Co
    - Documentation generated by JSDoc 3.6.5 on Mon Aug 17 2020 14:57:30 GMT-0700 (Pacific Daylight Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme.
    diff --git a/lib/index.d.ts b/lib/index.d.ts index 71cede40..77f4ff66 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -74,7 +74,7 @@ export interface PanelLifecycleContext { /** * Handler that takes a state update and any named parameters from a matched route expression. * Can optionally return a partial component state to automatically update the component. - * */ + */ export type HashRouteHandler = (stateUpdate: object, ...params: string[]) => object | null | undefined; /** Object mapping string hash-route expressions to handler functions */ From 75eaee3b8c07393366a5082390a6dae172261bce Mon Sep 17 00:00:00 2001 From: Jakub Grzegorzewski Date: Tue, 1 Feb 2022 17:23:51 -0600 Subject: [PATCH 8/9] wrap window so we don't hit history limits --- lib/component.js | 4 +- test/browser/router.js | 161 +++++++++++++++++++++++++++-------------- 2 files changed, 108 insertions(+), 57 deletions(-) diff --git a/lib/component.js b/lib/component.js index 143f7950..44477348 100644 --- a/lib/component.js +++ b/lib/component.js @@ -438,10 +438,10 @@ class Component extends WebComponent { Object.assign(this.state, this.getJSONAttribute(`data-state`), this._stateFromAttributes()); if (isPathRouteConfig(this.getConfig(`routes`))) { - this.router = new Router(this, {historyMethod: this.historyMethod}); + this.router = new Router(this, {historyMethod: this.historyMethod, window: this.window}); this.pathNavigate(this.router.getRelativePathFromWindow(), window.location.hash); } else if (Object.keys(this.getConfig(`routes`)).length) { - this.router = new Router(this, {historyMethod: this.historyMethod}); + this.router = new Router(this, {historyMethod: this.historyMethod, window: this.window}); this.navigate(window.location.hash); } diff --git a/test/browser/router.js b/test/browser/router.js index ce8cf93b..44385fe3 100644 --- a/test/browser/router.js +++ b/test/browser/router.js @@ -1,8 +1,51 @@ +import sinon from 'sinon'; + import {expect} from 'chai'; import {nextAnimationFrame, retryable} from 'domsuite'; import {Component, h} from '../../lib'; +/** + * Basic window wrapper to support an unlimited history.length (chromium browsers cap at 50). + */ +class WindowWrapper { + constructor() { + let length = 0; + this.location = new (class { + get pathname() { + return window.location.pathname; + } + + get hash() { + return window.location.hash; + } + })(); + + this.history = new (class { + pushState() { + length++; + window.history.pushState(...arguments); + } + + replaceState() { + window.history.replaceState(...arguments); + } + + get length() { + return length; + } + })(); + } + + addEventListener() { + window.addEventListener(...arguments); + } + + removeEventListener() { + window.removeEventListener(...arguments); + } +} + export class RouterApp extends Component { get config() { return { @@ -100,11 +143,17 @@ describe(`hash-only Router`, function () { window.location = `#`; window.history.replaceState(null, null, `/`); this.routerApp = document.createElement(`router-app`); + this.windowWrapper = new WindowWrapper(); + this.routerApp.window = this.windowWrapper; document.body.appendChild(this.routerApp); await nextAnimationFrame(); }); + afterEach(function () { + sinon.restore(); + }); + it(`is not initialized when component has no routes defined`, function () { const simpleApp = document.createElement(`simple-app`); document.body.appendChild(simpleApp); @@ -179,22 +228,22 @@ describe(`hash-only Router`, function () { }); it(`does not update the URL if hash is the same`, function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.navigate(`foo`); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.navigate(`foo`); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); // ensure window.location.hash is properly URI-decoded for comparison, // otherwise `widget/bar baz` !== `widget/bar%20baz` may be compared // resulting in possible circular redirect loop this.routerApp.router.navigate(`widget/bar baz`); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.navigate(`widget/bar baz`); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.navigate(`widget/bar%20baz`); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); }); it(`supports passing state updates to the route handler`, async function () { @@ -224,50 +273,52 @@ describe(`hash-only Router`, function () { }); it(`does not update the URL if hash is the same`, function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.replaceLocation({fragment: `foo`}); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.replaceLocation({fragment: `foo`}); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); // ensure window.location.hash is properly URI-decoded for comparison, // otherwise `widget/bar baz` !== `widget/bar%20baz` may be compared // resulting in possible circular redirect loop this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.replaceLocation({fragment: `widget/bar%20baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); }); it(`uses pushState by default and adds a history entry`, function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.replaceLocation({fragment: `foo`}); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); }); it(`can use replaceState to avoid adding a history entry`, function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.replaceLocation({fragment: `foo`, historyMethod: `replaceState`}); - expect(window.history.length).to.equal(historyLength); + expect(this.windowWrapper.history.length).to.equal(historyLength); this.routerApp.router.replaceLocation({fragment: `widget/bar baz`, historyMethod: `replaceState`}); - expect(window.history.length).to.equal(historyLength); + expect(this.windowWrapper.history.length).to.equal(historyLength); }); }); }); -describe.only(`path + hash Router`, function () { +describe(`path + hash Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; window.location = `#`; window.history.replaceState(null, null, `/foo/123`); this.routerApp = document.createElement(`path-router-app`); + this.windowWrapper = new WindowWrapper(); + this.routerApp.window = this.windowWrapper; document.body.appendChild(this.routerApp); await nextAnimationFrame(); @@ -278,7 +329,7 @@ describe.only(`path + hash Router`, function () { }); it(`runs index route handler when at root with trailing slash`, async function () { - window.history.pushState(null, null, `/foo/123/`); + this.windowWrapper.history.pushState(null, null, `/foo/123/`); await nextAnimationFrame(); expect(this.routerApp.textContent).to.equal(`Default route!`); }); @@ -289,52 +340,52 @@ describe.only(`path + hash Router`, function () { }); it(`passes path params to route handlers`, async function () { - window.history.pushState(null, null, `/foo/123/widget/15`); + this.windowWrapper.history.pushState(null, null, `/foo/123/widget/15`); await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15`)); }); it(`passes path params to route handlers with trailing slash`, async function () { - window.history.pushState(null, null, `/foo/123/widget/15/`); + this.windowWrapper.history.pushState(null, null, `/foo/123/widget/15/`); await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15`)); }); it(`passes path and hash params to route handlers`, async function () { - window.history.pushState(null, null, `/foo/123/widget/15#param/foobar`); + this.windowWrapper.history.pushState(null, null, `/foo/123/widget/15#param/foobar`); await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15 with param foobar`)); }); it(`supports splat params`, async function () { - window.history.pushState(null, null, `/foo/123/widget/15/project/10/view/app`); + this.windowWrapper.history.pushState(null, null, `/foo/123/widget/15/project/10/view/app`); await retryable(() => expect(this.routerApp.textContent).to.equal(`Widget 15 project/10/view/app`)); }); it(`supports optional path params`, async function () { - window.history.pushState(null, null, `/foo/123/optional/wombat`); + this.windowWrapper.history.pushState(null, null, `/foo/123/optional/wombat`); await retryable(() => expect(this.routerApp.textContent).to.equal(`One param: wombat`)); - window.history.pushState(null, null, `/foo/123/optional/wombat/32`); + this.windowWrapper.history.pushState(null, null, `/foo/123/optional/wombat/32`); await retryable(() => expect(this.routerApp.textContent).to.equal(`Two params: wombat and 32`)); }); it(`supports path params with optional hash params`, async function () { - window.history.pushState(null, null, `/foo/123/widget/21#optional/wombat`); + this.windowWrapper.history.pushState(null, null, `/foo/123/widget/21#optional/wombat`); await retryable(() => expect(this.routerApp.textContent).to.equal(`ID: 21, one param: wombat`)); - window.history.pushState(null, null, `/foo/123/widget/21#optional/wombat/32`); + this.windowWrapper.history.pushState(null, null, `/foo/123/widget/21#optional/wombat/32`); await retryable(() => expect(this.routerApp.textContent).to.equal(`ID: 21, two params: wombat and 32`)); }); it(`supports optional path params with optional hash params`, async function () { - window.history.pushState(null, null, `/foo/123/optional/wombat#bar`); + this.windowWrapper.history.pushState(null, null, `/foo/123/optional/wombat#bar`); await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat. From hash: bar.`)); - window.history.pushState(null, null, `/foo/123/optional/wombat/32#bar`); + this.windowWrapper.history.pushState(null, null, `/foo/123/optional/wombat/32#bar`); await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat, 32. From hash: bar.`)); - window.history.pushState(null, null, `/foo/123/optional/wombat#bar/21`); + this.windowWrapper.history.pushState(null, null, `/foo/123/optional/wombat#bar/21`); await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat. From hash: bar, 21.`)); - window.history.pushState(null, null, `/foo/123/optional/wombat/32#bar/21`); + this.windowWrapper.history.pushState(null, null, `/foo/123/optional/wombat/32#bar/21`); await retryable(() => expect(this.routerApp.textContent).to.equal(`From path: wombat, 32. From hash: bar, 21.`)); }); @@ -351,22 +402,22 @@ describe.only(`path + hash Router`, function () { }); it(`does not update the URL if hash is the same`, function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.pathNavigate(`/widget/30`); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.pathNavigate(`/widget/30`); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); // ensure window.location.hash is properly URI-decoded for comparison, // otherwise `widget/bar baz` !== `widget/bar%20baz` may be compared // resulting in possible circular redirect loop this.routerApp.router.pathNavigate(`/widget/bar baz`); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.pathNavigate(`/widget/bar baz`); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.pathNavigate(`/widget/bar%20baz`); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); }); it(`supports passing state updates to the route handler`, async function () { @@ -401,48 +452,48 @@ describe.only(`path + hash Router`, function () { }); it(`does not update the URL if path is the same`, function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.replaceLocation({path: `/widget/30`}); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.replaceLocation({path: `/widget/30`}); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.replaceLocation({path: `/widget/bar%20baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); }); it(`uses pushState by default and adds a history entry`, function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.replaceLocation({path: `/widget/15`}); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); }); - it(`adds a history entry when updating hash fragment`, function () { - const historyLength = window.history.length; + it(`adds a history entry when updating hash fragment`, async function () { + const historyLength = this.windowWrapper.history.length; this.routerApp.router.replaceLocation({path: `/widget/15`}); - expect(window.history.length).to.equal(historyLength + 1); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); - expect(window.history.length).to.equal(historyLength + 2); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); this.routerApp.router.replaceLocation({path: `/widget/bar baz`, fragment: `param/foobar`}); - expect(window.history.length).to.equal(historyLength + 3); + expect(this.windowWrapper.history.length).to.equal(historyLength + 3); }); it(`can use replaceState to avoid adding a history entry`, async function () { - const historyLength = window.history.length; + const historyLength = this.windowWrapper.history.length; this.routerApp.router.replaceLocation({path: `/`, historyMethod: `replaceState`}); - expect(window.history.length).to.equal(historyLength); + expect(this.windowWrapper.history.length).to.equal(historyLength); this.routerApp.router.replaceLocation({path: `/widget/bar baz`, historyMethod: `replaceState`}); - expect(window.history.length).to.equal(historyLength); + expect(this.windowWrapper.history.length).to.equal(historyLength); }); }); }); From 2567f43a2ef38af90e965e645582fd8f27e00b3e Mon Sep 17 00:00:00 2001 From: jakub Date: Thu, 3 Feb 2022 00:03:08 +0000 Subject: [PATCH 9/9] update public component method types --- docs/Component.html | 2 +- docs/StateController.html | 2 +- docs/StateStore.html | 2 +- docs/component-utils_hook-helpers.js.html | 2 +- docs/component-utils_index.js.html | 2 +- docs/component-utils_perf.js.html | 2 +- docs/component-utils_state-controller.js.html | 2 +- docs/component-utils_state-store.js.html | 2 +- docs/component.js.html | 6 +++--- docs/global.html | 2 +- docs/index.html | 2 +- docs/index.js.html | 2 +- docs/isorender_dom-shims.js.html | 2 +- docs/module-component-utils.html | 2 +- docs/module-isorender_dom-shims.html | 2 +- docs/module-panel.html | 2 +- lib/index.d.ts | 9 +++++++++ 17 files changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/Component.html b/docs/Component.html index c8e906d8..3f0f3e95 100644 --- a/docs/Component.html +++ b/docs/Component.html @@ -3328,7 +3328,7 @@
    Example

    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/StateController.html b/docs/StateController.html index b43ce726..eca09cf4 100644 --- a/docs/StateController.html +++ b/docs/StateController.html @@ -167,7 +167,7 @@

    new St
    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/StateStore.html b/docs/StateStore.html index 9a43ae3f..f6c66fd5 100644 --- a/docs/StateStore.html +++ b/docs/StateStore.html @@ -163,7 +163,7 @@

    new StateSt
    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/component-utils_hook-helpers.js.html b/docs/component-utils_hook-helpers.js.html index 3cff3783..a0249711 100644 --- a/docs/component-utils_hook-helpers.js.html +++ b/docs/component-utils_hook-helpers.js.html @@ -61,7 +61,7 @@

    component-utils/hook-helpers.js


    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/component-utils_index.js.html b/docs/component-utils_index.js.html index 6e3c8850..45ec0d1b 100644 --- a/docs/component-utils_index.js.html +++ b/docs/component-utils_index.js.html @@ -72,7 +72,7 @@

    component-utils/index.js


    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/component-utils_perf.js.html b/docs/component-utils_perf.js.html index 2d899f1a..061f46d1 100644 --- a/docs/component-utils_perf.js.html +++ b/docs/component-utils_perf.js.html @@ -60,7 +60,7 @@

    component-utils/perf.js


    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/component-utils_state-controller.js.html b/docs/component-utils_state-controller.js.html index 7696fcf7..7bd1ee97 100644 --- a/docs/component-utils_state-controller.js.html +++ b/docs/component-utils_state-controller.js.html @@ -89,7 +89,7 @@

    component-utils/state-controller.js


    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/component-utils_state-store.js.html b/docs/component-utils_state-store.js.html index a5e00c5f..db0258bf 100644 --- a/docs/component-utils_state-store.js.html +++ b/docs/component-utils_state-store.js.html @@ -80,7 +80,7 @@

    component-utils/state-store.js


    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/component.js.html b/docs/component.js.html index ca5f5c51..919f129d 100644 --- a/docs/component.js.html +++ b/docs/component.js.html @@ -478,10 +478,10 @@

    component.js

    Object.assign(this.state, this.getJSONAttribute(`data-state`), this._stateFromAttributes()); if (isPathRouteConfig(this.getConfig(`routes`))) { - this.router = new Router(this, {historyMethod: this.historyMethod}); + this.router = new Router(this, {historyMethod: this.historyMethod, window: this.window}); this.pathNavigate(this.router.getRelativePathFromWindow(), window.location.hash); } else if (Object.keys(this.getConfig(`routes`)).length) { - this.router = new Router(this, {historyMethod: this.historyMethod}); + this.router = new Router(this, {historyMethod: this.historyMethod, window: this.window}); this.navigate(window.location.hash); } @@ -968,7 +968,7 @@

    component.js


    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/global.html b/docs/global.html index 6a3b54b8..30bb91b9 100644 --- a/docs/global.html +++ b/docs/global.html @@ -484,7 +484,7 @@
    Properties:

    - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/docs/index.html b/docs/index.html index f7a78ad7..ccf198ad 100644 --- a/docs/index.html +++ b/docs/index.html @@ -62,7 +62,7 @@

    Home

    Classes

    • diff --git a/docs/index.js.html b/docs/index.js.html index 9b027c6b..604e2826 100644 --- a/docs/index.js.html +++ b/docs/index.js.html @@ -87,7 +87,7 @@

      index.js


      - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
      diff --git a/docs/isorender_dom-shims.js.html b/docs/isorender_dom-shims.js.html index 55b523c6..685392a8 100644 --- a/docs/isorender_dom-shims.js.html +++ b/docs/isorender_dom-shims.js.html @@ -215,7 +215,7 @@

      isorender/dom-shims.js


      - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
      diff --git a/docs/module-component-utils.html b/docs/module-component-utils.html index 2ed815e1..89c21910 100644 --- a/docs/module-component-utils.html +++ b/docs/module-component-utils.html @@ -296,7 +296,7 @@

      (static) S
      - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
      diff --git a/docs/module-isorender_dom-shims.html b/docs/module-isorender_dom-shims.html index ec8457e0..f920cf78 100644 --- a/docs/module-isorender_dom-shims.html +++ b/docs/module-isorender_dom-shims.html @@ -177,7 +177,7 @@

      Example

      - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
      diff --git a/docs/module-panel.html b/docs/module-panel.html index 152376de..8f457feb 100644 --- a/docs/module-panel.html +++ b/docs/module-panel.html @@ -232,7 +232,7 @@

      (static) Co
      - Documentation generated by JSDoc 3.6.5 on Tue Feb 01 2022 14:32:08 GMT-0600 (Central Standard Time) using the Minami theme. + Documentation generated by JSDoc 3.6.5 on Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
      diff --git a/lib/index.d.ts b/lib/index.d.ts index 77f4ff66..1bbc6c4a 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -267,10 +267,19 @@ export class Component< val: ConfigOptions[K], ): void; + /** + * Executes the route handler matching the given path with optional fragment, and updates + * the URL, as though the user has navigated explicitly to that address. + */ + pathNavigate(path: string, fragment?: string, stateUpdate?: Partial): void; + /** * Executes the route handler matching the given URL fragment, and updates * the URL, as though the user had navigated explicitly to that address. */ + hashNavigate(fragment: string, stateUpdate?: Partial): void; + + /** alias for hashNavigate to maintain backward compatibility */ navigate(fragment: string, stateUpdate?: Partial): void; /** Run a user-defined hook with the given parameters */