js-dojo

3 talking back

GOAL

get both of the quest-log mod's ways of talking back to reach the HUD: the callback it calls, and the event it shouts.

CONCEPT

on a custom element React sets a prop as a PROPERTY when the element already has one by that name - unless the prop name starts with "on". React keeps those for itself and turns them into event wiring, so the property the mod reads never gets written and nothing warns. An event travels the other way, and no property can catch one; only a listener on the element itself will.

HINT

nothing throws, so stop hunting for an error. Ask the element what it is holding instead - typeof el.notify says whether React handed the function over or kept it.

MIRRORS

a quest-log panel the HUD loads at runtime. Somebody else wrote it, the HUD cannot edit a line of it, and it reports every sighting twice: once by calling the callback it was handed, once by shouting an event for hosts that would rather listen.

Run

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

pnpm koan 10-embedded-mods/03-talking-back.tsx

Source

// DOJO · Module 10 / Exercise 3 — talking back
// GOAL: get both of the quest-log mod's ways of talking back to reach
//       the HUD: the callback it calls, and the event it shouts.
// CONCEPT: on a custom element React sets a prop as a PROPERTY when
//       the element already has one by that name - unless the prop
//       name starts with "on". React keeps those for itself and turns
//       them into event wiring, so the property the mod reads never
//       gets written and nothing warns. An event travels the other
//       way, and no property can catch one; only a listener on the
//       element itself will.
// HINT: nothing throws, so stop hunting for an error. Ask the element
//       what it is holding instead - typeof el.notify says whether
//       React handed the function over or kept it.
// MIRRORS: a quest-log panel the HUD loads at runtime. Somebody else
//       wrote it, the HUD cannot edit a line of it, and it reports
//       every sighting twice: once by calling the callback it was
//       handed, once by shouting an event for hosts that would rather
//       listen.
// Run: pnpm koan 10-embedded-mods/03-talking-back.tsx

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

afterEach(cleanup);

// The mod: a panel somebody else wrote and shipped as a custom
// element. The HUD loads it at runtime and cannot change a line of it.
// It reports a sighting twice, so a host can pick either channel:
//   1. it CALLS whatever function sits on its own `notify` property;
//   2. it SHOUTS a bubbling "mod-sighting" event.
class QuestLogMod extends HTMLElement {
  // Declared here, so it is a real property on every instance - which
  // is what lets a JSX prop of the same name land as one.
  notify?: (message: string) => void;

  sight(message: string): void {
    this.notify?.(message);
    this.dispatchEvent(
      new CustomEvent("mod-sighting", {
        detail: message,
        bubbles: true,
      }),
    );
  }
}
customElements.define("mod-quest-log", QuestLogMod);

// TypeScript is happy to accept either name below. Only one of them
// survives the trip through React.
declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "mod-quest-log": HTMLAttributes<HTMLElement> & {
        ref?: Ref<HTMLElement | null>;
        notify?: (message: string) => void;
        onNotify?: (message: string) => void;
      };
    }
  }
}

interface QuestHudProps {
  // Every line the HUD manages to hear goes through here.
  record: (line: string) => void;
}

// --- TODO 1 ----------------------------------------------------------
// Down in the JSX, the HUD hands its callback to the mod under the
// name `onNotify`. React never lets an "on" name reach a custom
// element as a property, so the mod's `notify` stays undefined,
// `this.notify?.(message)` short-circuits on the `?.`, and not one
// thing complains. Rename that prop to the plain property name the mod
// actually reads, so the function lands ON the element.
function QuestHud({ record }: QuestHudProps) {
  // --- TODO 2 --------------------------------------------------------
  // The same mod also shouts a bubbling CustomEvent called
  // "mod-sighting", whose `detail` is the message. No property can
  // catch that; a listener has to sit on the element itself. Put the
  // ref below on the <mod-quest-log> in the JSX, then use this effect
  // to addEventListener("mod-sighting", ...) on that element and
  // record `heard: ${detail}`. Take the listener off again in the
  // cleanup function the effect returns.
  const modRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const mod = modRef.current;
    // BROKEN: no ref on the element, no listener here - the mod
    // shouts into an empty room.
    void mod;
    void record;
  }, [record]);

  return <mod-quest-log onNotify={(m) => record(`called: ${m}`)} />;
}

// --- TODO 3 ----------------------------------------------------------
// One question about what TODO 1 was really fixing. React neither
// warned nor threw about `onNotify`, and it set no property and no
// attribute - so it did something else with that function. The last
// test rules out property and attribute, then dispatches a handful of
// event names at the element to find the one React is listening for.
// Work out which name that is and write it down, exactly as spelled.
const EVENT_REACT_LISTENED_FOR: string | null = null;

describe("Module 10 / Exercise 3 — talking back", () => {
  it("TODO 1 — the mod calls the callback it was handed", () => {
    const heard: string[] = [];
    const { container } = render(
      <QuestHud record={(line) => heard.push(line)} />,
    );
    const mod = container.querySelector("mod-quest-log") as QuestLogMod;

    expect(
      typeof mod.notify,
      "TODO 1 — React never handed the callback over",
    ).toBe("function");

    mod.sight("a shrine in the fog");

    expect(
      heard,
      "TODO 1 — the mod called notify, so the HUD wants a line",
    ).toContain("called: a shrine in the fog");
  });

  it("TODO 2 — the mod shouts, the HUD has to be listening", () => {
    const heard: string[] = [];
    const { container, unmount } = render(
      <QuestHud record={(line) => heard.push(line)} />,
    );
    const mod = container.querySelector("mod-quest-log") as QuestLogMod;

    mod.sight("a shrine in the fog");

    expect(
      heard,
      'TODO 2 — nothing listened for the "mod-sighting" event',
    ).toContain("heard: a shrine in the fog");

    // The element outlives the HUD that mounted it, so a listener
    // left behind would keep on firing.
    unmount();
    mod.sight("a second shrine");

    expect(
      heard.filter((line) => line === "heard: a second shrine"),
      "TODO 2 — cleanup has to take that listener off again",
    ).toHaveLength(0);
  });

  it('TODO 3 — where an "on" prop really goes', () => {
    const reached: string[] = [];
    // Kept broken on purpose: the prop name TODO 1 started from.
    const { container } = render(
      <mod-quest-log onNotify={() => void reached.push("hit")} />,
    );
    const mod = container.querySelector("mod-quest-log") as QuestLogMod;
    const dropped = (mod as { onNotify?: unknown }).onNotify;

    // Not a property on the element, and not an attribute either.
    expect(typeof dropped, "TODO 3 setup").toBe("undefined");
    expect(mod.getAttribute("onNotify"), "TODO 3 setup").toBeNull();

    // So React turned it into a listener. Exactly one of these names
    // reaches it; the rest fall on the floor.
    const candidates = [
      "notify",
      "onNotify",
      "onnotify",
      "Notify",
      "mod-sighting",
    ];
    const wired = candidates.filter((name) => {
      reached.length = 0;
      mod.dispatchEvent(new CustomEvent(name));
      return reached.length > 0;
    });
    expect(wired, "TODO 3 setup").toHaveLength(1);

    expect(
      EVENT_REACT_LISTENED_FOR,
      "TODO 3 — name the event React actually listened for",
    ).toBe(wired[0]);
  });
});

Solution