js-dojo

6 styles leak into the host

GOAL

stop the filter panel's stylesheet from reaching the host app's own toolbar, then pay the price that isolation costs the panel's backdrop.

CONCEPT

a <style> element styles the tree it sits in. Appended to document.head it sits in the HOST's tree, so a bare selector like "button" reaches the host's buttons as surely as the widget's. A shadow root is a separate tree: the same sheet put inside one reaches only what the widget built in there. The price is that anything the widget parks outside itself - a backdrop on document.body, so it can cover the whole page - falls outside that tree and gets none of those rules back.

HINT

the selector never changes, and it does not have to. What changes is which tree the sheet sits in - ask what getRootNode() says about the sheet before and after.

MIRRORS

a design-system widget another team ships as a custom element. It appends its own sheet to the page's head, and the host's save button comes back letter-spaced like the widget's own controls. Give the widget a tree of its own and the toolbar is left alone - but the backdrop the widget pins over the whole page goes unpainted.

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 10-embedded-mods/06-styles-leak-into-the-host.tsx

Source

// DOJO · Module 10 / Exercise 6 — styles leak into the host
// GOAL: stop the filter panel's stylesheet from reaching the host
//       app's own toolbar, then pay the price that isolation costs
//       the panel's backdrop.
// CONCEPT: a <style> element styles the tree it sits in. Appended to
//       document.head it sits in the HOST's tree, so a bare selector
//       like "button" reaches the host's buttons as surely as the
//       widget's. A shadow root is a separate tree: the same sheet
//       put inside one reaches only what the widget built in there.
//       The price is that anything the widget parks outside itself -
//       a backdrop on document.body, so it can cover the whole page -
//       falls outside that tree and gets none of those rules back.
// HINT: the selector never changes, and it does not have to. What
//       changes is which tree the sheet sits in - ask what
//       getRootNode() says about the sheet before and after.
// MIRRORS: a design-system widget another team ships as a custom
//       element. It appends its own sheet to the page's head, and the
//       host's save button comes back letter-spaced like the widget's
//       own controls. Give the widget a tree of its own and the
//       toolbar is left alone - but the backdrop the widget pins over
//       the whole page goes unpainted.
// Run: pnpm koan 10-embedded-mods/06-styles-leak-into-the-host.tsx

import { useEffect, useRef } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";

afterEach(cleanup);

const WIDGET_TAG = "filter-panel";

// The stylesheet the widget ships. Two rules: one for its own
// controls, one for the backdrop it pins over the page.
const WIDGET_CSS = [
  "button { letter-spacing: 3px; }",
  ".filter-panel-backdrop { position: fixed; inset: 0; }",
].join("\n");

class FilterPanel extends HTMLElement {
  sheet: HTMLStyleElement | null = null;
  backdrop: HTMLDivElement | null = null;
  backdropSheet: HTMLStyleElement | null = null;

  connectedCallback(): void {
    // --- TODO 1 ------------------------------------------------------
    // The widget builds itself straight into the host: its sheet goes
    // on document.head and its markup goes on itself, so both end up
    // in the host's document. The "button" rule therefore reaches the
    // toolbar's save button too. Give the widget a tree of its own -
    // this.attachShadow({ mode: "open" }) - and build both the sheet
    // and the markup inside that root instead. Leave WIDGET_CSS alone;
    // the selector is not the problem.
    this.sheet = document.createElement("style");
    this.sheet.textContent = WIDGET_CSS;
    document.head.appendChild(this.sheet);

    const apply = document.createElement("button");
    apply.className = "filter-panel-apply";
    apply.textContent = "apply";
    this.appendChild(apply);

    // --- TODO 2 ------------------------------------------------------
    // The backdrop is appended to document.body on purpose: it has to
    // cover the whole page, so it cannot be clipped inside the widget.
    // Once TODO 1 moves the sheet into the widget's own tree, the
    // backdrop is outside that tree and the .filter-panel-backdrop
    // rule never reaches it. Split that one rule out of WIDGET_CSS
    // into a sheet of its own, keep it on this.backdropSheet, and
    // append that sheet to document.head - the one rule the widget
    // publishes into the host. Keep its selector narrow enough that
    // nothing of the host's matches it.
    this.backdrop = document.createElement("div");
    this.backdrop.className = "filter-panel-backdrop";
    document.body.appendChild(this.backdrop);
  }

  disconnectedCallback(): void {
    this.sheet?.remove();
    this.backdrop?.remove();
    this.backdropSheet?.remove();
  }
}

customElements.define(WIDGET_TAG, FilterPanel);

// The host app's own page. It mounts the widget at runtime into a slot.
function HostPage() {
  const slot = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const panel = document.createElement(WIDGET_TAG) as FilterPanel;
    slot.current?.appendChild(panel);
    return () => panel.remove();
  }, []);
  return (
    <div className="app-toolbar">
      <button className="app-save">save</button>
      <div ref={slot} />
    </div>
  );
}

// The selector half of a one-rule sheet: everything before the "{".
const selectorOf = (css: string): string =>
  css.slice(0, css.indexOf("{")).trim();

// The widget's markup moves into its shadow tree once TODO 1 lands,
// and screen queries do not walk into one. Look in whichever root
// holds it.
const rootOf = (panel: FilterPanel): ParentNode =>
  panel.shadowRoot ?? panel;

describe("Module 10 / Exercise 6 — styles leak into the host", () => {
  it("TODO 1 — the widget's sheet stops reaching the host", () => {
    render(<HostPage />);
    const panel = document.querySelector(WIDGET_TAG) as FilterPanel | null;
    expect(panel, "TODO 1 setup — the widget should be mounted")
      .not.toBeNull();
    expect(panel!.sheet, "TODO 1 setup — the widget still ships a sheet")
      .not.toBeNull();

    const hostSave = screen.getByRole("button", { name: "save" });
    const selector = selectorOf(panel!.sheet!.textContent!);
    // Unchanged by the fix, and that is the point: the rule is a bare
    // tag selector, and the host uses that tag as well.
    expect(selector, "TODO 1 setup — the rule is a bare tag").toBe("button");
    expect(
      hostSave.matches(selector),
      "TODO 1 setup — the host's own button still answers to it",
    ).toBe(true);

    const scope = panel!.sheet!.getRootNode();
    expect(
      scope.contains(hostSave),
      "TODO 1 — the sheet must not sit in a tree holding the host's button",
    ).toBe(false);
    expect(
      panel!.shadowRoot,
      "TODO 1 — give the widget a shadow root of its own",
    ).not.toBeNull();
    expect(
      panel!.shadowRoot!.contains(panel!.sheet),
      "TODO 1 — the sheet belongs inside that shadow root",
    ).toBe(true);

    const apply = rootOf(panel!).querySelector(".filter-panel-apply");
    expect(
      apply !== null && scope.contains(apply),
      "TODO 1 — the widget's own markup has to stay inside that tree",
    ).toBe(true);
  });

  it("TODO 2 — the backdrop buys one rule back, and no more", () => {
    render(<HostPage />);
    const panel = document.querySelector(WIDGET_TAG) as FilterPanel;
    const hostSave = screen.getByRole("button", { name: "save" });
    const hostToolbar = document.querySelector(".app-toolbar")!;

    expect(
      document.body.contains(panel.backdrop),
      "TODO 2 setup — the backdrop is pinned to the host's body",
    ).toBe(true);
    expect(
      panel.shadowRoot !== null && panel.shadowRoot.contains(panel.backdrop),
      "TODO 2 setup — which puts it outside the widget's own tree",
    ).toBe(false);

    expect(
      panel.backdropSheet,
      "TODO 2 — the backdrop needs a sheet of its own",
    ).not.toBeNull();
    expect(
      document.head.contains(panel.backdropSheet),
      "TODO 2 — that sheet has to sit in the host's head to reach it",
    ).toBe(true);

    const selector = selectorOf(panel.backdropSheet!.textContent!);
    expect(
      panel.backdrop!.matches(selector),
      "TODO 2 — the published rule has to reach the backdrop",
    ).toBe(true);
    expect(
      hostSave.matches(selector),
      "TODO 2 — and must not reach the host's save button",
    ).toBe(false);
    expect(
      hostToolbar.matches(selector),
      "TODO 2 — nor anything else the host drew",
    ).toBe(false);
  });
});

Solution