js-dojo

1 define and mount

GOAL

ship a mod as a custom element, then mount it from the game shell by writing its tag - no import, no shared build.

CONCEPT

a mod is a class extending HTMLElement, handed to customElements.define under a dashed tag name. From then on the browser builds one every time that tag lands in the document, and calls its connectedCallback so the mod can fill itself in. React writing the tag counts.

HINT

inside connectedCallback `this` IS the element, so the mod paints into itself. And in JSX a lowercase dashed tag is a plain DOM tag, not a reference to a component.

MIRRORS

the relic tracker bolted into the pause screen. The game shipped without it; a player drops the mod in, the shell writes <mod-panel> where the panel belongs, and the panel draws its own contents - the shell knowing nothing about it but the name.

Run

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

pnpm koan 10-embedded-mods/01-define-and-mount.tsx

Source

// DOJO · Module 10 / Exercise 1 — define and mount
// GOAL: ship a mod as a custom element, then mount it from the game
//       shell by writing its tag - no import, no shared build.
// CONCEPT: a mod is a class extending HTMLElement, handed to
//       customElements.define under a dashed tag name. From then on
//       the browser builds one every time that tag lands in the
//       document, and calls its connectedCallback so the mod can
//       fill itself in. React writing the tag counts.
// HINT: inside connectedCallback `this` IS the element, so the mod
//       paints into itself. And in JSX a lowercase dashed tag is a
//       plain DOM tag, not a reference to a component.
// MIRRORS: the relic tracker bolted into the pause screen. The game
//       shipped without it; a player drops the mod in, the shell
//       writes <mod-panel> where the panel belongs, and the panel
//       draws its own contents - the shell knowing nothing about it
//       but the name.
// Run: pnpm koan 10-embedded-mods/01-define-and-mount.tsx

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

afterEach(() => {
  cleanup();
  // cleanup() unmounts the React tree. The elements these tests build
  // by hand are outside it, so the fixture sweeps them up itself.
  document.body.replaceChildren();
});

// Given: JSX only accepts a tag it has been told about. This block is
// for the compiler; the browser needs no such notice.
declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "mod-panel": HTMLAttributes<HTMLElement>;
    }
  }
}

// Given: the mod's own markup. Somebody else wrote it and shipped it.
// The shell never reads this string and never imports it.
const RELIC_LINE = "relics 3 / 7";
const MOD_MARKUP = `<p data-mod="relics">${RELIC_LINE}</p>`;

// Given: the tag the mod claims. A dash in the name is what marks a
// tag as somebody's custom element, which is why you may invent one.
const MOD_TAG = "mod-panel";

// --- TODO 1 ----------------------------------------------------------
// Shipping a mod is two moves, and both are missing.
// (a) connectedCallback runs when the browser puts this element into
//     the document - the mod's first chance to fill itself in. `this`
//     is the element, so give it MOD_MARKUP.
// (b) on the line below the class, hand MOD_TAG and the class to
//     customElements.define, so that the tag means something.
class ModPanel extends HTMLElement {
  connectedCallback(): void {
    // fix me (a)
  }
}
// fix me (b): customElements.define(...)

// --- TODO 2 ----------------------------------------------------------
// The pause screen is where the panel belongs. Write the mod's tag
// inside the section below - lowercase, spelled exactly the way
// MOD_TAG spells it. React hands a lowercase dashed tag straight to
// the document instead of hunting for a component of that name.
function PauseScreen() {
  return (
    <section aria-label="pause screen">
      {/* fix me */}
    </section>
  );
}

// --- TODO 3 ----------------------------------------------------------
// Look at what actually crossed from the mod to the shell. PauseScreen
// imports nothing from the mod, so the shell's bundler never learns
// the class exists. One string is the whole handoff: write it down.
const HANDOFF: string | null = null;

describe("Module 10 / Exercise 1 — define and mount", () => {
  it("TODO 1 — the browser can build the mod from its tag", () => {
    expect(
      customElements.get(MOD_TAG),
      "TODO 1 — register the class with customElements.define",
    ).toBe(ModPanel);

    const panel = document.createElement(MOD_TAG);
    document.body.appendChild(panel);

    expect(
      panel,
      "TODO 1 — the tag must build a ModPanel, not a bare element",
    ).toBeInstanceOf(ModPanel);
    expect(
      panel.textContent,
      "TODO 1 — connectedCallback must paint the mod into itself",
    ).toContain(RELIC_LINE);
  });

  it("TODO 2 — the shell mounts the mod by writing its tag", () => {
    const { container } = render(<PauseScreen />);
    const mounted = container.querySelector(MOD_TAG);

    expect(
      mounted,
      "TODO 2 — the pause screen must render the mod's tag",
    ).not.toBeNull();
    expect(
      document.body.contains(mounted),
      "TODO 2 — the mounted mod must sit in the host's document",
    ).toBe(true);
    expect(
      mounted?.textContent,
      "TODO 2 — the host must see the mod's own markup",
    ).toContain(RELIC_LINE);
  });

  it("TODO 3 — the handoff is a tag name, not an import", () => {
    expect(
      typeof HANDOFF,
      "TODO 3 — write down the one string the shell needs",
    ).toBe("string");

    expect(
      customElements.get(HANDOFF as string),
      "TODO 3 — that name must be the tag the mod registered",
    ).toBe(ModPanel);

    // Nothing but the name travelled, and the name is enough.
    const spare = document.createElement(HANDOFF as string);
    document.body.appendChild(spare);

    expect(
      spare.textContent,
      "TODO 3 — the name alone must be enough to build the mod",
    ).toContain(RELIC_LINE);
  });
});

Solution