Motivation
Dependency / reactivity tracking in JSX compilation can be tricky, and admittedly the current implementation merged in #108 is rather naive, at least in how it targets DOM elements to update.
So taking this basic counter component render function
export default class Counter extends HTMLElement {
constructor() {
super();
this.count = 0;
}
connectedCallback() {
this.count = parseInt(this.getAttribute('count'), 10) || this.count;
this.render();
}
render() {
const { count } = this;
return (
<div style="width: 50%; margin: 0 auto; text-align:center;">
<button onclick={(this.count -= 1)}> -</button>
<span>
You have clicked <span class="red">{count}</span> times
</span>
<button onclick={(this.count += 1)}> +</button>
</div>
);
}
}
customElements.define('app-counter', Counter);
It leaves a bunch of attributes inlined into elements with instruction sets for what to update (an attribute or text, see <span class="red" data-wcc-count="${this.count}" data-wcc-ins="text">${count}</span>) and uses DOM updates are not very efficient (see the update function)
export const inferredObservability = true;
export default class Counter extends HTMLElement {
static get observedAttributes() {
return ['count'];
}
attributeChangedCallback(name, oldValue, newValue) {
function getValue(value) {
return value.charAt(0) === '{' || value.charAt(0) === '[' ? JSON.parse(value) : !isNaN(value) ? parseInt(value, 10) : value === 'true' || value === 'false' ? value === 'true' ? true : false : value;
}
if (newValue !== oldValue) {
switch (name) {
case 'count':
this.count = getValue(newValue);
break;
}
this.update(name, oldValue, newValue);
}
}
update(name, oldValue, newValue) {
const attr = `data-wcc-${name}`;
const selector = `[${attr}]`;
(this?.shadowRoot || this).querySelectorAll(selector).forEach(el => {
const needle = oldValue === '' ? '' : oldValue ?? el.getAttribute(attr);
switch (el.getAttribute('data-wcc-ins')) {
case 'text':
el.textContent = el.textContent.replace(needle, newValue);
break;
case 'attr':
if (el.hasAttribute(el.getAttribute(attr))) {
el.setAttribute(el.getAttribute(attr), newValue);
}
break;
}
});
if (['count'].includes(name)) {}
}
constructor() {
super();
this.count = 0;
}
connectedCallback() {
this.count = parseInt(this.getAttribute('count'), 10) || this.count;
this.render();
}
render() {
const {count} = this;
this.innerHTML = `<div style="width: 50%; margin: 0 auto; text-align:center;">
<button onclick="this.parentElement.parentElement.count-=1; this.parentElement.parentElement.setAttribute(\'count\', this.parentElement.parentElement.count);"> -</button>
<span>
You have clicked <span class="red" data-wcc-count="${this.count}" data-wcc-ins="text">${count}</span> times
</span>
<button onclick="this.parentElement.parentElement.count+=1; this.parentElement.parentElement.setAttribute(\'count\', this.parentElement.parentElement.count);"> +</button>
</div>`;
}
}
customElements.define('app-counter', Counter);
Technical Design
With Signals, a few things would get simpler, so here is what a Signals based example would look like
export const inferredObservability = true;
export default class Counter extends HTMLElement {
constructor() {
super();
this.count = new Signal.State(0);
this.parity = new Signal.Computed(() => (this.count.get() % 2 === 0 ? "even" : "odd"));
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({
mode: 'open'
});
this.render();
}
}
increment() {
this.count.set(this.count.get() + 1);
}
decrement() {
this.count.set(this.count.get() - 1);
}
render() {
const { count, parity } = this;
return (
<div>
<button onclick={this.increment}>Increment (+)</button>
<button onclick={this.decrement}>Decrement (-)</button>
<button onclick={() => this.count.set(this.count.get() * 2)}>Double (++)</button>
<span>The count is ${count.get()} (${parity.get()})</span>
</div>
)
}
}
customElements.define("app-counter", Counter);
The compiled output would now look something like this
export const inferredObservability = true;
export default class Counter extends HTMLElement {
static $$tmpl = (count, parity) => _wcc`The count is ${count} (${parity})`;
static parseAttribute = (value) => return value.charAt(0) === '{' || value.charAt(0) === '[' ? JSON.parse(value) : !isNaN(value) ? parseInt(value, 10) : value === 'true' || value === 'false' ? value === 'true' ? true : false : value;
static get observedAttributes() {
return ['count'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (newValue !== oldValue) {
switch (name) {
case 'count':
this.count.set(Counter.parseAttribute(newValue));
break;
}
}
}
constructor() {
super();
this.count = new Signal.State(0);
this.parity = new Signal.Computed(() => (this.count.get() % 2 === 0 ? "even" : "odd"));
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({
mode: 'open'
});
this.render();
}
// register effects
effect(() => {
this.shadowRoot.querySelector('span').textContent = SignalCounterComponentRaw.$$tmpl(this.count.get(), this.parity.get());
})
}
increment() {
this.count.set(this.count.get() + 1);
}
decrement() {
this.count.set(this.count.get() - 1);
}
render() {
const { count, parity } = this;
const template = document.createElement('template');
template.innerHTML = `
<button onclick="this.getRootNode().host.increment()">Increment (+)</button>
<button onclick="this.getRootNode().host.decrement()">Decrement (-)</button>
<button onclick="this.getRootNode().host.count.set(this.getRootNode().host.count.get() * 2)">Double (++)</button>
<span>The count is ${count.get()} (${parity.get()})</span>
`;
if (!this.shadowRoot) {
this.shadowRoot.appendChild(template.content.cloneNode(true));
} else {
this.shadowRoot.innerHTML = template.innerHTML;
}
}
}
customElements.define("app-counter", Counter);
Some observations
- With
effects, we have built in reactivity, there is no need for an update function
- No "sprouting" attributes
- No need for "wrapper" elements from userland to "narrow down" holes in the template
- With Signals, updating attributes on the host to the same value already set doesn't trigger an
effect (this is just how Signals work!)
- Overall less boilerplate per component
Some considerations though
- The tricky part will still be mapping signals usage (the "holes" in the template) to effects, but we can probably iterate on this over time
- How to handle the runtime (e.g.
effect) and polyfill
- We should make sure computeds used in the template are NOT mapped to attributes
Additional Context
Have been doing some prototyping here
https://github.com/thescientist13/wcc-jsx-to-signals/pull/1/
Motivation
Dependency / reactivity tracking in JSX compilation can be tricky, and admittedly the current implementation merged in #108 is rather naive, at least in how it targets DOM elements to update.
So taking this basic counter component render function
It leaves a bunch of attributes inlined into elements with instruction sets for what to update (an attribute or text, see
<span class="red" data-wcc-count="${this.count}" data-wcc-ins="text">${count}</span>) and uses DOM updates are not very efficient (see theupdatefunction)Technical Design
With Signals, a few things would get simpler, so here is what a Signals based example would look like
The compiled output would now look something like this
Some observations
effects, we have built in reactivity, there is no need for anupdatefunctioneffect(this is just how Signals work!)Some considerations though
effect) and polyfillAdditional Context
Have been doing some prototyping here
https://github.com/thescientist13/wcc-jsx-to-signals/pull/1/