Skip to content

support private members / methods in JSX render functions #279

Description

@thescientist13

Motivation

Private members / methods are a standard part of JavaScript when working with classes and so should be supported when referenced via a render template, e.g.

export default class QuickLinks extends HTMLElement {
  connectedCallback() {
    // ...
    this.#title = 'My Dropdown';
  }

  #selectOption(event: Event) {
    const selectedLink = this.links.find(
      (link) => link.route === (event.target as HTMLSelectElement).value,
    );

    window.location.href = selectedLink.route;
  }

  render() {
    return (
      <div>
        <h1>{this.#title}</h1>
        <select name="quick-links-dropdown" onchange={this.#selectOption}>
          {/* ... *}
        </select>
      </div>
    );
  }
}

customElements.define("x-quick-links", QuickLinks);

Technical Design

The main issue with this inside templates, is that they'll bind to current element, so the <p> or <select> tag, NOT the custom element host, where title and selectOption are actually bound to. Now this isn't an issue for public members / methods, because WCC's JSX compiler will support binding this references used in templates to the hosting custom element.

For example, this

<button onclick={this.increment}>Increment (+)</button>

Would get turned into

<button onclick="this.getRootNode().host.increment(event)">Increment (+)</button>

BUT, this.getRootNode().host is only a reference to the DOM element, not the underlying class. Such that if we try and use a private method

<select name="quick-links-dropdown" onchange={this.#selectOption}>
  {/* ... */}
</select>

We will get an error about access, which makes sense

Image

The same is true for private members, for example this

<p>Name: {this.#name}</p>

Will yield an error from the compiler

TypeError [Error]: Cannot read properties of undefined (reading 'name')

Additional Context

So it seems in general we'll need to rethink our binding approach if we want to support private methods / members, and figure out a way to really bind this to the custom element instance instead of just the associated DOM node.

Events are maybe easier, since in theory we could just convert any event handlers to addEventListeners

<select name="quick-links-dropdown" onchange={this.#selectOption}>
  {/* ... */}
</select>
connectedCallback() {
  const select = this.shadowRoot.querySelector('select');

  select.addEventListener('change', this.#selectOption);
}

Would just a little bit of extra work / refactoring, but should be doable.


Also saw an option using Symbols

// Define a unique key hidden inside your component module scope
const privateClickKey = Symbol('privateClick');

class SymbolInlineElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // Bind the symbol method to lock down "this"
    this[privateClickKey] = (event) => {
      console.log('Handled securely via symbol token. Target:', event.target);
      console.log('Class context:', this.tagName);
    };
  }

  connectedCallback() {
    // Expose the key safely to the inner layout string via a reference
    this.shadowRoot.innerHTML = `
      <button onclick="this.getRootNode().host[Symbol.for('privateClickKey')]?.(event)">
        Click Me
      </button>
    `;
    
    // Map a global or instance token lookup if required
    this[Symbol.for('privateClickKey')] = this[privateClickKey];
  }
}
customElements.define('symbol-inline-element', SymbolInlineElement);

Another option using custom elements registry

class MyParentComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <div>
        <!-- Deeply nested native element -->
        <button id="myBtn">Click Me</button>
      </div>
    );
  }

  connectedCallback() {
    const btn = this.shadowRoot.querySelector('#myBtn');

    btn.addEventListener('click', function() {
      // 1. Get the ShadowRoot's host element (<my-parent-component>)
      const hostElement = this.getRootNode().host;

      // 2. Direct approach: Read the constructor property directly from the instance
      const componentClassDirect = hostElement.constructor;
      console.log('Class reference via constructor:', componentClassDirect);

      // 3. Alternative registry approach: Lookup via tag name
      const tagName = hostElement.tagName.toLowerCase();
      const componentClassRegistry = customElements.get(tagName);
      console.log('Class reference via registry:', componentClassRegistry);
    });
  }
}
customElements.define('my-parent-component', MyParentComponent);

Metadata

Metadata

Labels

JSXfeatureNew feature or request

Fields

No fields configured for Feature.

Projects

Status
🔖 Ready

Relationships

None yet

Development

No branches or pull requests

Issue actions