js-dojo

5 who owns the url

GOAL

make the report panel's two views linkable: open the app at ?view=chart and the panel opens on the chart, switch views and the address bar follows, press Back and the panel walks back with it.

CONCEPT

a custom element has no window of its own. It is rendered 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 an embedded element that wants linkable views announces where it went and lets the host navigate.

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=chart") writes it; Back remounts nothing at all - it fires one event at the window, and whoever is listening re-reads the URL.

MIRRORS

a reporting widget another team ships, dropped into a dashboard page. Someone pastes a link to the chart view into a ticket, a colleague opens it and lands on the chart instead of the table, and Back takes them out again - all of it through an address bar the widget'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 report panel's two views linkable: open the app at
//       ?view=chart and the panel opens on the chart, switch views
//       and the address bar follows, press Back and the panel walks
//       back with it.
// CONCEPT: a custom element has no window of its own. It is rendered
//       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 an embedded element that wants linkable
//       views announces where it went and lets the host navigate.
// 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=chart")
//       writes it; Back remounts nothing at all - it fires one event
//       at the window, and whoever is listening re-reads the URL.
// MIRRORS: a reporting widget another team ships, dropped into a
//       dashboard page. Someone pastes a link to the chart view into
//       a ticket, a colleague opens it and lands on the chart instead
//       of the table, and Back takes them out again - all of it
//       through an address bar the widget'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 = "table" | "chart";
type ViewChange = CustomEvent<{ view: ViewName }>;

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

  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 ["table", "chart"] 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("report-panel", ReportPanel);

// Given: TypeScript has never heard of <report-panel>, 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 {
      "report-panel": { ref?: React.Ref<HTMLElement>; view?: ViewName };
    }
  }
}

// --- TODO 1 ----------------------------------------------------------
// The panel opens on the table whatever the address bar says, so a
// link to the chart lands the colleague 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 table.
function viewFromUrl(): ViewName {
  return "table"; // BROKEN: never looks at the URL
}

// --- TODO 2 ----------------------------------------------------------
// pushView runs whenever the panel announces a move. The panel
// 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 user came from stays behind it.
function pushView(next: ViewName): void {
  void next; // BROKEN: nobody tells the address bar
}

function DashboardPage() {
  const panelRef = useRef<HTMLElement | null>(null);
  const [view, setView] = useState<ViewName>(viewFromUrl);

  // Given: the host listens for the panel'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 by hand.
  useEffect(() => {
    const panel = panelRef.current;
    if (!panel) return;
    const onViewchange = (event: Event): void => {
      const next = (event as ViewChange).detail.view;
      setView(next);
      pushView(next);
    };
    panel.addEventListener("viewchange", onViewchange);
    return () => panel.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 panel goes on
  // showing the view the user 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 panel's only tie to
  // the host's window, and it has to let go when the panel unmounts.
  useEffect(() => {
    return () => {}; // BROKEN: subscribes to nothing
  }, []);

  return <report-panel ref={panelRef} view={view} />;
}

// Opens the app at a URL, the way a pasted link would, and hands back
// the panel element the host mounted.
function openAppAt(url: string): ReportPanel {
  window.history.replaceState(null, "", url);
  const { container } = render(<DashboardPage />);
  return container.querySelector("report-panel") as ReportPanel;
}

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

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

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

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

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

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

    // 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=table");
    fireEvent(window, new PopStateEvent("popstate"));

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

Solution