Skip to content

Signals Based JSX compilation #232

@thescientist13

Description

@thescientist13

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/

Metadata

Metadata

Projects

Status

🏗 In progress

Relationships

None yet

Development

No branches or pull requests

Issue actions