/**
+ * 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();
+}
+
+ 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:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ contextName
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ name of context
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Source:
+ -
+ component.js, line 898
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Returns:
+
+
+
+ context object
+
+
+
+
+
+ -
+ Type
+
+ -
+
+object
+
+
+
+
+
+
+
+
+
+
+
+
+
+
navigate(fragment, stateUpdateopt)
@@ -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:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+ Attributes
+
+
+
+ Default
+
+
+ Description
+
+
+
+
+
+
+
+
+ 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:
+ -
+ component.js, line 162
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
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
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
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
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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
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
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
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:
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
Parameters:
Parameters:
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:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ contextName
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+ name of context
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Source:
+ -
+ component.js, line 898
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Returns:
+
+
+
+ context object
+
+
+
+
+
+ -
+ Type
+
+ -
+
+object
+
+
+
+
+
+
+
+
+
+
+
+
+
+
navigate(fragment, stateUpdateopt)
@@ -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:
+
+
+
+
+
+
+ Name
+
+
+ Type
+
+
+ Attributes
+
+
+
+ Default
+
+
+ Description
+
+
+
+
+
+
+
+
+ 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:
+ -
+ component.js, line 162
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
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
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
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
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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
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
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
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:
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
contextName
- + component.js, line 898 +
path
fragment
+ + + + + +
stateUpdate
+ + + + + +
- + component.js, line 162 +
myApp.pathNavigate('/animals', 'wombat/54', {color: 'blue'});
new St
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
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
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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
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
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
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:
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
new StateSt
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
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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
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
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
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:
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
component-utils/hook-helpers.js
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 @@
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + 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 @@
component-utils/index.js
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
+ + + + + + + ++ + + + + + + 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 @@
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 @@
component-utils/state-controller.js
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 @@
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 @@
component-utils/state-store.js
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 @@
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 @@
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
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 @@ Source:
@@ -401,7 +484,7 @@
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 @@
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: +
- + + + + + + + +
Properties:
Properties:
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 @@