js-dojo

9 a slice of the url

GOAL

hand the reports app its own slice of the address bar, so a link deep inside it opens on the right screen, its own buttons write real URLs under /reports, and Back walks through its screens and then out of it - with no event, no callback and no sync code between the host and the app.

CONCEPT

a router is the code that turns a path into a screen, and two of them can share one address bar if they split the path between them. The host keeps every URL except one subtree and matches that subtree with a splat route: a pattern ending in /*, which takes the path itself and everything under it. The app is told where its subtree starts - its base href, the prefix its router reads and writes every path under - and from there it does its own routing. A move between two of its screens is still the same host match, so the host renders nothing new and the app is never torn down.

HINT

three small jobs, all on the host's side - the app already does its part. A splat takes whole path segments: "/reports" itself, or anything starting "/reports/". The base is one prop on the tag. And Back fires one popstate at the window, heard by every listener on it: the app repaints its own screen, so the host only has to re-read which of its routes the URL is in.

MIRRORS

an app another team built, with its own screens and its own router, shipped as one element into your dashboard. Your app owns the address bar and hands theirs everything under /reports: a link to their summary screen, pasted into an email, opens on that screen, and Back steps through their screens before it leaves them - and neither team writes a line of routing code for the other.

Run

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

pnpm koan 10-embedded-mods/09-a-slice-of-the-url.tsx

Source

// DOJO · Module 10 / Exercise 9 — a slice of the url
// GOAL: hand the reports app its own slice of the address bar, so a
//       link deep inside it opens on the right screen, its own
//       buttons write real URLs under /reports, and Back walks
//       through its screens and then out of it - with no event, no
//       callback and no sync code between the host and the app.
// CONCEPT: a router is the code that turns a path into a screen, and
//       two of them can share one address bar if they split the path
//       between them. The host keeps every URL except one subtree and
//       matches that subtree with a splat route: a pattern ending in
//       /*, which takes the path itself and everything under it. The
//       app is told where its subtree starts - its base href, the
//       prefix its router reads and writes every path under - and
//       from there it does its own routing. A move between two of its
//       screens is still the same host match, so the host renders
//       nothing new and the app is never torn down.
// HINT: three small jobs, all on the host's side - the app already
//       does its part. A splat takes whole path segments: "/reports"
//       itself, or anything starting "/reports/". The base is one
//       prop on the tag. And Back fires one popstate at the window,
//       heard by every listener on it: the app repaints its own
//       screen, so the host only has to re-read which of its routes
//       the URL is in.
// MIRRORS: an app another team built, with its own screens and its
//       own router, shipped as one element into your dashboard. Your
//       app owns the address bar and hands theirs everything under
//       /reports: a link to their summary screen, pasted into an
//       email, opens on that screen, and Back steps through their
//       screens before it leaves them - and neither team writes a
//       line of routing code for the other.
// Run: pnpm koan 10-embedded-mods/09-a-slice-of-the-url.tsx

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

afterEach(cleanup);

// The app's own route table: a path inside its slice, and the screen
// that path shows.
const SCREENS: Record<string, string> = {
  "/": "overview",
  "/q3/summary": "summary",
  "/settings": "settings",
};

// The reports app, as shipped by another team. It stands in for a
// whole app with a router of its own: it reads its screen from the
// URL, writes a history entry for every move, and repaints on Back -
// every path taken relative to its `base`. It never fires an event at
// the host. The URL is the only thing the two of them share.
class ReportsApp extends HTMLElement {
  // Counts connectedCallback runs. Each one is the whole app booting
  // from nothing: its router, its lazily loaded screens, its state.
  connects = 0;

  private basePath = "";

  // The app's own Back: the URL moved under it, so it reads it again.
  private onPopstate = (): void => this.paint();

  // Where the app's slice of the URL starts. The router strips it off
  // every path it reads and puts it back on every path it writes.
  get base(): string {
    return this.basePath;
  }

  set base(next: string) {
    this.basePath = next;
    if (this.isConnected) this.paint();
  }

  connectedCallback(): void {
    this.connects += 1;
    window.addEventListener("popstate", this.onPopstate);
    this.paint();
  }

  disconnectedCallback(): void {
    window.removeEventListener("popstate", this.onPopstate);
  }

  // The app's own links: one real history entry per move, written
  // under the base.
  private navigate(path: string): void {
    window.history.pushState(null, "", `${this.basePath}${path}`);
    this.paint();
  }

  private screenName(): string {
    const { pathname } = window.location;
    if (!pathname.startsWith(this.basePath)) return "not found";
    const path = pathname.slice(this.basePath.length) || "/";
    return SCREENS[path] ?? "not found";
  }

  private paint(): void {
    this.replaceChildren();
    const label = document.createElement("p");
    label.textContent = `screen: ${this.screenName()}`;
    this.append(label);
    for (const [path, name] of Object.entries(SCREENS)) {
      const button = document.createElement("button");
      button.textContent = `go ${name}`;
      button.addEventListener("click", () => this.navigate(path));
      this.append(button);
    }
  }
}

customElements.define("reports-app", ReportsApp);

// Given: TypeScript has never heard of <reports-app>, 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 {
      "reports-app": { ref?: React.Ref<HTMLElement>; base?: string };
    }
  }
}

// The host's route table, first match wins. "/reports/*" is a splat
// route: "/reports" and anything under it, however deep - the whole
// of the app's slice.
const ROUTES = ["/home", "/reports/*"] as const;
type Route = (typeof ROUTES)[number];

// --- TODO 1 ----------------------------------------------------------
// The host matches every route as an exact path, so "/reports/*" only
// ever matches "/reports". A link to /reports/q3/summary finds no
// route at all, the host paints its own "not found", and the app
// never gets to read the rest of the path. Make a splat route match
// its root and everything under it, in whole segments - the pieces
// between the slashes. /reports-archive only starts with the same
// letters, and it stays the host's.
function matchRoute(pathname: string): Route | null {
  for (const route of ROUTES) {
    const root = route.replace("/*", "");
    if (pathname === root) return route; // BROKEN: exact match only
  }
  return null;
}

// --- TODO 2 ----------------------------------------------------------
// The host mounts the app but never tells it where its slice starts,
// so the app's router takes the whole path as its own. It has no
// screen called /reports/q3/summary and paints "not found", and its
// buttons write at the root of the address bar: "go settings" puts
// /settings there, not /reports/settings - a path the host never gave
// away. Hand the app its base, "/reports", as a prop on its tag.
function ReportsPage() {
  return <reports-app />; // BROKEN: never told where its slice starts
}

function HostApp() {
  const [pathname, setPathname] = useState(
    () => window.location.pathname,
  );

  // Given: the host's own links go through here. Each writes one
  // history entry and updates `pathname` in the same move, so the
  // address bar and the page agree on every move the host makes.
  const navigate = (to: string): void => {
    window.history.pushState(null, "", to);
    setPathname(to);
  };

  // --- TODO 3 --------------------------------------------------------
  // Back fires one popstate at the window. The app is listening and
  // repaints its own screen, but the host is not: `pathname` stays on
  // the last path the host wrote itself. Inside the slice nobody
  // notices. Walk Back out of it and the reports app stays on screen
  // at /home. Subscribe to popstate, set `pathname` from
  // window.location.pathname when it fires, and take the listener off
  // in the cleanup. Nothing more: a Back that stays inside /reports
  // has to leave the very same element on screen.
  useEffect(() => {
    return () => {}; // BROKEN: subscribes to nothing
  }, []);

  const route = matchRoute(pathname);
  if (route === "/reports/*") return <ReportsPage />;
  if (route === "/home") {
    return (
      <>
        <p>home</p>
        <button onClick={() => navigate("/reports")}>
          open reports
        </button>
      </>
    );
  }
  return <p>not found</p>;
}

// Opens the host at a URL, the way a pasted link would, and hands back
// the reports app if the host mounted one.
function openAppAt(url: string): ReportsApp | null {
  window.history.replaceState(null, "", url);
  render(<HostApp />);
  return appOnScreen();
}

// Whichever reports app is on the page right now, if any.
function appOnScreen(): ReportsApp | null {
  return document.querySelector<ReportsApp>("reports-app");
}

// Clicks a button by its name - the host's buttons and the app's
// alike, since they all sit in the one document.
function click(name: string): void {
  fireEvent.click(screen.getByRole("button", { name }));
}

// A real Back button does two things: it moves the history pointer
// back an entry, then it fires one popstate at the window. The tests
// do both by hand so nothing here waits on a timer.
function pressBack(to: string): void {
  window.history.replaceState(null, "", to);
  fireEvent(window, new PopStateEvent("popstate"));
}

describe("Module 10 / Exercise 9 — a slice of the url", () => {
  it("TODO 1 — everything under /reports goes to the app", () => {
    expect(
      openAppAt("/reports/q3/summary"),
      "TODO 1 — a link deep inside /reports has to mount the app",
    ).not.toBeNull();

    cleanup(); // same session, the root of the slice itself
    expect(
      openAppAt("/reports"),
      "TODO 1 — /reports itself is part of the slice too",
    ).not.toBeNull();

    cleanup(); // a path that only starts with the same letters
    expect(
      openAppAt("/reports-archive"),
      "TODO 1 — /reports-archive is the host's, not under /reports",
    ).toBeNull();

    cleanup(); // and a page the host kept for itself
    expect(
      openAppAt("/home"),
      "TODO 1 — /home is the host's own page, not the app's",
    ).toBeNull();
  });

  it("TODO 2 — the app reads and writes only its own slice", () => {
    const app = openAppAt("/reports/q3/summary");
    expect(
      app,
      "TODO 2 setup — the host has to hand this link to the app",
    ).not.toBeNull();
    expect(
      app?.textContent,
      "TODO 2 — /reports/q3/summary has to open the summary",
    ).toContain("screen: summary");

    click("go settings");

    expect(
      window.location.pathname,
      "TODO 2 — the app's own links have to stay inside its slice",
    ).toBe("/reports/settings");
    expect(
      appOnScreen(),
      "TODO 2 — the host heard nothing and changed nothing",
    ).toBe(app);
  });

  it("TODO 3 — Back walks the app's screens, then out of it", () => {
    openAppAt("/home");
    click("open reports");
    click("go summary");
    click("go settings");
    const app = appOnScreen();
    expect(
      window.location.pathname,
      "TODO 3 setup — in by the host's link, two moves inside",
    ).toBe("/reports/settings");

    pressBack("/reports/q3/summary");
    expect(
      app?.textContent,
      "TODO 3 — a Back inside the slice is the app's to handle",
    ).toContain("screen: summary");
    expect(
      appOnScreen(),
      "TODO 3 — same match, same element: nothing may remount it",
    ).toBe(app);
    expect(
      app?.connects,
      "TODO 3 — the app booted once and never again",
    ).toBe(1);

    pressBack("/reports");
    expect(
      app?.textContent,
      "TODO 3 — one more Back, and the app is on its first screen",
    ).toContain("screen: overview");

    pressBack("/home");
    expect(
      appOnScreen(),
      "TODO 3 — leaving the slice has to take the app off the page",
    ).toBeNull();
    expect(
      screen.queryByText("home"),
      "TODO 3 — and put the host's own page back",
    ).not.toBeNull();
  });
});

Solution