js-dojo

5 who owns the url

GOAL

make the map mod's two views linkable: open the game at ?view=quest and the mod opens on the quest board, switch views and the address bar follows, press Back and the mod walks back with it.

CONCEPT

a custom element has no window of its own. It is painted into the host's document, so it reads the host's location and writes the host's history - one address bar, one back stack, and the host is the one holding them. An iframe is the other deal entirely: its own window, its own history, its own back stack, and a frame from another origin cannot reach the host's URL at all. So a mod that wants linkable views announces where it went and lets the host do the navigating.

HINT

three separate jobs, and the URL is the only thing joining them. new URLSearchParams(window.location.search).get("view") reads it; window.history.pushState(null, "", "?view=quest") writes it; Back remounts nothing at all - it fires one event at the window, and whoever is listening re-reads the URL.

MIRRORS

the map mod bolted into the HUD. A player pastes a link to the quest board into party chat, a friend opens it and lands on the quest board instead of the world map, and Back walks them out again - all of it through an address bar the mod's author never had a handle on.

Run

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

pnpm koan 10-embedded-mods/05-who-owns-the-url.tsx

Source

// DOJO · Module 10 / Exercise 5 — who owns the url
// GOAL: make the map mod's two views linkable: open the game at
//       ?view=quest and the mod opens on the quest board, switch
//       views and the address bar follows, press Back and the mod
//       walks back with it.
// CONCEPT: a custom element has no window of its own. It is painted
//       into the host's document, so it reads the host's location and
//       writes the host's history - one address bar, one back stack,
//       and the host is the one holding them. An iframe is the other
//       deal entirely: its own window, its own history, its own back
//       stack, and a frame from another origin cannot reach the
//       host's URL at all. So a mod that wants linkable views
//       announces where it went and lets the host do the navigating.
// HINT: three separate jobs, and the URL is the only thing joining
//       them. new URLSearchParams(window.location.search).get("view")
//       reads it; window.history.pushState(null, "", "?view=quest")
//       writes it; Back remounts nothing at all - it fires one event
//       at the window, and whoever is listening re-reads the URL.
// MIRRORS: the map mod bolted into the HUD. A player pastes a link to
//       the quest board into party chat, a friend opens it and lands
//       on the quest board instead of the world map, and Back walks
//       them out again - all of it through an address bar the mod's
//       author never had a handle on.
// Run: pnpm koan 10-embedded-mods/05-who-owns-the-url.tsx

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

afterEach(cleanup);

type ViewName = "world" | "quest";
type ViewChange = CustomEvent<{ view: ViewName }>;

// The mod, as shipped by somebody else. It paints whichever view the
// host writes into its `view` property, and it announces the player's
// clicks as a "viewchange" event. It never touches the URL: it has no
// idea the host even has an address bar.
class ModMap extends HTMLElement {
  private shown: ViewName = "world";

  get view(): ViewName {
    return this.shown;
  }

  set view(next: ViewName) {
    this.shown = next;
    this.paint();
  }

  connectedCallback(): void {
    this.paint();
  }

  private paint(): void {
    this.replaceChildren();
    const label = document.createElement("p");
    label.textContent = `showing ${this.shown}`;
    this.append(label);
    for (const name of ["world", "quest"] as ViewName[]) {
      const button = document.createElement("button");
      button.textContent = `open ${name}`;
      button.addEventListener("click", () => {
        this.dispatchEvent(
          new CustomEvent("viewchange", {
            detail: { view: name },
            bubbles: true,
          }),
        );
      });
      this.append(button);
    }
  }
}

customElements.define("mod-map", ModMap);

// Given: TypeScript has never heard of <mod-map>, so the tag and the
// one property the host writes are declared here. Nothing in this
// block is part of the exercise.
declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "mod-map": { ref?: React.Ref<ModMap>; view?: ViewName };
    }
  }
}

// --- TODO 1 ----------------------------------------------------------
// The mod opens on the world map whatever the address bar says, so a
// link to the quest board lands the friend who clicked it in the wrong
// place. Read the "view" key out of the HOST's location.search and
// return it. A link with no view key, or with a view nobody ships,
// still opens the world map.
function viewFromUrl(): ViewName {
  return "world"; // BROKEN: never looks at the URL
}

// --- TODO 2 ----------------------------------------------------------
// pushView runs whenever the mod announces a move. The mod repaints
// either way - that part already works - but the address bar never
// moves, so the view on screen cannot be linked and Back has nowhere
// to go. Add a history entry for the new view, so the URL reads
// ?view=<next> and the entry the player came from stays behind it.
function pushView(next: ViewName): void {
  void next; // BROKEN: nobody tells the address bar
}

function HudShell() {
  const modRef = useRef<ModMap | null>(null);
  const [view, setView] = useState<ViewName>(viewFromUrl);

  // Given: the host listens for the mod's announcement through a ref.
  // A prop named onViewchange would be dropped on the floor - React
  // registers no listener for it - so the wiring is done by hand.
  useEffect(() => {
    const mod = modRef.current;
    if (!mod) return;
    const onViewchange = (event: Event): void => {
      const next = (event as ViewChange).detail.view;
      setView(next);
      pushView(next);
    };
    mod.addEventListener("viewchange", onViewchange);
    return () => mod.removeEventListener("viewchange", onViewchange);
  }, []);

  // --- TODO 3 --------------------------------------------------------
  // Back remounts nothing. The host's history pointer moves, the URL
  // changes under everyone's feet, and the window gets one "popstate"
  // event - which nobody here is listening for, so the mod goes on
  // showing the view the player already left. Subscribe to popstate on
  // the window; when it fires, re-read the URL (viewFromUrl is already
  // the reader) and put the answer back into `view`. Take the listener
  // off again in the cleanup: this effect is the mod's only tie to the
  // host's window, and it has to let go when the mod unmounts.
  useEffect(() => {
    return () => {}; // BROKEN: subscribes to nothing
  }, []);

  return <mod-map ref={modRef} view={view} />;
}

// Opens the game at a URL, the way a pasted link would, and hands back
// the mod element the host mounted.
function openGameAt(url: string): ModMap {
  window.history.replaceState(null, "", url);
  const { container } = render(<HudShell />);
  return container.querySelector("mod-map") as ModMap;
}

describe("Module 10 / Exercise 5 — who owns the url", () => {
  it("TODO 1 — the mod opens on the view the URL asked for", () => {
    const linked = openGameAt("/?view=quest");
    expect(
      linked.view,
      "TODO 1 — ?view=quest has to open the quest board",
    ).toBe("quest");
    expect(
      linked.textContent,
      "TODO 1 — the mod paints whatever the host writes into it",
    ).toContain("showing quest");

    cleanup(); // same session, a second link with nothing to say
    const bare = openGameAt("/");
    expect(
      bare.view,
      "TODO 1 — a URL with no view key still opens the world map",
    ).toBe("world");
  });

  it("TODO 2 — switching views moves the host's address bar", () => {
    const mod = openGameAt("/?view=world");

    fireEvent.click(screen.getByRole("button", { name: "open quest" }));

    expect(
      mod.textContent,
      "TODO 2 setup — the mod repaints on its own either way",
    ).toContain("showing quest");
    expect(
      window.location.search,
      "TODO 2 — the view on screen has to be the view in the URL",
    ).toBe("?view=quest");
  });

  it("TODO 3 — Back walks the mod back to the previous view", () => {
    const mod = openGameAt("/?view=world");
    fireEvent.click(screen.getByRole("button", { name: "open quest" }));
    expect(
      mod.textContent,
      "TODO 3 setup — start on the quest board, one entry deep",
    ).toContain("showing quest");

    // A real Back button does two things: it moves the history pointer
    // back an entry, then it fires one popstate at the window. The test
    // does both by hand so nothing here waits on a timer.
    window.history.replaceState(null, "", "/?view=world");
    fireEvent(window, new PopStateEvent("popstate"));

    expect(
      mod.view,
      "TODO 3 — popstate has to put the mod back on the world map",
    ).toBe("world");
    expect(
      mod.textContent,
      "TODO 3 — and the mod has to repaint, not just agree quietly",
    ).toContain("showing world");
  });
});

Solution