Skip to content

Commit ef3b2da

Browse files
committed
Defer synchronous render calls
1 parent b3d3727 commit ef3b2da

5 files changed

Lines changed: 193 additions & 24 deletions

File tree

src/index.js

Lines changed: 88 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { h, cloneElement, render, hydrate } from 'preact';
1+
import {
2+
h,
3+
cloneElement,
4+
render,
5+
hydrate,
6+
options as preactOptions,
7+
} from 'preact';
28

39
/**
410
* @typedef {import('./internal.d.ts').PreactCustomElement} PreactCustomElement
@@ -12,6 +18,7 @@ export default function register(Component, tagName, propNames, options) {
1218
const inst = /** @type {PreactCustomElement} */ (
1319
Reflect.construct(HTMLElement, [], PreactElement)
1420
);
21+
inst._options = options;
1522
inst._vdomComponent = Component;
1623

1724
if (options && options.shadow) {
@@ -31,9 +38,7 @@ export default function register(Component, tagName, propNames, options) {
3138
}
3239
PreactElement.prototype = Object.create(HTMLElement.prototype);
3340
PreactElement.prototype.constructor = PreactElement;
34-
PreactElement.prototype.connectedCallback = function () {
35-
connectedCallback.call(this, options);
36-
};
41+
PreactElement.prototype.connectedCallback = connectedCallback;
3742
PreactElement.prototype.attributeChangedCallback = attributeChangedCallback;
3843
PreactElement.prototype.disconnectedCallback = disconnectedCallback;
3944

@@ -54,10 +59,16 @@ export default function register(Component, tagName, propNames, options) {
5459
propNames.forEach((name) => {
5560
Object.defineProperty(PreactElement.prototype, name, {
5661
get() {
57-
return this._vdom ? this._vdom.props[name] : this._props[name];
62+
if (this._pendingProps && name in this._pendingProps) {
63+
return this._pendingProps[name];
64+
}
65+
66+
return this._vdom
67+
? this._vdom.props[name]
68+
: this._props && this._props[name];
5869
},
5970
set(v) {
60-
if (this._vdom) {
71+
if (this._vdom || this.isConnected) {
6172
this.attributeChangedCallback(name, null, v);
6273
} else {
6374
if (!this._props) this._props = {};
@@ -94,9 +105,9 @@ function ContextProvider(props) {
94105
}
95106

96107
/**
97-
* @this {PreactCustomElement}
108+
* @param {PreactCustomElement} element
98109
*/
99-
function connectedCallback(options) {
110+
function createVdom(element) {
100111
// Obtain a reference to the previous context by pinging the nearest
101112
// higher up node that was rendered with Preact. If one Preact component
102113
// higher up receives our ping, it will set the `detail` property of
@@ -107,15 +118,22 @@ function connectedCallback(options) {
107118
bubbles: true,
108119
cancelable: true,
109120
});
110-
this.dispatchEvent(event);
121+
element.dispatchEvent(event);
111122
const context = event.detail.context;
112123

113-
this._vdom = h(
124+
return h(
114125
ContextProvider,
115-
{ ...this._props, context },
116-
toVdom(this, this._vdomComponent, options)
126+
{ ...element._props, context },
127+
toVdom(element, element._vdomComponent, element._options)
117128
);
118-
(this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root);
129+
}
130+
131+
/**
132+
* @this {PreactCustomElement}
133+
*/
134+
function connectedCallback() {
135+
this._vdom = null;
136+
enqueueRender(this);
119137
}
120138

121139
/**
@@ -127,6 +145,49 @@ function toCamelCase(str) {
127145
return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''));
128146
}
129147

148+
/**
149+
* @param {PreactCustomElement} element
150+
*/
151+
function enqueueRender(element) {
152+
if (element._renderQueued) return;
153+
154+
element._renderQueued = true;
155+
(preactOptions.debounceRendering || queueMicrotask)(() => {
156+
element._renderQueued = false;
157+
flushRender(element);
158+
});
159+
}
160+
161+
/**
162+
* @param {PreactCustomElement} element
163+
*/
164+
function flushRender(element) {
165+
if (!element.isConnected) {
166+
element._pendingProps = null;
167+
168+
if (!element._vdom) return;
169+
170+
element._mounted = false;
171+
render((element._vdom = null), element._root);
172+
return;
173+
}
174+
175+
if (!element._vdom) {
176+
element._vdom = createVdom(element);
177+
element._pendingProps = null;
178+
} else if (element._pendingProps) {
179+
const props = element._pendingProps;
180+
element._pendingProps = null;
181+
element._vdom = cloneElement(element._vdom, props);
182+
}
183+
184+
(element._mounted || !element.hasAttribute('hydrate') ? render : hydrate)(
185+
element._vdom,
186+
element._root
187+
);
188+
element._mounted = true;
189+
}
190+
130191
/**
131192
* Changed whenver an attribute of the HTML element changed
132193
* @this {PreactCustomElement}
@@ -135,24 +196,31 @@ function toCamelCase(str) {
135196
* @param {unknown} newValue The new value
136197
*/
137198
function attributeChangedCallback(name, oldValue, newValue) {
138-
if (!this._vdom) return;
139199
// Attributes use `null` as an empty value whereas `undefined` is more
140200
// common in pure JS components, especially with default parameters.
141201
// When calling `node.removeAttribute()` we'll receive `null` as the new
142202
// value. See issue #50.
143203
newValue = newValue == null ? undefined : newValue;
144-
const props = {};
145-
props[name] = newValue;
146-
props[toCamelCase(name)] = newValue;
147-
this._vdom = cloneElement(this._vdom, props);
148-
render(this._vdom, this._root);
204+
if (!this._props) this._props = {};
205+
this._props[name] = newValue;
206+
this._props[toCamelCase(name)] = newValue;
207+
208+
if (this._vdom) {
209+
if (!this._pendingProps) this._pendingProps = {};
210+
this._pendingProps[name] = newValue;
211+
this._pendingProps[toCamelCase(name)] = newValue;
212+
}
213+
214+
if (this.isConnected) {
215+
enqueueRender(this);
216+
}
149217
}
150218

151219
/**
152220
* @this {PreactCustomElement}
153221
*/
154222
function disconnectedCallback() {
155-
render((this._vdom = null), this._root);
223+
enqueueRender(this);
156224
}
157225

158226
/**

src/internal.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { h, AnyComponent } from 'preact';
22

33
export type PreactCustomElement = HTMLElement & {
4+
_options?: unknown;
45
_root: ShadowRoot | HTMLElement;
56
_vdomComponent: AnyComponent;
67
_vdom: ReturnType<typeof h> | null;
7-
_props: Record<string, unknown>;
8+
_props?: Record<string, unknown>;
9+
_pendingProps?: Record<string, unknown> | null;
10+
_renderQueued?: boolean;
11+
_mounted?: boolean;
812
};

test/browser/index.test.jsx

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import { assert } from '@open-wc/testing';
2-
import { h, createContext, Fragment } from 'preact';
2+
import { h, createContext, Fragment, options as preactOptions } from 'preact';
33
import { useContext } from 'preact/hooks';
44
import { act } from 'preact/test-utils';
55
import registerElement from '../../src/index';
66

7+
const flushRender = () => Promise.resolve();
8+
79
describe('web components', () => {
10+
let previousDebounceRendering;
811
/** @type {HTMLDivElement} */
912
let root;
1013

1114
beforeEach(() => {
15+
previousDebounceRendering = preactOptions.debounceRendering;
16+
preactOptions.debounceRendering = (callback) => callback();
1217
root = document.createElement('div');
1318
document.body.appendChild(root);
1419
});
1520

1621
afterEach(() => {
22+
preactOptions.debounceRendering = previousDebounceRendering;
1723
document.body.removeChild(root);
1824
});
1925

@@ -40,6 +46,68 @@ describe('web components', () => {
4046
);
4147
});
4248

49+
it('batches multiple attribute changes into one deferred render', async () => {
50+
preactOptions.debounceRendering = undefined;
51+
52+
let renders = 0;
53+
function Clock({ hours = '', minutes = '' }) {
54+
renders++;
55+
return (
56+
<span>
57+
{hours}:{minutes}
58+
</span>
59+
);
60+
}
61+
62+
registerElement(Clock, 'x-batched-clock', ['hours', 'minutes']);
63+
64+
const el = document.createElement('x-batched-clock');
65+
root.appendChild(el);
66+
67+
assert.equal(renders, 0);
68+
assert.equal(el.innerHTML, '');
69+
70+
await flushRender();
71+
72+
assert.equal(renders, 1);
73+
assert.equal(el.querySelector('span').textContent, ':');
74+
75+
el.setAttribute('hours', '10');
76+
el.setAttribute('minutes', '30');
77+
78+
assert.equal(renders, 1);
79+
assert.equal(el.querySelector('span').textContent, ':');
80+
81+
await flushRender();
82+
83+
assert.equal(renders, 2);
84+
assert.equal(el.querySelector('span').textContent, '10:30');
85+
});
86+
87+
it('defers mount and unmount', async () => {
88+
preactOptions.debounceRendering = undefined;
89+
90+
function Clock() {
91+
return <span>ready</span>;
92+
}
93+
94+
registerElement(Clock, 'x-deferred-mount', []);
95+
96+
const el = document.createElement('x-deferred-mount');
97+
root.appendChild(el);
98+
99+
assert.equal(el.innerHTML, '');
100+
101+
await flushRender();
102+
assert.equal(el.innerHTML, '<span>ready</span>');
103+
104+
root.removeChild(el);
105+
assert.equal(el.innerHTML, '<span>ready</span>');
106+
107+
await flushRender();
108+
assert.equal(el.innerHTML, '');
109+
});
110+
43111
// #50
44112
it('remove attributes without crashing', () => {
45113
function NullProps({ size = 'md' }) {
@@ -344,5 +412,26 @@ describe('web components', () => {
344412
});
345413
assert.equal(getShadowHTML(), '<p>Active theme: sunny</p>');
346414
});
415+
416+
it('passes context over custom element boundaries with deferred mounts', async () => {
417+
preactOptions.debounceRendering = undefined;
418+
419+
const el = document.createElement('x-parent');
420+
const noSlot = document.createElement('x-display-theme');
421+
el.appendChild(noSlot);
422+
423+
root.appendChild(el);
424+
await flushRender();
425+
426+
const getShadowHTML = () =>
427+
document.querySelector('x-display-theme').shadowRoot.innerHTML;
428+
assert.equal(getShadowHTML(), '<p>Active theme: dark</p>');
429+
430+
await act(async () => {
431+
el.setAttribute('theme', 'sunny');
432+
await flushRender();
433+
});
434+
assert.equal(getShadowHTML(), '<p>Active theme: sunny</p>');
435+
});
347436
});
348437
});

test/browser/options.test.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { assert } from '@open-wc/testing';
2-
import { h } from 'preact';
2+
import { h, options as preactOptions } from 'preact';
33
import registerElement from '../../src/index';
44

55
describe('options bag', () => {
6+
let previousDebounceRendering;
67
/** @type {HTMLDivElement} */
78
let root;
89

910
beforeEach(() => {
11+
previousDebounceRendering = preactOptions.debounceRendering;
12+
preactOptions.debounceRendering = (callback) => callback();
1013
root = document.createElement('div');
1114
document.body.appendChild(root);
1215
});
1316

1417
afterEach(() => {
18+
preactOptions.debounceRendering = previousDebounceRendering;
1519
document.body.removeChild(root);
1620
});
1721

test/browser/static-properties.test.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { assert } from '@open-wc/testing';
2-
import { h, Component } from 'preact';
2+
import { h, Component, options as preactOptions } from 'preact';
33
import registerElement from '../../src/index';
44

55
describe('static properties', () => {
6+
let previousDebounceRendering;
67
/** @type {HTMLDivElement} */
78
let root;
89

910
beforeEach(() => {
11+
previousDebounceRendering = preactOptions.debounceRendering;
12+
preactOptions.debounceRendering = (callback) => callback();
1013
root = document.createElement('div');
1114
document.body.appendChild(root);
1215
});
1316

1417
afterEach(() => {
18+
preactOptions.debounceRendering = previousDebounceRendering;
1519
document.body.removeChild(root);
1620
});
1721

0 commit comments

Comments
 (0)