diff --git a/docs/Component.html b/docs/Component.html index 99a654c0..3f0f3e95 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 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 d81f8fbd..eca09cf4 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 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 353980a7..f6c66fd5 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 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 eca5ef24..a0249711 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 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 0b91c8bd..45ec0d1b 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 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 new file mode 100644 index 00000000..061f46d1 --- /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 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 8bf21124..7bd1ee97 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 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 44c82276..db0258bf 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 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 be23d223..919f129d 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) { - this.router = new Router(this, {historyMethod: this.historyMethod}); + if (isPathRouteConfig(this.getConfig(`routes`))) { + 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, window: this.window}); 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 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 d478a02b..30bb91b9 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 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 cb29b6d8..ccf198ad 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..604e2826 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 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 b1651c4c..685392a8 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 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 2bb3cb4d..89c21910 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 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 7040327a..f920cf78 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 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 21e48401..8f457feb 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 Thu Feb 03 2022 00:02:32 GMT+0000 (Coordinated Universal Time) using the Minami theme.
    diff --git a/lib/component.js b/lib/component.js index ed5684d8..44477348 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,8 +437,11 @@ class Component extends WebComponent { Object.assign(this.state, this.getJSONAttribute(`data-state`), this._stateFromAttributes()); - if (Object.keys(this.getConfig(`routes`)).length) { - this.router = new Router(this, {historyMethod: this.historyMethod}); + if (isPathRouteConfig(this.getConfig(`routes`))) { + 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, window: this.window}); 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.replaceLocation({ + path: updatePath ? this[store].$path : null, + fragment: updateHash ? this[store].$fragment : null, + }); } this.runHook(`postUpdate`, updateOptions, stateUpdate); diff --git a/lib/index.d.ts b/lib/index.d.ts index 27f861d1..1bbc6c4a 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -71,6 +71,40 @@ 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]: 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 expression where component lives, defaults to '/' */ + basePath?: string; + paths: Array<{ + /** Path expression relative to the basePath */ + pathName: string; + /** Any hash routes and their handlers */ + hashRoutes: { + [route: string]: PathRouteHandler | string; + }; + }>; +} + export interface ConfigOptions { /** Function transforming state object to virtual dom tree */ template(scope?: StateT): VNode; @@ -99,8 +133,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; @@ -233,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 */ diff --git a/lib/router.js b/lib/router.js index 7cfa52fd..6f4f9db3 100644 --- a/lib/router.js +++ b/lib/router.js @@ -1,15 +1,65 @@ +import {join} from 'path'; + 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); } -// 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, 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]*))?${end ? `$` : ``}`); +} + +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, + }; + }); +} + +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 @@ -18,45 +68,47 @@ 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(`^/$`); + + this.isPathRouteConfig = isPathRouteConfig(routeDefs); + if (this.isPathRouteConfig) { + if (routeDefs.basePath) { + this.basePathExpr = compileRouteExpression(routeDefs.basePath, false); } - 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: new RegExp(`.*`), + hashRoutes: getCompiledHashRoutes(routeDefs), + }, + ]; + } this.registerListeners(options.historyMethod || `pushState`); } 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())); @@ -64,36 +116,72 @@ export default class Router { } unregisterListeners() { - this.window.removeEventListener(`popstate`, this.navigateToHash); + this.window.removeEventListener(`popstate`, this.handleNavigation); this.window.history[this.historyMethod] = this.origChangeStateMethod; } + // alias for hashNavigate to maintain backwards compatibility. navigate(fragment, stateUpdate = {}) { + return this.hashNavigate(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; + } + } + + // no route matched + console.error(`No path route found matching ${currentPath}`); + } + + 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) { + if (decodedStringsEqual(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 - // 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 hashParams = extractParamsFromMatches(matches); const routeHandler = route.handler; if (!routeHandler) { throw `No route handler defined for #${fragment}`; } - const routeStateUpdate = routeHandler.call(this.app, stateUpdate, ...params); + + let routeStateUpdate; + // for path routing config, our handler should expect separated path and hash params + if (this.isPathRouteConfig) { + routeStateUpdate = routeHandler.call(this.app, stateUpdate, pathParams, hashParams); + } 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)); @@ -106,11 +194,27 @@ export default class Router { console.error(`No route found matching #${fragment}`); } - replaceHash(fragment, {historyMethod = null} = {}) { + replaceLocation({path = null, historyMethod = null, fragment = null} = {}) { historyMethod = historyMethod || this.historyMethod; - fragment = stripHash(fragment); - if (!decodedFragmentsEqual(stripHash(this.window.location.hash), fragment)) { - this.window.history[historyMethod](null, null, `#${fragment}`); + + const basePathMatch = this.window.location.pathname.match(this.basePathExpr); + // const fullPath = `${basePathMatch[0]}${path}`.replace(/\/$/, ``); // remove trailing slash, can we do this better? + const fullPath = join(basePathMatch[0], path || ``); + + let url = ``; + if (path && !decodedStringsEqual(this.window.location.pathname, fullPath)) { + url += fullPath; } + if (fragment && !decodedStringsEqual(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, ``).replace(/^\/?/, `/`); } } diff --git a/test/browser/router.js b/test/browser/router.js index 8731d919..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 { @@ -30,17 +73,87 @@ export class RouterApp extends Component { } customElements.define(`router-app`, RouterApp); -describe(`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: { + '': (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}`}), + }, + }, + ], + }, + template: (state) => h(`p`, [`${state.text}${state.additionalText}`]), + }; + } +} + +customElements.define(`path-router-app`, PathRouterApp); + +describe(`hash-only Router`, function () { beforeEach(async function () { document.body.innerHTML = ``; 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); @@ -115,22 +228,22 @@ describe(`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 () { @@ -152,52 +265,235 @@ describe(`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; + const historyLength = this.windowWrapper.history.length; - this.routerApp.router.replaceHash(`foo`); - expect(window.history.length).to.equal(historyLength + 1); - this.routerApp.router.replaceHash(`foo`); - expect(window.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({fragment: `foo`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({fragment: `foo`}); + 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.replaceHash(`widget/bar baz`); - expect(window.history.length).to.equal(historyLength + 2); - this.routerApp.router.replaceHash(`widget/bar baz`); - expect(window.history.length).to.equal(historyLength + 2); - this.routerApp.router.replaceHash(`widget/bar%20baz`); - expect(window.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({fragment: `widget/bar%20baz`}); + 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.replaceHash(`foo`); - expect(window.history.length).to.equal(historyLength + 1); - this.routerApp.router.replaceHash(`widget/bar baz`); - expect(window.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({fragment: `foo`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`}); + 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.replaceHash(`foo`, { - historyMethod: `replaceState`, - }); - expect(window.history.length).to.equal(historyLength); - this.routerApp.router.replaceHash(`widget/bar baz`, { - historyMethod: `replaceState`, + this.routerApp.router.replaceLocation({fragment: `foo`, historyMethod: `replaceState`}); + expect(this.windowWrapper.history.length).to.equal(historyLength); + this.routerApp.router.replaceLocation({fragment: `widget/bar baz`, historyMethod: `replaceState`}); + expect(this.windowWrapper.history.length).to.equal(historyLength); + }); + }); +}); + +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(); + }); + + it(`runs index route handler when at root`, function () { + expect(this.routerApp.textContent).to.equal(`Default route!`); + }); + + it(`runs index route handler when at root with trailing slash`, async function () { + this.windowWrapper.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 path params to route handlers`, async function () { + 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 () { + 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 () { + 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 () { + 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 () { + this.windowWrapper.history.pushState(null, null, `/foo/123/optional/wombat`); + await retryable(() => expect(this.routerApp.textContent).to.equal(`One param: wombat`)); + + 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 () { + 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`)); + + 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 () { + 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.`)); + + 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.`)); + + 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.`)); + + 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.`)); + }); + + 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 = this.windowWrapper.history.length; + + this.routerApp.router.pathNavigate(`/widget/30`); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); + this.routerApp.router.pathNavigate(`/widget/30`); + 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(this.windowWrapper.history.length).to.equal(historyLength + 2); + this.routerApp.router.pathNavigate(`/widget/bar baz`); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + this.routerApp.router.pathNavigate(`/widget/bar%20baz`); + expect(this.windowWrapper.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!`, }); - expect(window.history.length).to.equal(historyLength); + 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 = this.windowWrapper.history.length; + + this.routerApp.router.replaceLocation({path: `/widget/30`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({path: `/widget/30`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); + + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({path: `/widget/bar%20baz`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + }); + + it(`uses pushState by default and adds a history entry`, function () { + const historyLength = this.windowWrapper.history.length; + + this.routerApp.router.replaceLocation({path: `/widget/15`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + }); + + 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(this.windowWrapper.history.length).to.equal(historyLength + 1); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 2); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`, fragment: `param/foobar`}); + expect(this.windowWrapper.history.length).to.equal(historyLength + 3); + }); + + it(`can use replaceState to avoid adding a history entry`, async function () { + const historyLength = this.windowWrapper.history.length; + + this.routerApp.router.replaceLocation({path: `/`, historyMethod: `replaceState`}); + expect(this.windowWrapper.history.length).to.equal(historyLength); + this.routerApp.router.replaceLocation({path: `/widget/bar baz`, historyMethod: `replaceState`}); + expect(this.windowWrapper.history.length).to.equal(historyLength); }); }); });