The Problem: Web Components Misbehaving When Imported as Modules

When developing Custom Elements (Web Components), you might encounter a frustrating bug: a component works perfectly in isolation, but once integrated into a larger modular codebase, its styles stop applying and event delegation breaks or returns unexpected target nodes. You might notice behavior similar to a closed Shadow DOM, even if you didn't explicitly attach a shadow root.

The Root Cause: Global Scope Pollution with self

The primary reason for this behavior usually comes down to variable scope pollution—specifically, misusing the self keyword inside your custom element's class constructor and lifecycle callbacks.

Take a look at this problematic snippet:

class Viewer_1 extends HTMLElement {
  constructor() {
    self = super(); // <-- Problematic assignment
  }
  connectedCallback() {
    let f = BuildViewer_1(this);
    self.append(f); // <-- Appending to window/global scope instead of 'this'
  }
}

In JavaScript, self is a standard global property that points to the Window object (or Worker scope). When you write self = super() or use self.append(f), you are not creating a local reference to the custom element instance. Instead, you are interacting with window.self.

Why This Causes CSS and Event Failures

  • Ignored CSS Styles: Because self.append(f) appends the child node to the global window/document scope rather than the custom element instance (this), the resulting HTML structure becomes sibling elements rather than nested children. A CSS selector like viewer-1 button will fail because the <button> element is actually rendered outside <viewer-1>.
  • Broken Event Target Matching: When a user clicks the button, evt.target correctly points to the button element. However, because the button is not a child of <viewer-1>, checks like e.matches('viewer-1 button') or e.closest('viewer-1') evaluate to false or return unexpected parent nodes.

The Solution: Correctly Use this Reference

To fix this issue, replace all instances of self with this, and call super() without attempting to assign its return value to a global property.

Here is the corrected and clean implementation:

"use strict";

class Viewer_1 extends HTMLElement {
  constructor() {
    super(); // Always call super() first in custom element constructors
  }

  connectedCallback() {
    let f = BuildViewer_1(this);
    this.append(f); // Appends the fragment directly to this <viewer-1> instance
  }
}

customElements.define("viewer-1", Viewer_1);

function BuildViewer_1(thisObj) {
  let f = document.createDocumentFragment();
  let btn = document.createElement("button");
  
  btn.textContent = "Get Value";
  btn.value = "My Value";
  f.append(btn);
  
  return f;
}

Key Takeaways for Custom Element Development

  • Avoid self in Classes: Use this to reference the element instance. If you need to alias this in inner functions, use standard arrow functions or const self = this; scoped explicitly.
  • Validate Element Tree Structure: Inspect the DOM tree using Browser Developer Tools. If your styles aren't applying, verify whether the child elements are actually inside the host custom element tag.
  • Consider Shadow DOM for Isolation: If you intentionally want encapsulated styles and isolated DOM trees, use this.attachShadow({ mode: 'open' }) instead of appending directly to the Light DOM.