domlifecyclestenciljsstencil-component

How can I safely manipulate DOM in a StencilJS component?


I'm trying to safely remove a DOM node from a component made whit StencilJS.

I've put the removing code in a public method - It's what I need.

But, depending on which moment this method is called, I have a problem. If it is called too early, it don't have the DOM node reference yet - it's undefined.

The code below shows the component code (using StencilJS) and the HTML page.

Calling alert.dismiss() in page script is problematic. Calling the same method clicking the button works fine.

There is a safe way to do this remove()? Do StencilJS provide some resource, something I should test or I should wait?

import {
  Component,
  Element,
  h,
  Method
} from '@stencil/core';

@Component({
  tag: 'my-alert',
  scoped: true
})

export class Alert {

  // Reference to dismiss button
  dismissButton: HTMLButtonElement;
  
  /**
   * StencilJS lifecycle methods
   */

  componentDidLoad() {
    // Dismiss button click handler
    this.dismissButton.addEventListener('click', () => this.dismiss());
  }

  // If this method is called from "click" event (handler above), everything is ok.
  // If it is called from a script executed on the page, this.dismissButton may be undefined.
  @Method()
  async dismiss() {
    // Remove button from DOM
    // ** But this.dismissButton is undefined before `render` **
    this.dismissButton.remove();
  }

  render() {
    return (
      <div>
        <slot/>
        <button ref={el => this.dismissButton = el as HTMLButtonElement} >
          Dismiss
        </button>
      </div>
    );
  }
}
<!DOCTYPE html>
<html lang="pt-br">
<head>
  <title>App</title>
</head>
<body>

  <my-alert>Can be dismissed.</my-alert>


  <script type="module">
    import { defineCustomElements } from './node_modules/my-alert/alert.js';
    defineCustomElements();
  
    (async () => {
      await customElements.whenDefined('my-alert');
      let alert = document.querySelector('my-alert');
      // ** Throw an error, because `this.dismissButton`
      // is undefined at this moment.
      await alert.dismiss(); 
    })();

  </script>
</body>
</html>


Solution

  • There are multiple ways to delete DOM nodes in Stencil.

    The simplest is to just call remove() on the element, like any other element:

    document.querySelector('my-alert').remove();
    

    Another would be to have a parent container that manages the my-alert message(s). This is especially useful for things like notifications.

    @Component({...})
    class MyAlertManager {
      @Prop({ mutable: true }) alerts = ['alert 1'];
    
      removeAlert(alert: string) {
        const index = this.alerts.indexOf(alert);
    
        this.alerts = [
          ...this.alerts.slice(0, index),
          ...this.alerts.slice(index + 1, 0),
        ];
      }
    
      render() {
        return (
          <Host>
            {this.alerts.map(alert => <my-alert text={alert} />)}
          </Host>
        );
      }
    }
    

    There are other options and which one to choose will depend on the exact use case.

    Update

    In your specific case I would just render the dismiss button conditionally:

    export class Alert {
      @State() shouldRenderDismissButton = true;
    
      @Method()
      async dismiss() {
        this.shouldRenderDismissButton = false;
      }
    
      render() {
        return (
          <div>
            <slot/>
            {this.shouldRenderDismissButton && <button onClick={() => this.dismiss()}>
              Dismiss
            </button>
          </div>
        );
      }
    }
    

    Generally I would not recommend manually manipulating the DOM in Stencil components directly since that could lead to problems with the next renders since the virtual DOM is out of sync with the real DOM.

    And if you really need to wait for the component to render you can use a Promise:

    class Alert {
      loadPromiseResolve;
      loadPromise = new Promise(resolve => this.loadPromiseResolve = resolve);
    
      @Method()
      async dismiss() {
        // Wait for load
        await this.loadPromise;
    
        // Remove button from DOM
        this.dismissButton.remove();
      }
    
      componentDidLoad() {
        this.loadPromiseResolve();
      }
    }
    

    I previously asked a question about waiting for the next render which would make this a bit cleaner but I don't think it's easily possible at the moment. I might create a feature request for this in the future.