js-dojo

3 talking back

GOAL

get both of the feed element's ways of talking back to reach the host page: the callback it calls, and the event it fires.

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 element 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

an activity widget another team ships as a custom element and a React page drops in. The page cannot edit a line of it, and it reports every new entry twice: once by calling the callback it was handed, once by firing 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 feed element's ways of talking back to reach
//       the host page: the callback it calls, and the event it fires.
// 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 element 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: an activity widget another team ships as a custom element
//       and a React page drops in. The page cannot edit a line of it,
//       and it reports every new entry twice: once by calling the
//       callback it was handed, once by firing 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 element: an activity feed another team wrote and shipped as a
// custom element. The host page loads it at runtime and cannot change
// a line of it. It reports each new entry twice, so a host can pick
// either channel:
//   1. it CALLS whatever function sits on its own `notify` property;
//   2. it FIRES a bubbling "feed-entry" event.
class ActivityFeed 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;

  report(message: string): void {
    this.notify?.(message);
    this.dispatchEvent(
      new CustomEvent("feed-entry", {
        detail: message,
        bubbles: true,
      }),
    );
  }
}
customElements.define("activity-feed", ActivityFeed);

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

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

// --- TODO 1 ----------------------------------------------------------
// Down in the JSX, the host hands its callback to the element under
// the name `onNotify`. React never lets an "on" name reach a custom
// element as a property, so the element'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
// element actually reads, so the function lands ON the element.
function FeedHost({ record }: FeedHostProps) {
  // --- TODO 2 --------------------------------------------------------
  // The same element also fires a bubbling CustomEvent called
  // "feed-entry", 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 <activity-feed> in the JSX, then use this effect to
  // addEventListener("feed-entry", ...) on that element and record
  // `heard: ${detail}`. Take the listener off again in the cleanup
  // function the effect returns.
  const feedRef = useRef<HTMLElement | null>(null);

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

  return <activity-feed 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 element calls the callback it was handed", () => {
    const heard: string[] = [];
    const { container } = render(
      <FeedHost record={(line) => heard.push(line)} />,
    );
    const feed = container.querySelector("activity-feed") as ActivityFeed;

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

    feed.report("row 7 saved");

    expect(
      heard,
      "TODO 1 — the element called notify, so the host wants a line",
    ).toContain("called: row 7 saved");
  });

  it("TODO 2 — the element fires, the host has to be listening", () => {
    const heard: string[] = [];
    const { container, unmount } = render(
      <FeedHost record={(line) => heard.push(line)} />,
    );
    const feed = container.querySelector("activity-feed") as ActivityFeed;

    feed.report("row 7 saved");

    expect(
      heard,
      'TODO 2 — nothing listened for the "feed-entry" event',
    ).toContain("heard: row 7 saved");

    // The element outlives the host that mounted it, so a listener
    // left behind would keep on firing.
    unmount();
    feed.report("row 8 saved");

    expect(
      heard.filter((line) => line === "heard: row 8 saved"),
      "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(
      <activity-feed onNotify={() => void reached.push("hit")} />,
    );
    const feed = container.querySelector("activity-feed") as ActivityFeed;
    const dropped = (feed as { onNotify?: unknown }).onNotify;

    // Not a property on the element, and not an attribute either.
    expect(typeof dropped, "TODO 3 setup").toBe("undefined");
    expect(feed.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",
      "feed-entry",
    ];
    const wired = candidates.filter((name) => {
      reached.length = 0;
      feed.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