6 styles leak into the host
GOAL
stop the map mod's stylesheet from reaching the game's own HUD, then pay the price that isolation costs its 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 mod's. A shadow root is a separate tree: the same sheet put inside one reaches only what the mod built in there. The price is that anything the mod parks outside itself - a backdrop on document.body, so it can cover the whole screen - 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
the map mod the game loads at runtime. It drops its own sheet into the game's head, and the pause button on the HUD comes back letter-spaced like the mod's own controls. Give the mod a tree of its own and the HUD is left alone - but the backdrop it pins over the whole screen 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.tsxSource
// DOJO · Module 10 / Exercise 6 — styles leak into the host
// GOAL: stop the map mod's stylesheet from reaching the game's own
// HUD, then pay the price that isolation costs its 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
// mod's. A shadow root is a separate tree: the same sheet put
// inside one reaches only what the mod built in there. The
// price is that anything the mod parks outside itself - a
// backdrop on document.body, so it can cover the whole screen -
// 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: the map mod the game loads at runtime. It drops its own
// sheet into the game's head, and the pause button on the HUD
// comes back letter-spaced like the mod's own controls. Give
// the mod a tree of its own and the HUD is left alone - but the
// backdrop it pins over the whole screen 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 MOD_TAG = "mod-map";
// The stylesheet the mod ships. Two rules: one for its own controls,
// one for the backdrop it pins over the screen.
const MOD_CSS = [
"button { letter-spacing: 3px; }",
".mod-backdrop { position: fixed; inset: 0; }",
].join("\n");
class ModMap extends HTMLElement {
sheet: HTMLStyleElement | null = null;
backdrop: HTMLDivElement | null = null;
backdropSheet: HTMLStyleElement | null = null;
connectedCallback(): void {
// --- TODO 1 ------------------------------------------------------
// The mod 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
// HUD's pause button too. Give the mod a tree of its own -
// this.attachShadow({ mode: "open" }) - and build both the sheet
// and the markup inside that root instead. Leave MOD_CSS alone;
// the selector is not the problem.
this.sheet = document.createElement("style");
this.sheet.textContent = MOD_CSS;
document.head.appendChild(this.sheet);
const pin = document.createElement("button");
pin.className = "mod-pin";
pin.textContent = "drop pin";
this.appendChild(pin);
// --- TODO 2 ------------------------------------------------------
// The backdrop is appended to document.body on purpose: it has to
// cover the whole screen, so it cannot be clipped inside the
// panel. Once TODO 1 moves the sheet into the mod's own tree, the
// backdrop is outside that tree and the .mod-backdrop rule never
// reaches it. Split that one rule out of MOD_CSS into a sheet of
// its own, keep it on this.backdropSheet, and append that sheet to
// document.head - the one rule the mod 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 = "mod-backdrop";
document.body.appendChild(this.backdrop);
}
disconnectedCallback(): void {
this.sheet?.remove();
this.backdrop?.remove();
this.backdropSheet?.remove();
}
}
customElements.define(MOD_TAG, ModMap);
// The game's own shell. It loads the mod at runtime into a slot.
function Hud() {
const slot = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const panel = document.createElement(MOD_TAG) as ModMap;
slot.current?.appendChild(panel);
return () => panel.remove();
}, []);
return (
<div className="hud-frame">
<button className="hud-pause">pause</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 mod'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: ModMap): ParentNode => panel.shadowRoot ?? panel;
describe("Module 10 / Exercise 6 — styles leak into the host", () => {
it("TODO 1 — the mod's sheet stops reaching the host's HUD", () => {
render(<Hud />);
const panel = document.querySelector(MOD_TAG) as ModMap | null;
expect(panel, "TODO 1 setup — the mod should be loaded").not.toBeNull();
expect(panel!.sheet, "TODO 1 setup — the mod still ships a sheet")
.not.toBeNull();
const hostPause = screen.getByRole("button", { name: "pause" });
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(
hostPause.matches(selector),
"TODO 1 setup — the host's own button still answers to it",
).toBe(true);
const scope = panel!.sheet!.getRootNode();
expect(
scope.contains(hostPause),
"TODO 1 — the sheet must not sit in a tree holding the host's button",
).toBe(false);
expect(
panel!.shadowRoot,
"TODO 1 — give the mod 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 pin = rootOf(panel!).querySelector(".mod-pin");
expect(
pin !== null && scope.contains(pin),
"TODO 1 — the mod's own markup has to stay inside the sheet's tree",
).toBe(true);
});
it("TODO 2 — the backdrop buys one rule back, and no more", () => {
render(<Hud />);
const panel = document.querySelector(MOD_TAG) as ModMap;
const hostPause = screen.getByRole("button", { name: "pause" });
const hostFrame = document.querySelector(".hud-frame")!;
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 mod'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(
hostPause.matches(selector),
"TODO 2 — and must not reach the host's pause button",
).toBe(false);
expect(
hostFrame.matches(selector),
"TODO 2 — nor anything else the host drew",
).toBe(false);
});
});