Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 88 additions & 20 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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;

Expand All @@ -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 = {};
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand All @@ -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}
Expand All @@ -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);
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/internal.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { h, AnyComponent } from 'preact';

export type PreactCustomElement = HTMLElement & {
_options?: unknown;
_root: ShadowRoot | HTMLElement;
_vdomComponent: AnyComponent;
_vdom: ReturnType<typeof h> | null;
_props: Record<string, unknown>;
_props?: Record<string, unknown>;
_pendingProps?: Record<string, unknown> | null;
_renderQueued?: boolean;
_mounted?: boolean;
};
91 changes: 90 additions & 1 deletion test/browser/index.test.jsx
Original file line number Diff line number Diff line change
@@ -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);
});

Expand All @@ -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 (
<span>
{hours}:{minutes}
</span>
);
}

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 <span>ready</span>;
}

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, '<span>ready</span>');

root.removeChild(el);
assert.equal(el.innerHTML, '<span>ready</span>');

await flushRender();
assert.equal(el.innerHTML, '');
});

// #50
it('remove attributes without crashing', () => {
function NullProps({ size = 'md' }) {
Expand Down Expand Up @@ -344,5 +412,26 @@ describe('web components', () => {
});
assert.equal(getShadowHTML(), '<p>Active theme: sunny</p>');
});

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(), '<p>Active theme: dark</p>');

await act(async () => {
el.setAttribute('theme', 'sunny');
await flushRender();
});
assert.equal(getShadowHTML(), '<p>Active theme: sunny</p>');
});
});
});
6 changes: 5 additions & 1 deletion test/browser/options.test.jsx
Original file line number Diff line number Diff line change
@@ -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);
});

Expand Down
6 changes: 5 additions & 1 deletion test/browser/static-properties.test.jsx
Original file line number Diff line number Diff line change
@@ -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);
});

Expand Down
Loading