2 router, walker, leaf
GOAL
finish a recursive skill-tree renderer's three jobs - route on kind, walk the unlocks one tier deeper, stop at a talent.
CONCEPT
a recursive component never calls itself blindly. A router picks the component for each node, a walker maps that node's unlocks back through the router at tier + 1, and the leaf draws itself and recurses into nothing.
HINT
exactly one of the three functions ever writes tier + 1.
MIRRORS
the pause menu's skill tree. One entry component asks what a node is, a discipline redraws everything it unlocks one tier in, and a talent ends the line. Forget the base case and the HUD redraws until the stack gives out.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 07-recursive-components/02-router-walker-leaf.tsxSource
// DOJO · Module 7 / Exercise 2 — router, walker, leaf
// GOAL: finish a recursive skill-tree renderer's three jobs - route on
// kind, walk the unlocks one tier deeper, stop at a talent.
// CONCEPT: a recursive component never calls itself blindly. A router
// picks the component for each node, a walker maps that node's
// unlocks back through the router at tier + 1, and the leaf
// draws itself and recurses into nothing.
// HINT: exactly one of the three functions ever writes tier + 1.
// MIRRORS: the pause menu's skill tree. One entry component asks what a
// node is, a discipline redraws everything it unlocks one tier
// in, and a talent ends the line. Forget the base case and the
// HUD redraws until the stack gives out.
// Run: pnpm koan 07-recursive-components/02-router-walker-leaf.tsx
import type { ReactNode } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
afterEach(cleanup);
interface SkillNode {
kind: "discipline" | "talent";
name: string;
unlocks: SkillNode[];
}
const SKILL_TREE: SkillNode = {
kind: "discipline",
name: "combat",
unlocks: [
{
kind: "discipline",
name: "blades",
unlocks: [{ kind: "talent", name: "riposte", unlocks: [] }],
},
{ kind: "talent", name: "shield-bash", unlocks: [] },
],
};
interface EntryProps {
node: SkillNode;
tier: number;
}
function SkillEntry({ node, tier }: EntryProps) {
// -- TODO 1 --------------------------------------------------------
// The router's one job: read node.kind and hand the node to the
// component that knows how to draw it. As shipped it sends EVERY node
// to TalentEntry, so a discipline never gets a chance to draw what it
// unlocks.
return <TalentEntry node={node} tier={tier} />;
}
function DisciplineEntry({ node, tier }: EntryProps) {
// -- TODO 2 --------------------------------------------------------
// The walker's one job: draw every entry of node.unlocks back through
// SkillEntry, each one tier deeper than this entry. As shipped
// unlockedEntries is null, so the recursion never starts.
const unlockedEntries: ReactNode = null;
return (
<div>
<div data-skill={node.name} data-kind="discipline" data-tier={tier}>
{node.name}
</div>
{unlockedEntries}
</div>
);
}
function TalentEntry({ node, tier }: EntryProps) {
// -- TODO 3 --------------------------------------------------------
// The base case: draw the name with data-skill, data-kind="talent" and
// data-tier attributes (match DisciplineEntry's entry div), and
// recurse into nothing. As shipped a talent draws nothing at all.
return null;
}
describe("Module 7 / Exercise 2 - router, walker, leaf", () => {
it("TODO 1 - the router sends a discipline to DisciplineEntry", () => {
render(<SkillEntry node={SKILL_TREE} tier={0} />);
const root = screen.queryByText("combat");
expect(
root?.getAttribute("data-kind"),
"TODO 1: the router must send a discipline node to DisciplineEntry, " +
"not to TalentEntry",
).toBe("discipline");
expect(
root?.getAttribute("data-tier"),
"TODO 1: the node the router was handed draws at the tier it was " +
"given, here 0",
).toBe("0");
});
it("TODO 2 - the walker draws the unlocks one tier deeper", () => {
render(<SkillEntry node={SKILL_TREE} tier={0} />);
const blades = screen.queryByText("blades");
expect(
blades?.getAttribute("data-kind"),
"TODO 2: a nested discipline only appears once the walker maps " +
"node.unlocks back through SkillEntry",
).toBe("discipline");
expect(
blades?.getAttribute("data-tier"),
"TODO 2: the walker is the only place tier + 1 is written",
).toBe("1");
});
it("TODO 3 - the talent base case draws, and the tree reads in order", () => {
const { container } = render(<SkillEntry node={SKILL_TREE} tier={0} />);
expect(
screen.queryByText("riposte")?.getAttribute("data-kind"),
"TODO 3: a talent draws itself and stops",
).toBe("talent");
expect(
screen.queryByText("riposte")?.getAttribute("data-tier"),
"TODO 3: riposte sits two tiers under the root",
).toBe("2");
expect(
screen.queryByText("shield-bash")?.getAttribute("data-tier"),
"TODO 3: shield-bash is unlocked straight off the root, so tier 1",
).toBe("1");
const names = Array.from(container.querySelectorAll("[data-skill]")).map(
(el) => el.getAttribute("data-skill"),
);
expect(
names,
"TODO 3: the three jobs together walk the tree depth-first, so the " +
"entries read in this order",
).toEqual(["combat", "blades", "riposte", "shield-bash"]);
});
});