diff --git a/src/index.js b/src/index.js index c38ef48..7aacf32 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,10 @@ -import { h, cloneElement, render, hydrate } from 'preact'; +import { + h, + cloneElement, + render, + hydrate, + options as preactOptions, +} from 'preact'; /** * @typedef {import('./internal.d.ts').PreactCustomElement} PreactCustomElement @@ -12,6 +18,7 @@ export default function register(Component, tagName, propNames, options) { const inst = /** @type {PreactCustomElement} */ ( Reflect.construct(HTMLElement, [], PreactElement) ); + inst._options = options; inst._vdomComponent = Component; if (options && options.shadow) { @@ -31,9 +38,7 @@ export default function register(Component, tagName, propNames, options) { } PreactElement.prototype = Object.create(HTMLElement.prototype); PreactElement.prototype.constructor = PreactElement; - PreactElement.prototype.connectedCallback = function () { - connectedCallback.call(this, options); - }; + PreactElement.prototype.connectedCallback = connectedCallback; PreactElement.prototype.attributeChangedCallback = attributeChangedCallback; PreactElement.prototype.disconnectedCallback = disconnectedCallback; @@ -54,10 +59,16 @@ export default function register(Component, tagName, propNames, options) { propNames.forEach((name) => { Object.defineProperty(PreactElement.prototype, name, { get() { - return this._vdom ? this._vdom.props[name] : this._props[name]; + if (this._pendingProps && name in this._pendingProps) { + return this._pendingProps[name]; + } + + return this._vdom + ? this._vdom.props[name] + : this._props && this._props[name]; }, set(v) { - if (this._vdom) { + if (this._vdom || this.isConnected) { this.attributeChangedCallback(name, null, v); } else { if (!this._props) this._props = {}; @@ -94,9 +105,9 @@ function ContextProvider(props) { } /** - * @this {PreactCustomElement} + * @param {PreactCustomElement} element */ -function connectedCallback(options) { +function createVdom(element) { // Obtain a reference to the previous context by pinging the nearest // higher up node that was rendered with Preact. If one Preact component // higher up receives our ping, it will set the `detail` property of @@ -107,15 +118,22 @@ function connectedCallback(options) { bubbles: true, cancelable: true, }); - this.dispatchEvent(event); + element.dispatchEvent(event); const context = event.detail.context; - this._vdom = h( + return h( ContextProvider, - { ...this._props, context }, - toVdom(this, this._vdomComponent, options) + { ...element._props, context }, + toVdom(element, element._vdomComponent, element._options) ); - (this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root); +} + +/** + * @this {PreactCustomElement} + */ +function connectedCallback() { + this._vdom = null; + enqueueRender(this); } /** @@ -127,6 +145,49 @@ function toCamelCase(str) { return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')); } +/** + * @param {PreactCustomElement} element + */ +function enqueueRender(element) { + if (element._renderQueued) return; + + element._renderQueued = true; + (preactOptions.debounceRendering || queueMicrotask)(() => { + element._renderQueued = false; + flushRender(element); + }); +} + +/** + * @param {PreactCustomElement} element + */ +function flushRender(element) { + if (!element.isConnected) { + element._pendingProps = null; + + if (!element._vdom) return; + + element._mounted = false; + render((element._vdom = null), element._root); + return; + } + + if (!element._vdom) { + element._vdom = createVdom(element); + element._pendingProps = null; + } else if (element._pendingProps) { + const props = element._pendingProps; + element._pendingProps = null; + element._vdom = cloneElement(element._vdom, props); + } + + (element._mounted || !element.hasAttribute('hydrate') ? render : hydrate)( + element._vdom, + element._root + ); + element._mounted = true; +} + /** * Changed whenver an attribute of the HTML element changed * @this {PreactCustomElement} @@ -135,24 +196,31 @@ function toCamelCase(str) { * @param {unknown} newValue The new value */ function attributeChangedCallback(name, oldValue, newValue) { - if (!this._vdom) return; // Attributes use `null` as an empty value whereas `undefined` is more // common in pure JS components, especially with default parameters. // When calling `node.removeAttribute()` we'll receive `null` as the new // value. See issue #50. newValue = newValue == null ? undefined : newValue; - const props = {}; - props[name] = newValue; - props[toCamelCase(name)] = newValue; - this._vdom = cloneElement(this._vdom, props); - render(this._vdom, this._root); + if (!this._props) this._props = {}; + this._props[name] = newValue; + this._props[toCamelCase(name)] = newValue; + + if (this._vdom) { + if (!this._pendingProps) this._pendingProps = {}; + this._pendingProps[name] = newValue; + this._pendingProps[toCamelCase(name)] = newValue; + } + + if (this.isConnected) { + enqueueRender(this); + } } /** * @this {PreactCustomElement} */ function disconnectedCallback() { - render((this._vdom = null), this._root); + enqueueRender(this); } /** diff --git a/src/internal.d.ts b/src/internal.d.ts index c984364..b0b15b5 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -1,8 +1,12 @@ import { h, AnyComponent } from 'preact'; export type PreactCustomElement = HTMLElement & { + _options?: unknown; _root: ShadowRoot | HTMLElement; _vdomComponent: AnyComponent; _vdom: ReturnType | null; - _props: Record; + _props?: Record; + _pendingProps?: Record | null; + _renderQueued?: boolean; + _mounted?: boolean; }; diff --git a/test/browser/index.test.jsx b/test/browser/index.test.jsx index 494900b..1f33cd5 100644 --- a/test/browser/index.test.jsx +++ b/test/browser/index.test.jsx @@ -1,19 +1,25 @@ import { assert } from '@open-wc/testing'; -import { h, createContext, Fragment } from 'preact'; +import { h, createContext, Fragment, options as preactOptions } from 'preact'; import { useContext } from 'preact/hooks'; import { act } from 'preact/test-utils'; import registerElement from '../../src/index'; +const flushRender = () => Promise.resolve(); + describe('web components', () => { + let previousDebounceRendering; /** @type {HTMLDivElement} */ let root; beforeEach(() => { + previousDebounceRendering = preactOptions.debounceRendering; + preactOptions.debounceRendering = (callback) => callback(); root = document.createElement('div'); document.body.appendChild(root); }); afterEach(() => { + preactOptions.debounceRendering = previousDebounceRendering; document.body.removeChild(root); }); @@ -40,6 +46,68 @@ describe('web components', () => { ); }); + it('batches multiple attribute changes into one deferred render', async () => { + preactOptions.debounceRendering = undefined; + + let renders = 0; + function Clock({ hours = '', minutes = '' }) { + renders++; + return ( + + {hours}:{minutes} + + ); + } + + registerElement(Clock, 'x-batched-clock', ['hours', 'minutes']); + + const el = document.createElement('x-batched-clock'); + root.appendChild(el); + + assert.equal(renders, 0); + assert.equal(el.innerHTML, ''); + + await flushRender(); + + assert.equal(renders, 1); + assert.equal(el.querySelector('span').textContent, ':'); + + el.setAttribute('hours', '10'); + el.setAttribute('minutes', '30'); + + assert.equal(renders, 1); + assert.equal(el.querySelector('span').textContent, ':'); + + await flushRender(); + + assert.equal(renders, 2); + assert.equal(el.querySelector('span').textContent, '10:30'); + }); + + it('defers mount and unmount', async () => { + preactOptions.debounceRendering = undefined; + + function Clock() { + return ready; + } + + registerElement(Clock, 'x-deferred-mount', []); + + const el = document.createElement('x-deferred-mount'); + root.appendChild(el); + + assert.equal(el.innerHTML, ''); + + await flushRender(); + assert.equal(el.innerHTML, 'ready'); + + root.removeChild(el); + assert.equal(el.innerHTML, 'ready'); + + await flushRender(); + assert.equal(el.innerHTML, ''); + }); + // #50 it('remove attributes without crashing', () => { function NullProps({ size = 'md' }) { @@ -344,5 +412,26 @@ describe('web components', () => { }); assert.equal(getShadowHTML(), '

Active theme: sunny

'); }); + + it('passes context over custom element boundaries with deferred mounts', async () => { + preactOptions.debounceRendering = undefined; + + const el = document.createElement('x-parent'); + const noSlot = document.createElement('x-display-theme'); + el.appendChild(noSlot); + + root.appendChild(el); + await flushRender(); + + const getShadowHTML = () => + document.querySelector('x-display-theme').shadowRoot.innerHTML; + assert.equal(getShadowHTML(), '

Active theme: dark

'); + + await act(async () => { + el.setAttribute('theme', 'sunny'); + await flushRender(); + }); + assert.equal(getShadowHTML(), '

Active theme: sunny

'); + }); }); }); diff --git a/test/browser/options.test.jsx b/test/browser/options.test.jsx index dee9b28..db71216 100644 --- a/test/browser/options.test.jsx +++ b/test/browser/options.test.jsx @@ -1,17 +1,21 @@ import { assert } from '@open-wc/testing'; -import { h } from 'preact'; +import { h, options as preactOptions } from 'preact'; import registerElement from '../../src/index'; describe('options bag', () => { + let previousDebounceRendering; /** @type {HTMLDivElement} */ let root; beforeEach(() => { + previousDebounceRendering = preactOptions.debounceRendering; + preactOptions.debounceRendering = (callback) => callback(); root = document.createElement('div'); document.body.appendChild(root); }); afterEach(() => { + preactOptions.debounceRendering = previousDebounceRendering; document.body.removeChild(root); }); diff --git a/test/browser/static-properties.test.jsx b/test/browser/static-properties.test.jsx index 4ae91fe..c3b928a 100644 --- a/test/browser/static-properties.test.jsx +++ b/test/browser/static-properties.test.jsx @@ -1,17 +1,21 @@ import { assert } from '@open-wc/testing'; -import { h, Component } from 'preact'; +import { h, Component, options as preactOptions } from 'preact'; import registerElement from '../../src/index'; describe('static properties', () => { + let previousDebounceRendering; /** @type {HTMLDivElement} */ let root; beforeEach(() => { + previousDebounceRendering = preactOptions.debounceRendering; + preactOptions.debounceRendering = (callback) => callback(); root = document.createElement('div'); document.body.appendChild(root); }); afterEach(() => { + preactOptions.debounceRendering = previousDebounceRendering; document.body.removeChild(root); });