1

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>
gulima
  • 139
  • 2
  • 11
  • I'm not sure what you're trying to achieve. Why do you want to remove the "Dismiss" button? Don't you actually want to remove the whole `my-alert` element? – Thomas Nov 13 '20 at 13:17
  • @Thomas, I know this sounds weird. But I just need to write an example to ilustrate the question, that is: "How can I safely manipulate DOM in a StencilJS component?", generically speaking. Sorry if it's not so clear. – gulima Nov 14 '20 at 20:59

1 Answers1

2

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.

Thomas
  • 8,426
  • 1
  • 25
  • 49
  • I appreciate your commitment to answer. But it has not yet reached the key point of the question, which is: at some point in the component's life cycle, I can call the `dismiss` method, but the `dismissButton` reference does not yet exist. Do StencilJS provide some resource, something I should test or I should wait? Or maybe I'm just misunderstanding some concept? – gulima Nov 17 '20 at 12:47
  • Thank you very much, @Thomas! I think that was the main point: avoid to manipulate DOM. I'm developing UI components for a very long time using jQuery, so... DOM manipulation is "on my blood". I have to change this paradigm at working with Stencil. – gulima Nov 18 '20 at 15:06
  • Exactly, I had the same thing when I first started with Stencil. But after a while I found it much easier since you're primarily changing state which is then reflected in the DOM (so no more `data-*` attributes etc.). I recommend looking at other projects built with Stencil (e.g. [Ionic](https://github.com/ionic-team/ionic-framework/tree/master/core)]) for some inspiration and best practices. – Thomas Nov 19 '20 at 13:37